diff --git a/docs/examples/enterprise-messaging-workflows.md b/docs/examples/enterprise-messaging-workflows.md index 2d50906..7760de0 100644 --- a/docs/examples/enterprise-messaging-workflows.md +++ b/docs/examples/enterprise-messaging-workflows.md @@ -20,6 +20,7 @@ Example source: | Routing slip | `RoutingSlipExample.cs` | Ordered fulfillment steps with route progress stored in message headers. | | Saga/process manager | `SagaExample.cs` | Typed message transitions over explicit saga state and completion rules. | | Mailbox | `MailboxExample.cs` | Serialized async inbox processing with explicit lifecycle and error behavior. | +| Source-generated mailbox | `MailboxExample.cs` | Attribute-driven serialized inbox factories with bounded backpressure and error policy. | | Idempotent receiver | `ReliabilityExample.cs` | Duplicate detection around at-least-once message delivery. | | Inbox/outbox | `ReliabilityExample.cs` | Explicit handoff records for durable integration boundaries owned by the application. | | Source-generated dispatcher | `DispatcherExample.cs` | Compile-time mediator commands, notifications, streams, and paging. | @@ -69,6 +70,8 @@ The generated factories are AOT-friendly and do not scan assemblies. The runtime Generated splitter and aggregator contracts follow the same rule: use `[GenerateSplitter]` and `[SplitterProjection]` for a stable split projection, then `[GenerateAggregator]` with `[AggregatorCorrelation]`, `[AggregatorCompletion]`, and `[AggregatorProjection]` for the matching rejoin contract. +Generated mailbox contracts use `[GenerateMailbox]` plus `[MailboxHandler]` when a serialized in-process inbox should have compile-time-validated capacity, backpressure, and error policies. + ## Testing Guidance The example tests use behavior-oriented assertions: @@ -92,6 +95,7 @@ The example tests use behavior-oriented assertions: - [Routing Slip](../patterns/messaging/routing-slip.md) - [Saga / Process Manager](../patterns/messaging/saga.md) - [Mailbox](../patterns/messaging/mailbox.md) +- [Generated Mailbox](generated-mailbox.md) - [Idempotent Receiver, Inbox, and Outbox](../patterns/messaging/reliability.md) - [Messaging Generators](../generators/messaging.md) - [Generated Splitter And Aggregator](generated-splitter-aggregator.md) diff --git a/docs/examples/generated-mailbox.md b/docs/examples/generated-mailbox.md new file mode 100644 index 0000000..149420a --- /dev/null +++ b/docs/examples/generated-mailbox.md @@ -0,0 +1,53 @@ +# Generated Mailbox + +This example shows fluent and source-generated mailbox factories side by side. A PatternKit mailbox is a serialized in-process inbox: accepted messages are handled by one consumer pump, making stateful background work deterministic without adopting an actor framework. + +Use the generated path when the payload type, handler, capacity, backpressure policy, and error policy are stable application structure. The generator emits a factory returning the same `Mailbox` runtime type as the fluent API. + +## Source + +- `src/PatternKit.Examples/Messaging/MailboxExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs` + +## Fluent Path + +```csharp +using var mailbox = Mailbox.Create((message, context, cancellationToken) => + { + processed.Add($"{context.Headers.GetString(MessageHeaderNames.CorrelationId)}:{message.Payload.Id}"); + return default; + }) + .Bounded(8, MailboxBackpressurePolicy.Wait) + .OnError(MailboxErrorPolicy.Continue) + .Build(); +``` + +## Source-Generated Path + +```csharp +[GenerateMailbox(typeof(MailboxWorkItem), FactoryName = "CreateWorkQueue", Capacity = 8, BackpressurePolicy = "Wait", ErrorPolicy = "Continue")] +public static partial class GeneratedMailboxWorkQueue +{ + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + { + Processed.Add($"{context.Headers.GetString(MessageHeaderNames.CorrelationId)}:{message.Payload.Id}"); + return default; + } +} +``` + +Optional `[MailboxErrorHandler]` and `[MailboxEventSink]` methods can be added when the generated factory should wire failure handling or metrics events. + +## Dependency Injection + +```csharp +var services = new ServiceCollection(); +services.AddGeneratedMailboxExample(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var example = provider.GetRequiredService(); +var processed = await example.Runner.RunGeneratedAsync(); +``` + +In a production host, register the generated mailbox itself as a singleton or wrap it behind a typed service that owns startup and shutdown. PatternKit keeps processing serialized in process; durable queues, persistence, and restart recovery remain application infrastructure. diff --git a/docs/examples/index.md b/docs/examples/index.md index ce66025..685677f 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -75,6 +75,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Generated Splitter and Aggregator** Shows fluent and source-generated split/rejoin message routing side by side, with an importable `IServiceCollection` extension. See [Generated Splitter And Aggregator](generated-splitter-aggregator.md). +* **Generated Mailbox** + Shows fluent and source-generated serialized inboxes side by side, with an importable `IServiceCollection` extension. See [Generated Mailbox](generated-mailbox.md). + * **Resilient Checkout and Collaborating Mailboxes** Application-shaped messaging demos: checkout route selection, routing-slip execution, command compensation, fallback routes, and service mailboxes collaborating over correlated messages. See [Resilient Checkout and Collaborating Mailboxes](resilient-checkout-and-mailboxes.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index cc89a93..f3cd41d 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -67,6 +67,9 @@ - name: CQRS Dispatcher href: cqrs-dispatcher.md +- name: Generated Mailbox + href: generated-mailbox.md + - name: Resilient Checkout and Collaborating Mailboxes href: resilient-checkout-and-mailboxes.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 30f215e..5c480b7 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -70,6 +70,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` | | [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` | | [**Saga**](messaging.md#generated-saga) | Typed process-manager transition factories | `[GenerateSaga]` | +| [**Mailbox**](messaging.md#generated-mailbox) | Serialized in-process inbox factories | `[GenerateMailbox]` | ## Quick Reference @@ -155,6 +156,10 @@ public static partial class OrderSplitter { } [GenerateAggregator(typeof(string), typeof(OrderLine), typeof(decimal))] public static partial class OrderLineAggregator { } +// Mailbox - generated serialized inbox factory +[GenerateMailbox(typeof(OrderWork), Capacity = 32, BackpressurePolicy = "Wait")] +public static partial class OrderMailbox { } + // Routing slip - generated ordered itinerary factory [GenerateRoutingSlip(typeof(Order))] public static partial class OrderSlip { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index c201d1a..c3c8e36 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,6 +1,6 @@ # Messaging Generators -PatternKit includes seven messaging-oriented source generators: +PatternKit includes eight messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. @@ -9,6 +9,7 @@ PatternKit includes seven messaging-oriented source generators: - and for split/rejoin routing. - for ordered routing-slip factories. - for typed saga/process-manager factories. +- for serialized in-process inbox factories. Use these generators when the message topology is known at compile time and should remain explicit, AOT-friendly, and validated by the compiler. They generate factories and fluent builders; they do not discover handlers from assemblies at runtime and they do not replace brokers, durable queues, or workflow engines. @@ -178,6 +179,30 @@ Example files: - `src/PatternKit.Examples/Messaging/MessageRoutingExample.cs` - `test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs` +## Generated Mailbox + +`[GenerateMailbox]` creates a `Mailbox` factory from one static `[MailboxHandler]` method. Optional `[MailboxErrorHandler]` and `[MailboxEventSink]` methods wire failure handling and metrics events: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateMailbox(typeof(OrderWork), FactoryName = "CreateWorker", Capacity = 32, BackpressurePolicy = "Wait", ErrorPolicy = "Continue")] +public static partial class OrderWorkMailbox +{ + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + => ProcessAsync(message.Payload, cancellationToken); +} +``` + +Use generated mailboxes when inbox configuration is static and should be reviewed in code. Keep using fluent builders when capacity or policy is tenant- or environment-defined. + +Example files: + +- `src/PatternKit.Examples/Messaging/MailboxExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs` + ## Generated Saga `[GenerateSaga]` emits a process-manager factory from typed transition methods: @@ -217,6 +242,7 @@ Example source: | `PKSA001`-`PKSA006` | Splitter / Aggregator | Non-partial host, missing contract methods, invalid signatures, or invalid duplicate policy. | | `PKRS001`-`PKRS003` | Routing Slip | Non-partial host, missing steps, or invalid step signatures. | | `PKSG001`-`PKSG004` | Saga | Non-partial host, missing transitions, invalid transition signatures, or invalid completion checks. | +| `PKMB001`-`PKMB005` | Mailbox | Non-partial host, missing handler, invalid handler signatures, or invalid configuration. | ## Related Runtime Patterns diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 40cab0f..91a2bac 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -51,7 +51,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Aggregator | `Aggregator` | Messaging generator | | Enterprise Integration | Routing Slip | `RoutingSlip` | Messaging generator | | Enterprise Integration | Saga / Process Manager | `Saga` | Messaging generator | -| Enterprise Integration | Mailbox | `Mailbox` | Tracked in [#209](https://github.com/JerrettDavis/PatternKit/issues/209) | +| Enterprise Integration | Mailbox | `Mailbox` | Messaging generator | | Messaging Reliability | Idempotent Receiver | `IdempotentReceiver` | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | | Messaging Reliability | Inbox | `InboxProcessor` | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | | Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Tracked in [#213](https://github.com/JerrettDavis/PatternKit/issues/213) | diff --git a/docs/patterns/messaging/enterprise-generators.md b/docs/patterns/messaging/enterprise-generators.md index ff7e526..27a077e 100644 --- a/docs/patterns/messaging/enterprise-generators.md +++ b/docs/patterns/messaging/enterprise-generators.md @@ -2,7 +2,7 @@ PatternKit source generators remove repetitive registration code for explicit enterprise integration patterns. They do not scan assemblies implicitly; each generated factory is opt-in through attributes on a partial type. -Use generators when routes, recipient lists, routing-slip steps, or saga transitions are static enough to validate at compile time and you want AOT-friendly factories without reflection. +Use generators when routes, recipient lists, splitter/aggregator contracts, routing-slip steps, saga transitions, or mailbox inbox policies are static enough to validate at compile time and you want AOT-friendly factories without reflection. ## Generated Content Router @@ -75,7 +75,9 @@ Routing-slip generation is documented in [Routing Slip](routing-slip.md). It dis Saga/process-manager generation is documented in [Saga / Process Manager](saga.md). It discovers `[SagaStep]` transition methods and optional `[SagaCompleteWhen]` completion checks. -Mailbox and reliability helpers stay runtime-only for now. Their registration is already small and lifecycle-sensitive, so a generator would add indirection without removing meaningful boilerplate. +Mailbox generation is documented in [Mailbox](mailbox.md). It discovers one `[MailboxHandler]` method plus optional error and event hooks, then emits a configured serialized inbox factory. + +Reliability helpers stay runtime-only for now. Their registration is still lifecycle-sensitive and is tracked separately. ## Diagnostics @@ -90,8 +92,10 @@ Mailbox and reliability helpers stay runtime-only for now. Their registration is | `PKRL002` | The generated recipient list has no `[RecipientListRecipient]` methods. | | `PKRL003` | A recipient handler or referenced predicate has an invalid signature. | | `PKRL004` | A recipient name or recipient order is duplicated. | +| `PKSA001`-`PKSA006` | Splitter/aggregator generator validation. | | `PKRS001`-`PKRS003` | Routing-slip generator validation. | | `PKSG001`-`PKSG004` | Saga generator validation. | +| `PKMB001`-`PKMB005` | Mailbox generator validation. | ## Troubleshooting @@ -113,6 +117,10 @@ Mailbox and reliability helpers stay runtime-only for now. Their registration is - - - +- +- +- +- - - diff --git a/docs/patterns/messaging/mailbox.md b/docs/patterns/messaging/mailbox.md index 370db3f..cf31f7e 100644 --- a/docs/patterns/messaging/mailbox.md +++ b/docs/patterns/messaging/mailbox.md @@ -78,6 +78,22 @@ var mailbox = Mailbox.Create(handler) .Build(); ``` +## Source-Generated Mailboxes + +Use `[GenerateMailbox]` when the inbox shape is stable and should be compile-time validated: + +```csharp +[GenerateMailbox(typeof(OrderWork), FactoryName = "CreateWorker", Capacity = 128, BackpressurePolicy = "Wait", ErrorPolicy = "Continue")] +public static partial class OrderWorkMailbox +{ + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + => ProcessAsync(message.Payload, cancellationToken); +} +``` + +The generated factory returns `Mailbox` and applies the configured capacity, backpressure policy, error policy, optional error handler, and optional event sink. + ## Choosing Related Patterns - Use `Mailbox` when one in-process consumer must serialize work. @@ -95,6 +111,10 @@ var mailbox = Mailbox.Create(handler) - - - +- +- +- +- ## Example Source diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index c7c85f8..7ff1637 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); public sealed record EnterpriseMessagingWorkflowSuiteExample(Func Run); public sealed record CqrsDispatcherExample(Func> RunFluentAsync, Func> RunSourceGeneratedAsync); +public sealed record GeneratedMailboxExample(MailboxExampleRunner Runner); public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); @@ -133,6 +134,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddSourceGeneratorApplicationSuiteExample() .AddEnterpriseMessagingWorkflowSuiteExample() .AddCqrsDispatcherExample() + .AddGeneratedMailboxExample() .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() .AddPrototypeGameCharacterFactoryExample() @@ -381,6 +383,13 @@ public static IServiceCollection AddCqrsDispatcherExample(this IServiceCollectio return services.RegisterExample("CQRS Dispatcher", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddGeneratedMailboxExample(this IServiceCollection services) + { + services.AddSingleton(new MailboxExampleRunner(MailboxExample.RunFluentAsync, MailboxExample.RunGeneratedAsync)); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Generated Mailbox", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/PatternKit.Examples/Messaging/MailboxExample.cs b/src/PatternKit.Examples/Messaging/MailboxExample.cs index 6ebdbed..6fa8242 100644 --- a/src/PatternKit.Examples/Messaging/MailboxExample.cs +++ b/src/PatternKit.Examples/Messaging/MailboxExample.cs @@ -1,5 +1,6 @@ using PatternKit.Messaging; using PatternKit.Messaging.Mailboxes; +using PatternKit.Generators.Messaging; namespace PatternKit.Examples.Messaging; @@ -9,7 +10,10 @@ namespace PatternKit.Examples.Messaging; public static class MailboxExample { /// Runs a bounded mailbox and returns the processed work item identifiers. - public static async ValueTask> RunAsync() + public static ValueTask> RunAsync() => RunFluentAsync(); + + /// Runs a bounded mailbox built with the fluent runtime API. + public static async ValueTask> RunFluentAsync() { var processed = new List(); using var mailbox = Mailbox.Create((message, context, cancellationToken) => @@ -30,7 +34,41 @@ public static async ValueTask> RunAsync() await mailbox.StopAsync(); return processed; } + + /// Runs a bounded mailbox built with the source-generated factory. + public static async ValueTask> RunGeneratedAsync() + { + GeneratedMailboxWorkQueue.Processed.Clear(); + using var mailbox = GeneratedMailboxWorkQueue.CreateWorkQueue(); + + await mailbox.StartAsync(); + + var context = new MessageContext(MessageHeaders.Empty.WithCorrelationId("batch-42")); + await mailbox.PostAsync(Message.Create(new MailboxWorkItem("prepare")), context); + await mailbox.PostAsync(Message.Create(new MailboxWorkItem("ship")), context); + + await mailbox.StopAsync(); + return GeneratedMailboxWorkQueue.Processed.ToArray(); + } } +/// DI-friendly entry points for fluent and generated mailbox examples. +public sealed record MailboxExampleRunner( + Func>> RunFluentAsync, + Func>> RunGeneratedAsync); + /// Mailbox example payload. public sealed record MailboxWorkItem(string Id); + +[GenerateMailbox(typeof(MailboxWorkItem), FactoryName = "CreateWorkQueue", Capacity = 8, BackpressurePolicy = "Wait", ErrorPolicy = "Continue")] +public static partial class GeneratedMailboxWorkQueue +{ + public static readonly List Processed = []; + + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + { + Processed.Add($"{context.Headers.GetString(MessageHeaderNames.CorrelationId)}:{message.Payload.Id}"); + return default; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index a50b09e..4e39d82 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -264,6 +264,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost, ["Mediator", "Dispatcher", "CQRS"], ["command/query separation", "source-generated dispatcher", "DI composition"]), + Descriptor( + "Generated Mailbox", + "src/PatternKit.Examples/Messaging/MailboxExample.cs", + "test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs", + "docs/examples/generated-mailbox.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Mailbox"], + ["serialized inbox", "source-generated factory", "DI composition"]), Descriptor( "Resilient Checkout and Collaborating Mailboxes", "src/PatternKit.Examples/Messaging/ResilientCheckoutDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index f022178..bd80c5d 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -458,14 +458,14 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/messaging/mailbox.md", "src/PatternKit.Core/Messaging/Mailboxes/Mailbox.cs", "test/PatternKit.Tests/Messaging/Mailboxes/MailboxTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/MailboxGenerator.cs", + "test/PatternKit.Generators.Tests/MailboxGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/209", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-mailbox.md", "src/PatternKit.Examples/Messaging/MailboxExample.cs", "test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs", - ["fluent serialized inbox", "generated mailbox tracked", "bounded worker example"]), + ["fluent serialized inbox", "generated mailbox", "DI-importable bounded worker example"]), Pattern("Idempotent Receiver", PatternFamily.MessagingReliability, "docs/patterns/messaging/reliability.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MailboxAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MailboxAttributes.cs new file mode 100644 index 0000000..21a523b --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MailboxAttributes.cs @@ -0,0 +1,49 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates a typed mailbox factory for a partial class or struct. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMailboxAttribute : Attribute +{ + /// Creates a mailbox generator attribute. + public GenerateMailboxAttribute(Type payloadType) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + } + + /// Message payload type accepted by the generated mailbox. + public Type PayloadType { get; } + + /// Name of the generated mailbox factory method. + public string FactoryName { get; set; } = "Create"; + + /// Bounded capacity. Use 0 for an unbounded mailbox. + public int Capacity { get; set; } + + /// Backpressure policy emitted when is greater than zero. + public string BackpressurePolicy { get; set; } = "Wait"; + + /// Error policy emitted into the generated mailbox. + public string ErrorPolicy { get; set; } = "Stop"; +} + +/// +/// Marks the static method used by a generated mailbox handler. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MailboxHandlerAttribute : Attribute; + +/// +/// Marks the static method used by a generated mailbox error handler. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MailboxErrorHandlerAttribute : Attribute; + +/// +/// Marks the static method used by a generated mailbox event sink. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MailboxEventSinkAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index a58c0bf..d731338 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -174,6 +174,11 @@ PKME001 | PatternKit.Generators.Messaging | Error | Message envelope type must b PKME002 | PatternKit.Generators.Messaging | Error | Message envelope must declare at least one required header. PKME003 | PatternKit.Generators.Messaging | Error | Message envelope header configuration is invalid. PKME004 | PatternKit.Generators.Messaging | Error | Message envelope header name or generated parameter name is duplicated. +PKMB001 | PatternKit.Generators.Messaging | Error | Mailbox type must be partial. +PKMB002 | PatternKit.Generators.Messaging | Error | Mailbox must declare exactly one handler. +PKMB003 | PatternKit.Generators.Messaging | Error | Mailbox handler signature is invalid. +PKMB004 | PatternKit.Generators.Messaging | Error | Mailbox optional handler signature is invalid. +PKMB005 | PatternKit.Generators.Messaging | Error | Mailbox generator configuration is invalid. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/MailboxGenerator.cs b/src/PatternKit.Generators/Messaging/MailboxGenerator.cs new file mode 100644 index 0000000..55300bd --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MailboxGenerator.cs @@ -0,0 +1,280 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class MailboxGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMB001", + "Mailbox type must be partial", + "Type '{0}' is marked with [GenerateMailbox] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKMB002", + "Mailbox handler is missing", + "Type '{0}' is marked with [GenerateMailbox] but must declare exactly one [MailboxHandler] method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKMB003", + "Mailbox handler signature is invalid", + "Mailbox handler '{0}' must be static and return ValueTask with Message, MessageContext, and CancellationToken parameters", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidOptionalHandler = new( + "PKMB004", + "Mailbox optional handler signature is invalid", + "Mailbox optional handler '{0}' has an invalid error-handler or event-sink signature", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKMB005", + "Mailbox configuration is invalid", + "Generated mailbox configuration is invalid: {0}", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMailboxAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateMailboxAttribute"); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var payloadType = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (payloadType is null) + return; + + var handlers = GetMarkedMethods(type, "PatternKit.Generators.Messaging.MailboxHandlerAttribute"); + if (handlers.Count != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var handler = handlers[0]; + if (!IsHandler(handler, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + + var errorHandlers = GetMarkedMethods(type, "PatternKit.Generators.Messaging.MailboxErrorHandlerAttribute"); + var eventSinks = GetMarkedMethods(type, "PatternKit.Generators.Messaging.MailboxEventSinkAttribute"); + if (errorHandlers.Count > 1 || eventSinks.Count > 1) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidOptionalHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var errorHandler = errorHandlers.FirstOrDefault(); + if (errorHandler is not null && !IsErrorHandler(errorHandler, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidOptionalHandler, errorHandler.Locations.FirstOrDefault(), errorHandler.Name)); + return; + } + + var eventSink = eventSinks.FirstOrDefault(); + if (eventSink is not null && !IsEventSink(eventSink)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidOptionalHandler, eventSink.Locations.FirstOrDefault(), eventSink.Name)); + return; + } + + var capacity = GetNamedInt(attribute, "Capacity"); + if (capacity < 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), "capacity must be zero or greater")); + return; + } + + var backpressure = GetNamedString(attribute, "BackpressurePolicy") ?? "Wait"; + if (!TryNormalizeBackpressure(backpressure, out var normalizedBackpressure)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), $"unsupported backpressure policy '{backpressure}'")); + return; + } + + var errorPolicy = GetNamedString(attribute, "ErrorPolicy") ?? "Stop"; + if (!TryNormalizeErrorPolicy(errorPolicy, out var normalizedErrorPolicy)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), $"unsupported error policy '{errorPolicy}'")); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + context.AddSource($"{type.Name}.Mailbox.g.cs", SourceText.From( + GenerateSource(type, payloadType, handler.Name, errorHandler?.Name, eventSink?.Name, capacity, normalizedBackpressure, normalizedErrorPolicy, factoryName), + Encoding.UTF8)); + } + + private static List GetMarkedMethods(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToList(); + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + method.Parameters.Length == 3 && + IsMessageOf(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsErrorHandler(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + method.Parameters.Length == 4 && + method.Parameters[0].Type.ToDisplayString() == "System.Exception" && + IsMessageOf(method.Parameters[1].Type, payloadType) && + method.Parameters[2].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[3].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsEventSink(IMethodSymbol method) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 1 && + method.Parameters[0].Type.ToDisplayString() == "PatternKit.Messaging.Mailboxes.MailboxEvent"; + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + string handlerName, + string? errorHandlerName, + string? eventSinkName, + int capacity, + string backpressurePolicy, + string errorPolicy, + string factoryName) + { + var payload = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Mailboxes.Mailbox<") + .Append(payload) + .Append("> ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Mailboxes.Mailbox<") + .Append(payload) + .AppendLine(">.Create(" + handlerName + ")"); + + if (capacity > 0) + { + sb.Append(" .Bounded(") + .Append(capacity) + .Append(", global::PatternKit.Messaging.Mailboxes.MailboxBackpressurePolicy.") + .Append(backpressurePolicy) + .AppendLine(")"); + } + else + { + sb.AppendLine(" .Unbounded()"); + } + + sb.Append(" .OnError(global::PatternKit.Messaging.Mailboxes.MailboxErrorPolicy.") + .Append(errorPolicy); + if (errorHandlerName is not null) + sb.Append(", ").Append(errorHandlerName); + sb.AppendLine(")"); + + if (eventSinkName is not null) + sb.Append(" .OnEvent(").Append(eventSinkName).AppendLine(")"); + + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int? ?? 0; + + private static bool TryNormalizeBackpressure(string value, out string normalized) + { + normalized = value; + if (string.Equals(value, "Wait", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Wait"; + else if (string.Equals(value, "Reject", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Reject"; + else if (string.Equals(value, "DropNewest", System.StringComparison.OrdinalIgnoreCase)) + normalized = "DropNewest"; + else if (string.Equals(value, "DropOldest", System.StringComparison.OrdinalIgnoreCase)) + normalized = "DropOldest"; + else + return false; + + return true; + } + + private static bool TryNormalizeErrorPolicy(string value, out string normalized) + { + normalized = value; + if (string.Equals(value, "Stop", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Stop"; + else if (string.Equals(value, "Continue", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Continue"; + else + return false; + + return true; + } +} diff --git a/test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs index f2b6a6f..e8d37bb 100644 --- a/test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs +++ b/test/PatternKit.Examples.Tests/Messaging/MailboxExampleTests.cs @@ -1,16 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.Messaging; +using PatternKit.Examples.ProductionReadiness; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Examples.Tests.Messaging; -public sealed class MailboxExampleTests +[Feature("Generated mailbox example")] +public sealed class MailboxExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - [Scenario("RunAsync ProcessesMessagesInOrder")] + [Scenario("Fluent and generated mailbox paths process messages in order")] [Fact] - public async Task RunAsync_ProcessesMessagesInOrder() - { - var processed = await MailboxExample.RunAsync(); + public Task Fluent_And_Generated_Mailbox_Paths_Process_Messages_In_Order() + => Given("mailbox example entry points", () => + new MailboxExampleRunner(MailboxExample.RunFluentAsync, MailboxExample.RunGeneratedAsync)) + .When("running both mailbox paths", async ValueTask (runner) => new MailboxExampleRun( + await runner.RunFluentAsync(), + await runner.RunGeneratedAsync())) + .Then("both paths process correlated work in order", result => + { + ScenarioExpect.Equal(["batch-42:prepare", "batch-42:ship"], result.FluentProcessed); + ScenarioExpect.Equal(result.FluentProcessed, result.GeneratedProcessed); + }) + .AssertPassed(); - ScenarioExpect.Equal(["batch-42:prepare", "batch-42:ship"], processed); - } + [Scenario("Generated mailbox example is importable through IServiceCollection")] + [Fact] + public Task Generated_Mailbox_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit mailbox extension", () => + { + var services = new ServiceCollection(); + services.AddGeneratedMailboxExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the generated mailbox example", async ValueTask (provider) => + { + using (provider) + { + var example = provider.GetRequiredService(); + var processed = await example.Runner.RunGeneratedAsync(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Generated Mailbox"); + + return new MailboxImportRun(processed, descriptor.Integration); + } + }) + .Then("the generated runner returns expected work item identifiers", result => + ScenarioExpect.Equal(["batch-42:prepare", "batch-42:ship"], result.Processed)) + .And("the descriptor advertises DI source generation and messaging", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator) + && result.Integration.HasFlag(ExampleIntegrationSurface.Messaging)) + .AssertPassed(); + + private sealed record MailboxExampleRun( + IReadOnlyList FluentProcessed, + IReadOnlyList GeneratedProcessed); + + private sealed record MailboxImportRun(IReadOnlyList Processed, ExampleIntegrationSurface Integration); } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 96557b1..8b857bc 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -124,7 +124,6 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() "Idempotent Receiver has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Inbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206", - "Mailbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/209", "Outbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Publish-Subscribe has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214" diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 85ca952..86d1126 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -85,6 +85,10 @@ private enum TestTrigger { typeof(AggregatorCorrelationAttribute), AttributeTargets.Method, false, false }, { typeof(AggregatorCompletionAttribute), AttributeTargets.Method, false, false }, { typeof(AggregatorProjectionAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateMailboxAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MailboxHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(MailboxErrorHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(MailboxEventSinkAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateMessageEnvelopeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageEnvelopeHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(ObserverAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -356,6 +360,13 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildAggregator", DuplicatePolicy = "Replace" }; + var mailbox = new GenerateMailboxAttribute(typeof(string)) + { + FactoryName = "BuildMailbox", + Capacity = 4, + BackpressurePolicy = "Reject", + ErrorPolicy = "Continue" + }; var envelope = new GenerateMessageEnvelopeAttribute(typeof(string)) { FactoryName = "BuildEnvelope", @@ -409,6 +420,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(typeof(decimal), aggregator.ResultType); ScenarioExpect.Equal("BuildAggregator", aggregator.FactoryName); ScenarioExpect.Equal("Replace", aggregator.DuplicatePolicy); + ScenarioExpect.Equal(typeof(string), mailbox.PayloadType); + ScenarioExpect.Equal("BuildMailbox", mailbox.FactoryName); + ScenarioExpect.Equal(4, mailbox.Capacity); + ScenarioExpect.Equal("Reject", mailbox.BackpressurePolicy); + ScenarioExpect.Equal("Continue", mailbox.ErrorPolicy); ScenarioExpect.Equal(typeof(string), envelope.PayloadType); ScenarioExpect.Equal("BuildEnvelope", envelope.FactoryName); ScenarioExpect.Equal("BuildContext", envelope.ContextFactoryName); @@ -431,6 +447,7 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(null!, typeof(int), typeof(decimal))); ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), null!, typeof(decimal))); ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), typeof(int), null!)); + ScenarioExpect.Throws(() => new GenerateMailboxAttribute(null!)); ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); @@ -440,6 +457,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.IsType(new AggregatorCorrelationAttribute()); ScenarioExpect.IsType(new AggregatorCompletionAttribute()); ScenarioExpect.IsType(new AggregatorProjectionAttribute()); + ScenarioExpect.IsType(new MailboxHandlerAttribute()); + ScenarioExpect.IsType(new MailboxErrorHandlerAttribute()); + ScenarioExpect.IsType(new MailboxEventSinkAttribute()); ScenarioExpect.IsType(new FlyweightFactoryAttribute()); ScenarioExpect.IsType(new IteratorStepAttribute()); ScenarioExpect.IsType(new TraversalIteratorAttribute()); diff --git a/test/PatternKit.Generators.Tests/MailboxGeneratorTests.cs b/test/PatternKit.Generators.Tests/MailboxGeneratorTests.cs new file mode 100644 index 0000000..d77aea8 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MailboxGeneratorTests.cs @@ -0,0 +1,187 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MailboxGeneratorTests +{ + [Scenario("Generates typed mailbox factory")] + [Fact] + public void GeneratesTypedMailboxFactory() + { + var source = """ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + using PatternKit.Messaging.Mailboxes; + + namespace MyApp; + + public sealed record WorkItem(string Id); + + [GenerateMailbox(typeof(WorkItem), FactoryName = "CreateWorker", Capacity = 8, BackpressurePolicy = "Reject", ErrorPolicy = "Continue")] + public static partial class WorkMailbox + { + public static readonly List Processed = []; + public static readonly List Errors = []; + public static readonly List Events = []; + + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) + { + Processed.Add(message.Payload.Id); + return default; + } + + [MailboxErrorHandler] + private static ValueTask HandleError(Exception exception, Message message, MessageContext context, CancellationToken cancellationToken) + { + Errors.Add(exception.Message); + return default; + } + + [MailboxEventSink] + private static void Observe(MailboxEvent evt) => Events.Add(evt.Kind); + } + + public static class Demo + { + public static int Capacity() + { + using var mailbox = WorkMailbox.CreateWorker(); + return mailbox.Capacity ?? 0; + } + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesTypedMailboxFactory)); + var gen = new MailboxGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("WorkMailbox.Mailbox.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("CreateWorker()", text); + ScenarioExpect.Contains(".Bounded(8, global::PatternKit.Messaging.Mailboxes.MailboxBackpressurePolicy.Reject)", text); + ScenarioExpect.Contains(".OnError(global::PatternKit.Messaging.Mailboxes.MailboxErrorPolicy.Continue, HandleError)", text); + ScenarioExpect.Contains(".OnEvent(Observe)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial mailbox")] + [Fact] + public void ReportsDiagnosticForNonPartialMailbox() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record WorkItem(string Id); + + [GenerateMailbox(typeof(WorkItem))] + public static class WorkMailbox; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialMailbox)); + var gen = new MailboxGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMB001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing mailbox handler")] + [Fact] + public void ReportsDiagnosticForMissingMailboxHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record WorkItem(string Id); + + [GenerateMailbox(typeof(WorkItem))] + public static partial class WorkMailbox; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingMailboxHandler)); + var gen = new MailboxGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMB002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid mailbox handler")] + [Fact] + public void ReportsDiagnosticForInvalidMailboxHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record WorkItem(string Id); + + [GenerateMailbox(typeof(WorkItem))] + public static partial class WorkMailbox + { + [MailboxHandler] + private static void Handle(WorkItem item) { } + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidMailboxHandler)); + var gen = new MailboxGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMB003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid mailbox policy")] + [Fact] + public void ReportsDiagnosticForInvalidMailboxPolicy() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record WorkItem(string Id); + + [GenerateMailbox(typeof(WorkItem), BackpressurePolicy = "Overflow")] + public static partial class WorkMailbox + { + [MailboxHandler] + private static ValueTask Handle(Message message, MessageContext context, CancellationToken cancellationToken) => default; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidMailboxPolicy)); + var gen = new MailboxGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMB005", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Mailboxes.Mailbox<>).Assembly.Location)); +}