diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b45ea5..5292f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ - Direct composition via `Owner.Compose()` and `Anchors.Compose()` - Methods: `Set`, `Replace`, `Get`, `GetOrThrow`, `TryGet`, `Compose`, `GetComposition` - Comprehensive documentation in ADR, README, API reference, and quick reference guide +- **Using* Extension Methods**: Fluent convenience methods for inline capability usage + - `UsingFirst()`, `UsingFirstOrDefault()` - Use first capability with action or function + - `UsingLast()`, `UsingLastOrDefault()` - Use last capability with action or function + - `UsingEach()` - Execute action or function for each capability + - `UsingAll()` - Execute action or function with full collection + - All methods maintain semantic consistency with existing `Get*` methods + - Action-based overloads return `IComposition` for chaining + - Function-based overloads return results directly ## [1.0.0] - 2025-10-10 diff --git a/adr/2025-10-25-using-extensions.md b/adr/2025-10-25-using-extensions.md new file mode 100644 index 0000000..8ab795c --- /dev/null +++ b/adr/2025-10-25-using-extensions.md @@ -0,0 +1,326 @@ +# ADR-002: Introduce `Using*` Extension Methods for Fluent Capability Usage + +**Status**: Accepted +**Date**: 2025-10-25 +**Decision type**: API Enhancement +**Scope**: Cocoar.Capabilities (framework-wide) + +--- + +## Context + +The `IComposition` interface provides explicit retrieval methods for capabilities: + +- `GetRequiredFirst()` / `GetFirstOrDefault()` / `TryGetFirst(out T?)` +- `GetRequiredLast()` / `GetLastOrDefault()` / `TryGetLast(out T?)` +- `GetAll()` + +While functionally complete, common usage patterns like "retrieve and immediately use" require verbose code: + +```csharp +var logger = composition.GetRequiredFirst(); +logger.Info("started"); + +var handlers = composition.GetAll(); +foreach (var handler in handlers) + handler.Handle(evt); +``` + +For inline usage where the capability instance isn't needed afterward, this feels heavyweight. We want to provide **ergonomic convenience methods** that: + +1. Support fluent, chainable usage for common patterns +2. Maintain 100% semantic consistency with existing `Get*` methods +3. Cover both single-instance and multi-instance scenarios +4. Preserve explicit intent (first/last/each/all) + +--- + +## Decision + +Introduce a comprehensive set of **`Using*` extension methods** on `IComposition` that mirror the existing `Get*` API surface, optimized for inline/fluent usage. + +These methods are **pure convenience wrappers** with **zero behavioral changes**—they directly delegate to existing retrieval methods and execute user-provided delegates. + +--- + +## API Surface + +```csharp +namespace Cocoar.Capabilities; + +public static class CompositionUsingExtensions +{ + // ============= FIRST ============= + + /// + /// Uses the first capability of type . + /// Throws if no instances exist. Returns composition for chaining. + /// + public static IComposition UsingFirst(this IComposition composition, Action use) + where T : class; + + /// + /// Uses the first capability of type and returns a result. + /// Throws if no instances exist. + /// + public static TResult UsingFirst(this IComposition composition, Func use) + where T : class; + + /// + /// Uses the first capability of type if it exists. + /// Silently skips if no instances exist. Returns composition for chaining. + /// + public static IComposition UsingFirstOrDefault(this IComposition composition, Action use) + where T : class; + + // ============= LAST ============= + + /// + /// Uses the last capability of type . + /// Throws if no instances exist. Returns composition for chaining. + /// + public static IComposition UsingLast(this IComposition composition, Action use) + where T : class; + + /// + /// Uses the last capability of type and returns a result. + /// Throws if no instances exist. + /// + public static TResult UsingLast(this IComposition composition, Func use) + where T : class; + + /// + /// Uses the last capability of type if it exists. + /// Silently skips if no instances exist. Returns composition for chaining. + /// + public static IComposition UsingLastOrDefault(this IComposition composition, Action use) + where T : class; + + // ============= EACH ============= + + /// + /// Executes for each capability of type . + /// Silent if no instances exist (iterates empty collection). Returns composition for chaining. + /// + public static IComposition UsingEach(this IComposition composition, Action use) + where T : class; + + /// + /// Executes for each capability of type and collects results. + /// Returns a list of results (empty if no instances exist). + /// + public static IReadOnlyList UsingEach(this IComposition composition, Func use) + where T : class; + + // ============= ALL ============= + + /// + /// Executes with the full collection of capabilities. + /// Use for aggregations or collection-level operations. Returns composition for chaining. + /// + public static IComposition UsingAll(this IComposition composition, Action> use) + where T : class; + + /// + /// Executes with the full collection of capabilities and returns a result. + /// Use for aggregations or collection-level operations. + /// + public static TResult UsingAll(this IComposition composition, Func, TResult> use) + where T : class; +} +``` + +--- + +## Usage Examples + +### Single Instance Usage + +```csharp +// Required first (throws if missing) +composition.UsingFirst(log => log.Info("started")); + +// Required first with result +var count = composition.UsingFirst(repo => repo.Count()); + +// Optional first (silent if missing, chainable) +composition + .UsingFirstOrDefault(log => log.Info("started")) + .UsingFirst(m => m.Inc()); + +// Last instance (when order matters) +composition.UsingLast(m => m.Execute()); +``` + +### Multiple Instance Usage + +```csharp +// Process each capability individually +composition.UsingEach(handler => handler.Handle(evt)); + +// Map each to a result +var counts = composition.UsingEach(repo => repo.Count()); +// Returns: [5, 12, 8] + +// Aggregate operation on collection +var total = composition.UsingAll(repos => repos.Sum(r => r.Count())); +// Returns: 25 + +// Collection operation (chainable) +composition.UsingAll(plugins => +{ + foreach (var p in plugins) + p.Initialize(); +}); +``` + +### Fluent Chaining + +```csharp +composition + .UsingFirst(log => log.Info("starting")) + .UsingEach(p => p.Initialize()) + .UsingFirstOrDefault(f => f.Configure()) + .UsingAll(handlers => handlers.ForEach(h => h.Register())) + .UsingLast(m => m.Inc("startup")); +``` + +--- + +## Semantics & Guarantees + +### Delegation to Existing Methods + +All `Using*` methods **directly delegate** to existing `IComposition` methods: + +| `Using*` Method | Delegates To | +|----------------|--------------| +| `UsingFirst()` | `GetRequiredFirst()` | +| `UsingFirstOrDefault()` | `GetFirstOrDefault()` | +| `UsingLast()` | `GetRequiredLast()` | +| `UsingLastOrDefault()` | `GetLastOrDefault()` | +| `UsingEach()` | `GetAll()` | +| `UsingAll()` | `GetAll()` | + +### Exception Behavior + +- **`UsingFirst()` / `UsingLast()`**: Throw if no instances exist (same as `GetRequired*`) +- **`UsingFirstOrDefault()` / `UsingLastOrDefault()`**: Silent if missing +- **`UsingEach()` / `UsingAll()`**: Silent if collection is empty + +### Chainability + +- All `Action`-based overloads return `IComposition` for fluent chaining +- All `Func`-based overloads return `TResult` (terminate the chain) + +### Thread Safety & Lifetime + +- **No additional isolation or scoping**: `Using*` methods provide **zero** lifetime management or thread safety beyond what the underlying composition provides +- They are **pure convenience wrappers** for inline execution + +--- + +## Consequences + +### Positive + +✅ **Improved ergonomics** for common inline usage patterns +✅ **Fluent, chainable API** for sequential capability usage +✅ **100% consistency** with existing `Get*` semantics +✅ **Zero behavioral changes** — pure convenience layer +✅ **Self-documenting intent** via explicit method names (`First`, `Last`, `Each`, `All`) +✅ **Complete coverage** of single/multi-instance scenarios + +### Neutral + +⚠️ **Expanded API surface** — 10 new methods, but all follow a consistent, predictable pattern +⚠️ **No "simple" `Using()`** — users must choose `First`, `Last`, `Each`, or `All` (this is intentional to avoid ambiguity) + +### Negative + +None identified. The extension methods are opt-in and don't affect existing code. + +--- + +## Alternatives Considered + +### 1. Generic `Using()` without position specifiers + +**Rejected**: Ambiguous when multiple instances exist. Which one gets used? Forces users to guess or read implementation. + +### 2. Different naming: `ActAs()`, `As()`, `Use()` + +**Rejected**: +- `ActAs` implies the composition "becomes" the capability (wrong mental model) +- `As` is too generic and doesn't convey position (first/last) +- `Use` without position is ambiguous + +`Using*` with explicit position (`First`, `Last`, `Each`, `All`) is clearer and mirrors the existing API. + +### 3. Only add `UsingFirst` and `UsingEach` (minimal set) + +**Rejected**: Incomplete. Users need `Last` for ordered scenarios, `Try*` for optional capabilities, and `*OrDefault` for silent fallback in chains. + +### 4. Return `bool` from `TryUsing*` methods + +**Rejected**: Breaks chainability. Returning `IComposition` allows fluent chaining while still being silent on missing capabilities. Users who need explicit success/failure checks can use `TryGet*` directly. + +--- + +## Backward Compatibility & Migration + +- **100% backward compatible**: New extension methods, no changes to existing API +- **Opt-in**: Teams can adopt incrementally or not at all +- **No performance impact**: Direct delegation with zero overhead + +--- + +## Testing Strategy + +### Unit Tests + +- Verify each `Using*` method delegates correctly to its `Get*` counterpart +- Verify exception behavior (throwing vs. silent) +- Verify return types (chainable vs. result) +- Verify `*OrDefault` silent behavior +- Verify `UsingEach` iteration over all instances +- Verify `UsingAll` receives full collection + +### Integration Tests + +- Fluent chaining scenarios (multiple `Using*` calls in sequence) +- Mixed required/optional capability usage +- Empty collection handling (`UsingEach`, `UsingAll`) +- Exception propagation from user delegates + +--- + +## Documentation Notes + +- Position `Using*` as **convenience methods for inline usage** +- Recommend using `Get*` when you need to store/reuse the capability instance +- Recommend using `Using*` when you immediately invoke and don't need the instance afterward +- Document chainability patterns and when to break chains (when you need a result) +- Add examples showing `First` vs. `Each` vs. `All` distinctions + +--- + +## Future Work (non-breaking extensions) + +- **Diagnostics**: Enhanced exceptions that show available capabilities, positions, or composition context +- **Analyzers**: Suggest `Using*` over `Get*` + immediate invocation patterns +- **Additional overloads**: If new retrieval patterns are added to `IComposition`, mirror them with corresponding `Using*` methods + +--- + +## Decision Rationale + +The `Using*` extensions solve a real ergonomic pain point (verbose retrieve-and-use patterns) without introducing semantic complexity or behavioral changes. By mirroring the existing `Get*` API structure, they feel like a natural extension of the framework rather than a separate concept. + +The explicit position/collection specifiers (`First`, `Last`, `Each`, `All`) prevent ambiguity and make call sites self-documenting, aligning with the framework's design philosophy of clarity over brevity. + +--- + +## Summary + +Introduce 10 `Using*` extension methods that provide fluent, chainable, inline capability usage while maintaining perfect semantic consistency with the existing `IComposition` API. These are pure convenience wrappers with zero behavioral changes. diff --git a/docs/api-reference.md b/docs/api-reference.md index 882e02b..ed43ecc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -10,6 +10,7 @@ Complete API reference for the Cocoar.Capabilities library. - [ScopeAnchorsApi](#scopeanchorsapi) - [Composer](#composer) - [IComposition](#icomposition) +- [Using Extensions](#using-extensions) - [IPrimaryCapability](#iprimarycapability) - [ComposerRegistryApi](#composerregistryapi) - [CompositionRegistryApi](#compositionregistryapi) @@ -344,6 +345,145 @@ var userPrimary = composition.GetRequiredPrimaryAs(); --- +## Using Extensions + +Fluent extension methods for inline capability usage. These are convenience wrappers over the `Get*` methods that enable chainable, expressive usage patterns. + +**Extension methods on `IComposition`** + +### Single Instance Methods (First) + +| Method | Returns | Description | +|--------|---------|-------------| +| `UsingFirst(Action use)` | `IComposition` | Uses the first T capability; throws if none exist. Chainable. | +| `UsingFirst(Func use)` | `TResult` | Uses the first T capability and returns a result; throws if none exist | +| `UsingFirstOrDefault(Action use)` | `IComposition` | Uses the first T capability if it exists; silent if missing. Chainable. | + +### Single Instance Methods (Last) + +| Method | Returns | Description | +|--------|---------|-------------| +| `UsingLast(Action use)` | `IComposition` | Uses the last T capability; throws if none exist. Chainable. | +| `UsingLast(Func use)` | `TResult` | Uses the last T capability and returns a result; throws if none exist | +| `UsingLastOrDefault(Action use)` | `IComposition` | Uses the last T capability if it exists; silent if missing. Chainable. | + +### Multiple Instance Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `UsingEach(Action use)` | `IComposition` | Executes action for each T capability; silent if none exist. Chainable. | +| `UsingEach(Func use)` | `IReadOnlyList` | Executes function for each T capability and collects results | +| `UsingAll(Action> use)` | `IComposition` | Executes action with full collection of T capabilities. Chainable. | +| `UsingAll(Func, TResult> use)` | `TResult` | Executes function with full collection and returns a result | + +### Delegation Mapping + +All `Using*` methods delegate directly to existing `IComposition` methods: + +| `Using*` Method | Delegates To | +|----------------|--------------| +| `UsingFirst()` | `GetRequiredFirst()` | +| `UsingFirstOrDefault()` | `GetFirstOrDefault()` | +| `UsingLast()` | `GetRequiredLast()` | +| `UsingLastOrDefault()` | `GetLastOrDefault()` | +| `UsingEach()` / `UsingAll()` | `GetAll()` | + +### Examples + +**Single instance usage:** + +```csharp +// Required first (throws if missing) +composition.UsingFirst(log => log.Info("started")); + +// Required first with result +var count = composition.UsingFirst(repo => repo.Count()); + +// Optional (silent if missing) +composition.UsingFirstOrDefault(log => log.Info("started")); + +// Last instance (when order matters) +composition.UsingLast(m => m.Execute()); +``` + +**Multiple instance usage:** + +```csharp +// Process each capability individually +composition.UsingEach(handler => handler.Handle(evt)); + +// Map each to a result +var counts = composition.UsingEach(repo => repo.Count()); +// Returns: [5, 12, 8] + +// Aggregate operation on collection +var total = composition.UsingAll( + repos => repos.Sum(r => r.Count()) +); +// Returns: 25 + +// Collection operation (chainable) +composition.UsingAll(plugins => +{ + foreach (var p in plugins) + p.Initialize(); +}); +``` + +**Fluent chaining:** + +```csharp +composition + .UsingFirst(log => log.Info("starting")) + .UsingEach(p => p.Initialize()) + .UsingFirstOrDefault(f => f.Configure()) + .UsingAll(handlers => + { + foreach (var h in handlers) + h.Register(); + }) + .UsingLast(m => m.Inc("startup")); +``` + +**When to use `Using*` vs `Get*`:** + +```csharp +// Use Get* when you need to store/reuse the instance +var logger = composition.GetRequiredFirst(); +logger.Info("step 1"); +logger.Info("step 2"); + +// Use Using* for inline/one-off usage +composition.UsingFirst(log => log.Info("one-off message")); + +// Use Using* for fluent chaining +composition + .UsingFirst(log => log.Info("starting")) + .UsingEach(v => v.Validate()); +``` + +### Exception Behavior + +| Method | Behavior | +|--------|----------| +| `UsingFirst()` / `UsingLast()` | Throws if no instances exist | +| `UsingFirstOrDefault()` / `UsingLastOrDefault()` | Silent if missing (no-op) | +| `UsingEach()` / `UsingAll()` | Silent if collection is empty | + +### Thread Safety + +- ✅ All `Using*` methods are thread-safe (they operate on immutable `IComposition` instances) +- ✅ User-provided delegates may execute concurrently if the composition is shared across threads +- ⚠️ User code inside delegates is responsible for its own thread safety + +### Performance + +- **Zero allocation overhead** beyond the user delegate itself +- Direct delegation to existing `Get*` methods (no intermediate objects) +- Chainable methods return the same `IComposition` instance (no copying) + +--- + ## IPrimaryCapability Marker interface indicating a capability that should be the primary capability for an instance. diff --git a/src/Cocoar.Capabilities.Tests/CompositionUsingExtensionsTests.cs b/src/Cocoar.Capabilities.Tests/CompositionUsingExtensionsTests.cs new file mode 100644 index 0000000..f61714d --- /dev/null +++ b/src/Cocoar.Capabilities.Tests/CompositionUsingExtensionsTests.cs @@ -0,0 +1,443 @@ +namespace Cocoar.Capabilities.Tests; + +public class CompositionUsingExtensionsTests +{ + private interface ITestCapability + { + void Execute(); + int GetValue(); + } + + private class TestCapability : ITestCapability + { + public int Value { get; } + public bool WasExecuted { get; private set; } + + public TestCapability(int value) + { + Value = value; + } + + public void Execute() + { + WasExecuted = true; + } + + public int GetValue() => Value; + } + + [Fact] + public void UsingFirst_WithAction_ExecutesActionAndReturnsComposition() + { + using var scope = new CapabilityScope(); + var capability = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(capability) + .Build(); + + var result = composition.UsingFirst(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(capability.WasExecuted); + } + + [Fact] + public void UsingFirst_WithFunc_ReturnsResult() + { + using var scope = new CapabilityScope(); + var capability = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(capability) + .Build(); + + var result = composition.UsingFirst(cap => cap.GetValue()); + + Assert.Equal(42, result); + } + + [Fact] + public void UsingFirst_WhenCapabilityMissing_Throws() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + Assert.Throws(() => + composition.UsingFirst(cap => cap.Execute())); + } + + [Fact] + public void UsingFirst_IsChainable() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var composition = scope.Compose(new object()) + .AddAs(cap1) + .AddAs(cap2) + .Build(); + + var result = composition + .UsingFirst(cap => cap.Execute()) + .UsingLast(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(cap1.WasExecuted); + Assert.True(cap2.WasExecuted); + } + + [Fact] + public void UsingFirstOrDefault_WhenCapabilityExists_ExecutesAction() + { + using var scope = new CapabilityScope(); + var capability = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(capability) + .Build(); + + var result = composition.UsingFirstOrDefault(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(capability.WasExecuted); + } + + [Fact] + public void UsingFirstOrDefault_WhenCapabilityMissing_DoesNotThrow() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + var executed = false; + + var result = composition.UsingFirstOrDefault(cap => executed = true); + + Assert.Same(composition, result); + Assert.False(executed); + } + + [Fact] + public void UsingLast_WithAction_ExecutesActionAndReturnsComposition() + { + using var scope = new CapabilityScope(); + var capability = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(capability) + .Build(); + + var result = composition.UsingLast(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(capability.WasExecuted); + } + + [Fact] + public void UsingLast_WithFunc_ReturnsResult() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var composition = scope.Compose(new object()) + .AddAs(cap1, order: 1) + .AddAs(cap2, order: 2) + .Build(); + + var result = composition.UsingLast(cap => cap.GetValue()); + + Assert.Equal(2, result); + } + + [Fact] + public void UsingLast_WhenCapabilityMissing_Throws() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + Assert.Throws(() => + composition.UsingLast(cap => cap.Execute())); + } + + [Fact] + public void UsingLastOrDefault_WhenCapabilityExists_ExecutesAction() + { + using var scope = new CapabilityScope(); + var capability = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(capability) + .Build(); + + var result = composition.UsingLastOrDefault(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(capability.WasExecuted); + } + + [Fact] + public void UsingLastOrDefault_WhenCapabilityMissing_DoesNotThrow() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + var executed = false; + + var result = composition.UsingLastOrDefault(cap => executed = true); + + Assert.Same(composition, result); + Assert.False(executed); + } + + [Fact] + public void UsingEach_WithAction_ExecutesForEachCapability() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var cap3 = new TestCapability(3); + var composition = scope.Compose(new object()) + .AddAs(cap1) + .AddAs(cap2) + .AddAs(cap3) + .Build(); + + var result = composition.UsingEach(cap => cap.Execute()); + + Assert.Same(composition, result); + Assert.True(cap1.WasExecuted); + Assert.True(cap2.WasExecuted); + Assert.True(cap3.WasExecuted); + } + + [Fact] + public void UsingEach_WithFunc_CollectsResults() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var cap3 = new TestCapability(3); + var composition = scope.Compose(new object()) + .AddAs(cap1, order: 1) + .AddAs(cap2, order: 2) + .AddAs(cap3, order: 3) + .Build(); + + var results = composition.UsingEach(cap => cap.GetValue()); + + Assert.Equal(3, results.Count); + Assert.Equal(1, results[0]); + Assert.Equal(2, results[1]); + Assert.Equal(3, results[2]); + } + + [Fact] + public void UsingEach_WithAction_WhenEmpty_DoesNotThrow() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + var executed = false; + + var result = composition.UsingEach(cap => executed = true); + + Assert.Same(composition, result); + Assert.False(executed); + } + + [Fact] + public void UsingEach_WithFunc_WhenEmpty_ReturnsEmptyList() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + var results = composition.UsingEach(cap => cap.GetValue()); + + Assert.Empty(results); + } + + [Fact] + public void UsingEach_IsChainable() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var composition = scope.Compose(new object()) + .AddAs(cap1) + .AddAs(cap2) + .Build(); + + var result = composition + .UsingEach(cap => cap.Execute()) + .UsingFirst(cap => { }); + + Assert.Same(composition, result); + Assert.True(cap1.WasExecuted); + Assert.True(cap2.WasExecuted); + } + + [Fact] + public void UsingAll_WithAction_ReceivesFullCollection() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var cap3 = new TestCapability(3); + var composition = scope.Compose(new object()) + .AddAs(cap1) + .AddAs(cap2) + .AddAs(cap3) + .Build(); + + IReadOnlyList? receivedCollection = null; + + var result = composition.UsingAll(caps => + { + receivedCollection = caps; + }); + + Assert.Same(composition, result); + Assert.NotNull(receivedCollection); + Assert.Equal(3, receivedCollection.Count); + } + + [Fact] + public void UsingAll_WithFunc_ReturnsAggregatedResult() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var cap3 = new TestCapability(3); + var composition = scope.Compose(new object()) + .AddAs(cap1) + .AddAs(cap2) + .AddAs(cap3) + .Build(); + + var sum = composition.UsingAll(caps => + caps.Sum(c => c.GetValue())); + + Assert.Equal(6, sum); + } + + [Fact] + public void UsingAll_WithAction_WhenEmpty_ExecutesWithEmptyCollection() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + IReadOnlyList? receivedCollection = null; + + var result = composition.UsingAll(caps => + { + receivedCollection = caps; + }); + + Assert.Same(composition, result); + Assert.NotNull(receivedCollection); + Assert.Empty(receivedCollection); + } + + [Fact] + public void UsingAll_WithFunc_WhenEmpty_ExecutesWithEmptyCollection() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + var count = composition.UsingAll(caps => caps.Count); + + Assert.Equal(0, count); + } + + [Fact] + public void UsingFirst_NullComposition_Throws() + { + Assert.Throws(() => + ((IComposition)null!).UsingFirst(cap => { })); + } + + [Fact] + public void UsingFirst_NullAction_Throws() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + Assert.Throws(() => + composition.UsingFirst(null!)); + } + + [Fact] + public void UsingEach_NullComposition_Throws() + { + Assert.Throws(() => + ((IComposition)null!).UsingEach(cap => { })); + } + + [Fact] + public void UsingEach_NullAction_Throws() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + Assert.Throws(() => + composition.UsingEach((Action)null!)); + } + + [Fact] + public void UsingAll_NullComposition_Throws() + { + Assert.Throws(() => + ((IComposition)null!).UsingAll(caps => { })); + } + + [Fact] + public void UsingAll_NullAction_Throws() + { + using var scope = new CapabilityScope(); + var composition = scope.Compose(new object()).Build(); + + Assert.Throws(() => + composition.UsingAll((Action>)null!)); + } + + [Fact] + public void ComplexChain_MixedUsingMethods_WorksCorrectly() + { + using var scope = new CapabilityScope(); + var cap1 = new TestCapability(1); + var cap2 = new TestCapability(2); + var cap3 = new TestCapability(3); + var composition = scope.Compose(new object()) + .AddAs(cap1, order: 1) + .AddAs(cap2, order: 2) + .AddAs(cap3, order: 3) + .Build(); + + var executionLog = new List(); + + var result = composition + .UsingFirst(cap => executionLog.Add($"First: {cap.GetValue()}")) + .UsingEach(cap => executionLog.Add($"Each: {cap.GetValue()}")) + .UsingLast(cap => executionLog.Add($"Last: {cap.GetValue()}")) + .UsingAll(caps => executionLog.Add($"All count: {caps.Count}")); + + Assert.Same(composition, result); + Assert.Equal(6, executionLog.Count); + Assert.Equal("First: 1", executionLog[0]); + Assert.Equal("Each: 1", executionLog[1]); + Assert.Equal("Each: 2", executionLog[2]); + Assert.Equal("Each: 3", executionLog[3]); + Assert.Equal("Last: 3", executionLog[4]); + Assert.Equal("All count: 3", executionLog[5]); + } + + [Fact] + public void ChainWithOrDefault_WhenMissing_ContinuesChain() + { + using var scope = new CapabilityScope(); + var cap = new TestCapability(42); + var composition = scope.Compose(new object()) + .AddAs(cap) + .Build(); + + var executed = false; + + var result = composition + .UsingFirstOrDefault(obj => { }) + .UsingFirst(c => executed = true); + + Assert.Same(composition, result); + Assert.True(executed); + } +} diff --git a/src/Cocoar.Capabilities/CompositionUsingExtensions.cs b/src/Cocoar.Capabilities/CompositionUsingExtensions.cs new file mode 100644 index 0000000..0561d9e --- /dev/null +++ b/src/Cocoar.Capabilities/CompositionUsingExtensions.cs @@ -0,0 +1,217 @@ +namespace Cocoar.Capabilities; + +/// +/// Fluent extension methods for inline capability usage on . +/// These are convenience wrappers over the Get* methods that enable chainable, expressive usage patterns. +/// +public static class CompositionUsingExtensions +{ + // ============= FIRST ============= + + /// + /// Uses the first capability of type . + /// Throws if no instances exist. Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve the capability from. + /// The action to execute with the capability. + /// The same composition instance for fluent chaining. + /// If no capability of type exists. + public static IComposition UsingFirst(this IComposition composition, Action use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + use(composition.GetRequiredFirst()); + return composition; + } + + /// + /// Uses the first capability of type and returns a result. + /// Throws if no instances exist. + /// + /// The capability type to use. + /// The type of result to return. + /// The composition to retrieve the capability from. + /// The function to execute with the capability. + /// The result of the function. + /// If no capability of type exists. + public static TResult UsingFirst(this IComposition composition, Func use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + return use(composition.GetRequiredFirst()); + } + + /// + /// Uses the first capability of type if it exists. + /// Silently skips if no instances exist. Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve the capability from. + /// The action to execute with the capability. + /// The same composition instance for fluent chaining. + public static IComposition UsingFirstOrDefault(this IComposition composition, Action use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + var cap = composition.GetFirstOrDefault(); + if (cap != null) + { + use(cap); + } + return composition; + } + + // ============= LAST ============= + + /// + /// Uses the last capability of type . + /// Throws if no instances exist. Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve the capability from. + /// The action to execute with the capability. + /// The same composition instance for fluent chaining. + /// If no capability of type exists. + public static IComposition UsingLast(this IComposition composition, Action use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + use(composition.GetRequiredLast()); + return composition; + } + + /// + /// Uses the last capability of type and returns a result. + /// Throws if no instances exist. + /// + /// The capability type to use. + /// The type of result to return. + /// The composition to retrieve the capability from. + /// The function to execute with the capability. + /// The result of the function. + /// If no capability of type exists. + public static TResult UsingLast(this IComposition composition, Func use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + return use(composition.GetRequiredLast()); + } + + /// + /// Uses the last capability of type if it exists. + /// Silently skips if no instances exist. Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve the capability from. + /// The action to execute with the capability. + /// The same composition instance for fluent chaining. + public static IComposition UsingLastOrDefault(this IComposition composition, Action use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + var cap = composition.GetLastOrDefault(); + if (cap != null) + { + use(cap); + } + return composition; + } + + // ============= EACH ============= + + /// + /// Executes for each capability of type . + /// Silent if no instances exist (iterates empty collection). Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve capabilities from. + /// The action to execute for each capability. + /// The same composition instance for fluent chaining. + public static IComposition UsingEach(this IComposition composition, Action use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + foreach (var cap in composition.GetAll()) + { + use(cap); + } + return composition; + } + + /// + /// Executes for each capability of type and collects results. + /// Returns a list of results (empty if no instances exist). + /// + /// The capability type to use. + /// The type of result to collect. + /// The composition to retrieve capabilities from. + /// The function to execute for each capability. + /// A read-only list of results from executing the function on each capability. + public static IReadOnlyList UsingEach(this IComposition composition, Func use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + var all = composition.GetAll(); + var results = new List(all.Count); + foreach (var cap in all) + { + results.Add(use(cap)); + } + return results; + } + + // ============= ALL ============= + + /// + /// Executes with the full collection of capabilities. + /// Use for aggregations or collection-level operations. Returns composition for chaining. + /// + /// The capability type to use. + /// The composition to retrieve capabilities from. + /// The action to execute with the full collection of capabilities. + /// The same composition instance for fluent chaining. + public static IComposition UsingAll(this IComposition composition, Action> use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + use(composition.GetAll()); + return composition; + } + + /// + /// Executes with the full collection of capabilities and returns a result. + /// Use for aggregations or collection-level operations. + /// + /// The capability type to use. + /// The type of result to return. + /// The composition to retrieve capabilities from. + /// The function to execute with the full collection of capabilities. + /// The result of the function. + public static TResult UsingAll(this IComposition composition, Func, TResult> use) + where T : class + { + ArgumentNullException.ThrowIfNull(composition); + ArgumentNullException.ThrowIfNull(use); + + return use(composition.GetAll()); + } +}