From 82488b2049d6fef9286a8b847722b438ffc18709 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 9 Apr 2026 16:45:11 -0500 Subject: [PATCH 1/2] Auto-detect metadata-only CLI mode to suppress DB/transport init (#2471) - Wolverine now detects codegen commands (via DynamicCodeBuilder.WithinCodegenCommand) and OpenAPI generation tools (via ASPNETCORE_HOSTINGSTARTUPASSEMBLIES containing "GetDocument") and automatically applies lightweight startup mode, suppressing persistence and transport initialization. - Replaced the Environment.CommandLine.Contains("codegen") code smell in WolverineHttpEndpointRouteBuilderExtensions with DynamicCodeBuilder.WithinCodegenCommand. - Added smoke tests verifying codegen preview and host startup work without DB. - Added documentation in command-line.md explaining that CLI commands do not require external connectivity. Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/command-line.md | 32 +++++++ ...erineHttpEndpointRouteBuilderExtensions.cs | 3 +- .../Bug_2471_codegen_without_connectivity.cs | 89 +++++++++++++++++++ .../Runtime/WolverineRuntime.HostService.cs | 27 +++++- 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/Testing/CoreTests/Bugs/Bug_2471_codegen_without_connectivity.cs diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index d59574063..118834252 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -118,6 +118,38 @@ to help you troubleshoot issues in the future. This functionality was originally built for consumption in the "CritterWatch" add on tool, but was requested by a [JasperFx Software](https://jasperfx.net) client to provide a mechanism to detect any unintentional changes to Wolverine application configuration. +## CLI Commands Work Without External Connectivity + +::: tip +This applies to `codegen write`, `codegen preview`, `describe`, and OpenAPI generation tools such as +`GetDocument.Insider` (Microsoft.Extensions.ApiDescription.Server). You do **not** need a running +database or message broker for these commands to succeed. +::: + +Wolverine automatically detects when it is running in a metadata-only CLI mode and suppresses +persistence and transport initialization. No database connections or message broker connections +are opened. This allows commands like `codegen` and `describe` to work safely in CI pipelines or +developer machines that do not have external infrastructure available. + +Detection is based on two signals: + +1. **`DynamicCodeBuilder.WithinCodegenCommand`** — set by JasperFx when the `codegen` command is + used, either via `dotnet run -- codegen ...` or the `--start` flag. +2. **`ASPNETCORE_HOSTINGSTARTUPASSEMBLIES` environment variable** — contains `"GetDocument"` when + OpenAPI generation tools like `GetDocument.Insider` start the host. + +When either condition is true, Wolverine applies the equivalent of "lightweight mode": +external transports are stubbed out, durability agents are disabled, and the durability +mode is set to `MediatorOnly`. + +If you need to explicitly disable persistence initialization for other tooling (e.g., your own +OpenAPI generation pipeline), you can use the `DisableAllWolverineMessagePersistence()` extension: + +```csharp +// In Program.cs or Startup.cs, guard with an environment check for your tooling +builder.Services.DisableAllWolverineMessagePersistence(); +``` + ## Other Highlights * See the [code generation support](./codegen) diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs index 9c73bc1e2..bd64ae1bf 100644 --- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using JasperFx; +using JasperFx.CodeGeneration; using JasperFx.Core.IoC; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -210,7 +211,7 @@ public static void MapWolverineEndpoints(this IEndpointRouteBuilder endpoints, options.Policies.Add(new ProblemDetailsFromMiddleware()); - if (Environment.CommandLine.Contains("codegen", StringComparison.OrdinalIgnoreCase)) + if (DynamicCodeBuilder.WithinCodegenCommand) { options.WarmUpRoutes = RouteWarmup.Lazy; } diff --git a/src/Testing/CoreTests/Bugs/Bug_2471_codegen_without_connectivity.cs b/src/Testing/CoreTests/Bugs/Bug_2471_codegen_without_connectivity.cs new file mode 100644 index 000000000..11e82b836 --- /dev/null +++ b/src/Testing/CoreTests/Bugs/Bug_2471_codegen_without_connectivity.cs @@ -0,0 +1,89 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine.Tracking; +using Xunit; + +namespace CoreTests.Bugs; + +/// +/// Smoke tests for GitHub issue #2471: codegen and OpenAPI CLI commands should work +/// without database/transport connectivity. +/// +public class Bug_2471_codegen_without_connectivity +{ + [Fact] + public void codegen_preview_works_without_database_connection() + { + // Simulate the codegen command setting WithinCodegenCommand = true before building host + DynamicCodeBuilder.WithinCodegenCommand = true; + + try + { + // Build but do not start the host — the codegen command does this by default + // (without --start flag). No DB or transport connectivity is needed. + var host = Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(typeof(SimpleCodegenHandler2471)); + }) + .Build(); + + var collections = host.Services.GetServices().ToArray(); + collections.ShouldNotBeEmpty("Expected at least one ICodeFileCollection from Wolverine"); + + var builder = new DynamicCodeBuilder(host.Services, collections) + { + ServiceVariableSource = host.Services.GetService() + }; + + // Should not throw even with no real database or transport configured + var code = builder.GenerateAllCode(); + code.ShouldNotBeNullOrEmpty(); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + [Fact] + public async Task host_startup_applies_lightweight_mode_automatically_during_codegen_command() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(typeof(SimpleCodegenHandler2471)); + }) + .StartAsync(); + + var runtime = host.GetRuntime(); + + // Lightweight mode should have been applied automatically + runtime.Options.LightweightMode.ShouldBeTrue(); + runtime.Options.ExternalTransportsAreStubbed.ShouldBeTrue(); + runtime.Options.Durability.DurabilityAgentEnabled.ShouldBeFalse(); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} + +public record SimpleCodegenMessage2471; + +public static class SimpleCodegenHandler2471 +{ + public static void Handle(SimpleCodegenMessage2471 message) + { + } +} diff --git a/src/Wolverine/Runtime/WolverineRuntime.HostService.cs b/src/Wolverine/Runtime/WolverineRuntime.HostService.cs index a61059974..a1fe4f44a 100644 --- a/src/Wolverine/Runtime/WolverineRuntime.HostService.cs +++ b/src/Wolverine/Runtime/WolverineRuntime.HostService.cs @@ -21,11 +21,36 @@ public partial class WolverineRuntime private bool _hasStarted; private Task? _idleAgentCleanupLoop; + /// + /// Detects whether Wolverine is running in a metadata-only CLI mode (codegen, OpenAPI + /// generation via GetDocument.Insider) where persistence and transport connectivity + /// are not required. When detected, lightweight startup settings are applied automatically + /// so the host can start without needing external databases or message brokers. + /// + private void applyMetadataOnlyModeIfDetected() + { + if (Options.LightweightMode) return; // Already applied (e.g., by StartLightweightAsync) + + var isMetadataOnly = DynamicCodeBuilder.WithinCodegenCommand + || (Environment.GetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES") + ?.Contains("GetDocument", StringComparison.OrdinalIgnoreCase) ?? false); + + if (!isMetadataOnly) return; + + Options.ExternalTransportsAreStubbed = true; + Options.Durability.DurabilityAgentEnabled = false; + Options.Durability.Mode = DurabilityMode.MediatorOnly; + Options.LightweightMode = true; + } + public async Task StartAsync(CancellationToken cancellationToken) { // Make this idempotent because the AddResourceSetupOnStartup() can cause it to bootstrap twice if (_hasStarted) return; - + + // Auto-detect codegen and OpenAPI generation tools; suppress persistence/transport init + applyMetadataOnlyModeIfDetected(); + try { Logger.LogInformation("Starting Wolverine messaging for application assembly {Assembly}", From 84a0a898332fce623b3f2304028711f07a290751 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 9 Apr 2026 17:03:02 -0500 Subject: [PATCH 2/2] Add wolverine-diagnostics codegen-preview CLI command (#2467) Adds a new `wolverine-diagnostics` Oakton command with an extensible sub-command dispatch pattern (string action argument). The first sub-command is `codegen-preview`: dotnet run -- wolverine-diagnostics codegen-preview --handler MyApp.Orders.CreateOrder dotnet run -- wolverine-diagnostics codegen-preview --route "POST /api/orders" - --handler: accepts fully-qualified message type, short class name, or handler class name with fuzzy contains fallback; prints the single generated MessageHandler adapter class with full middleware chain. - --route: converts "METHOD /path" to an expected HttpChain FileName and searches all ICodeFileCollection instances, so HTTP endpoints work without a direct Wolverine.Http compile-time dependency from core. - Starts the host in codegen lightweight mode so no DB/transport connectivity is required (same behaviour as `codegen preview --start`). - 13 passing unit/smoke tests covering route-name conversion, handler chain matching strategies, and end-to-end code generation. - Docs added to docs/guide/command-line.md. Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/command-line.md | 47 ++- .../WolverineDiagnosticsCommandTests.cs | 156 +++++++++ .../WolverineDiagnosticsCommand.cs | 306 ++++++++++++++++++ 3 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 src/Testing/CoreTests/Diagnostics/WolverineDiagnosticsCommandTests.cs create mode 100644 src/Wolverine/Diagnostics/WolverineDiagnosticsCommand.cs diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index 118834252..d56c669f2 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -71,8 +71,9 @@ The available commands are: help List all the available commands resources Check, setup, or teardown stateful resources of this system run Start and run this .Net application - storage Administer the Wolverine message storage - + storage Administer the Wolverine message storage + wolverine-diagnostics Wolverine diagnostics tools for inspecting generated code and runtime behavior + Use dotnet run -- ? [command name] or dotnet run -- help [command name] to see usage help about a specific command @@ -150,6 +151,48 @@ OpenAPI generation pipeline), you can use the `DisableAllWolverineMessagePersist builder.Services.DisableAllWolverineMessagePersistence(); ``` +## Wolverine Diagnostics Commands + +The `wolverine-diagnostics` command is an extensible parent command for deeper Wolverine-specific +inspection tools. Currently it exposes one sub-command: **`codegen-preview`**. + +### codegen-preview + +Preview the full generated adapter code for a **specific** message handler or HTTP endpoint without +generating all handlers at once. This is useful when you want to understand exactly what middleware, +dependency resolution, or transaction wrapping Wolverine applies to a single entry point. + +**Preview a message handler** (accepts fully-qualified name, short class name, or handler class name): + +```bash +# Fully-qualified message type +dotnet run -- wolverine-diagnostics codegen-preview --handler MyApp.Orders.CreateOrder + +# Short message type name (fuzzy match) +dotnet run -- wolverine-diagnostics codegen-preview --handler CreateOrder + +# Handler class name +dotnet run -- wolverine-diagnostics codegen-preview --handler CreateOrderHandler +``` + +**Preview an HTTP endpoint** (requires Wolverine.HTTP; format: `"METHOD /path"`): + +```bash +dotnet run -- wolverine-diagnostics codegen-preview --route "POST /api/orders" +dotnet run -- wolverine-diagnostics codegen-preview --route "GET /api/orders/{id}" +``` + +The output includes the full generated class — the `Handle` or `HandleAsync` override, all +middleware calls in order, dependency resolution from the IoC container, and any +transaction-wrapping frames. This is identical to what `codegen preview` outputs, but scoped to +exactly one handler so the signal-to-noise ratio is much higher. + +::: tip +`wolverine-diagnostics` works without database or message-broker connectivity for the same reason +as `codegen preview`: Wolverine automatically detects CLI codegen mode and stubs out persistence +and transports. +::: + ## Other Highlights * See the [code generation support](./codegen) diff --git a/src/Testing/CoreTests/Diagnostics/WolverineDiagnosticsCommandTests.cs b/src/Testing/CoreTests/Diagnostics/WolverineDiagnosticsCommandTests.cs new file mode 100644 index 000000000..cc98347de --- /dev/null +++ b/src/Testing/CoreTests/Diagnostics/WolverineDiagnosticsCommandTests.cs @@ -0,0 +1,156 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Diagnostics; +using Wolverine.Runtime.Handlers; +using Xunit; + +namespace CoreTests.Diagnostics; + +/// +/// Unit and smoke tests for the WolverineDiagnosticsCommand added in GitHub issue #2467. +/// +public class WolverineDiagnosticsCommandTests +{ + // ── RouteInputToFileName ────────────────────────────────────────────────── + + [Theory] + [InlineData("POST /api/orders", "POST_api_orders")] + [InlineData("GET /api/orders/{id}", "GET_api_orders_id")] + [InlineData("DELETE /api/orders/{id}", "DELETE_api_orders_id")] + [InlineData("GET /", "GET")] + [InlineData("PUT /api/v1/users/{userId}/roles", "PUT_api_v1_users_userId_roles")] + [InlineData("/api/orders", "api_orders")] // no HTTP method prefix + [InlineData("POST /api/some-path", "POST_api_some_path")] // hyphens → underscores + public void route_input_to_file_name(string input, string expected) + { + WolverineDiagnosticsCommand.RouteInputToFileName(input).ShouldBe(expected); + } + + // ── FindHandlerChain — exact and fuzzy matching ────────────────────────── + + [Fact] + public void find_handler_chain_by_exact_full_name() + { + var chains = BuildTestChains(); + var found = WolverineDiagnosticsCommand.FindHandlerChain( + "CoreTests.Diagnostics.DiagnosticsTestMessage", chains); + found.ShouldNotBeNull(); + found.MessageType.ShouldBe(typeof(DiagnosticsTestMessage)); + } + + [Fact] + public void find_handler_chain_by_short_name() + { + var chains = BuildTestChains(); + var found = WolverineDiagnosticsCommand.FindHandlerChain("DiagnosticsTestMessage", chains); + found.ShouldNotBeNull(); + found.MessageType.ShouldBe(typeof(DiagnosticsTestMessage)); + } + + [Fact] + public void find_handler_chain_by_handler_class_name() + { + var chains = BuildTestChains(); + var found = WolverineDiagnosticsCommand.FindHandlerChain("DiagnosticsTestHandler", chains); + found.ShouldNotBeNull(); + found.MessageType.ShouldBe(typeof(DiagnosticsTestMessage)); + } + + [Fact] + public void find_handler_chain_fuzzy_contains_message_type() + { + var chains = BuildTestChains(); + var found = WolverineDiagnosticsCommand.FindHandlerChain("TestMessage", chains); + found.ShouldNotBeNull(); + found.MessageType.ShouldBe(typeof(DiagnosticsTestMessage)); + } + + [Fact] + public void find_handler_chain_returns_null_for_unknown() + { + var chains = BuildTestChains(); + var found = WolverineDiagnosticsCommand.FindHandlerChain("NonExistentXyzHandler", chains); + found.ShouldBeNull(); + } + + private static HandlerChain[] BuildTestChains() + { + // Compile a real HandlerGraph so we get genuine HandlerChain objects. + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + var host = Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery + .DisableConventionalDiscovery() + .IncludeType(typeof(DiagnosticsTestHandler)); + }) + .Build(); + + // Accessing ICodeFileCollection triggers HandlerGraph.Compile() + host.Services.GetServices().ToArray(); + + var graph = host.Services.GetRequiredService(); + return graph.AllChains().ToArray(); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + // ── Full codegen-preview smoke test ────────────────────────────────────── + + [Fact] + public async Task codegen_preview_generates_code_for_handler() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery + .DisableConventionalDiscovery() + .IncludeType(typeof(DiagnosticsTestHandler)); + }) + .StartAsync(); + + var services = host.Services; + var serviceVariableSource = services.GetService(); + var graph = services.GetRequiredService(); + var chains = graph.AllChains().ToArray(); + + var chain = WolverineDiagnosticsCommand.FindHandlerChain("DiagnosticsTestMessage", chains); + chain.ShouldNotBeNull(); + + // Mimic what the command does: generate code for a single chain + var generatedAssembly = graph.StartAssembly(graph.Rules); + ((JasperFx.CodeGeneration.ICodeFile)chain).AssembleTypes(generatedAssembly); + var code = generatedAssembly.GenerateCode(serviceVariableSource); + + code.ShouldNotBeNullOrEmpty(); + code.ShouldContain("DiagnosticsTestHandler"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +public record DiagnosticsTestMessage(string Text); + +public static class DiagnosticsTestHandler +{ + public static void Handle(DiagnosticsTestMessage message) + { + // intentionally empty — used only for code-generation testing + } +} diff --git a/src/Wolverine/Diagnostics/WolverineDiagnosticsCommand.cs b/src/Wolverine/Diagnostics/WolverineDiagnosticsCommand.cs new file mode 100644 index 000000000..28f859a62 --- /dev/null +++ b/src/Wolverine/Diagnostics/WolverineDiagnosticsCommand.cs @@ -0,0 +1,306 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using JasperFx.CommandLine; +using JasperFx.Core; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Diagnostics; + +public class WolverineDiagnosticsInput : NetCoreInput +{ + [Description("Diagnostics sub-command to execute. Valid values: codegen-preview")] + public string Action { get; set; } = "codegen-preview"; + + [FlagAlias("handler", 'h')] + [Description( + "For codegen-preview: preview generated code for a specific message handler. " + + "Accepts a fully-qualified message type name (e.g. MyApp.Orders.CreateOrder), " + + "a short class name (e.g. CreateOrder), or a handler class name (e.g. CreateOrderHandler).")] + public string HandlerFlag { get; set; } = string.Empty; + + [FlagAlias("route", 'r')] + [Description( + "For codegen-preview: preview generated code for a specific HTTP endpoint. " + + "Format: 'METHOD /path' (e.g. 'POST /api/orders' or 'GET /api/orders/{id}').")] + public string RouteFlag { get; set; } = string.Empty; +} + +/// +/// Parent command for Wolverine diagnostics sub-commands. Currently supports: +/// +/// +/// codegen-preview --handler <type> — show generated handler adapter code +/// +/// +/// codegen-preview --route "METHOD /path" — show generated HTTP endpoint code +/// +/// +/// +[Description("Wolverine diagnostics tools for inspecting generated code and runtime behavior", + Name = "wolverine-diagnostics")] +public class WolverineDiagnosticsCommand : JasperFxAsyncCommand +{ + public WolverineDiagnosticsCommand() + { + Usage("Run a diagnostics sub-command (e.g. codegen-preview)").Arguments(x => x.Action) + .ValidFlags(x => x.HandlerFlag, x => x.RouteFlag); + } + + public override async Task Execute(WolverineDiagnosticsInput input) + { + switch (input.Action.ToLowerInvariant()) + { + case "codegen-preview": + return await RunCodegenPreviewAsync(input); + + default: + AnsiConsole.MarkupLine( + $"[red]Unknown sub-command '{input.Action}'. Valid sub-commands: codegen-preview[/]"); + return false; + } + } + + private static async Task RunCodegenPreviewAsync(WolverineDiagnosticsInput input) + { + if (input.HandlerFlag.IsEmpty() && input.RouteFlag.IsEmpty()) + { + AnsiConsole.MarkupLine( + "[red]codegen-preview requires either --handler or --route \"METHOD /path\".[/]"); + return false; + } + + // Set codegen mode BEFORE building the host so Wolverine applies lightweight startup + // (no database connections, no transport connections). + DynamicCodeBuilder.WithinCodegenCommand = true; + + try + { + using var host = input.BuildHost(); + + // Starting the host with WithinCodegenCommand=true applies lightweight mode + // automatically (stubs transports, disables durability). This is necessary to + // ensure HandlerGraph.Compile() runs and HTTP chains are registered. + await host.StartAsync(); + + var services = host.Services; + var serviceVariableSource = services.GetService(); + + if (!input.HandlerFlag.IsEmpty()) + { + return PreviewHandlerCode(input.HandlerFlag, services, serviceVariableSource); + } + + return PreviewRouteCode(input.RouteFlag, services, serviceVariableSource); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + private static bool PreviewHandlerCode( + string handlerSearch, + IServiceProvider services, + IServiceVariableSource? serviceVariableSource) + { + var handlerGraph = services.GetRequiredService(); + var chains = handlerGraph.AllChains().ToArray(); + + var chain = FindHandlerChain(handlerSearch, chains); + + if (chain == null) + { + AnsiConsole.MarkupLine($"[red]No handler found matching '[bold]{Markup.Escape(handlerSearch)}[/]'.[/]"); + AnsiConsole.MarkupLine("[grey]Available message handlers:[/]"); + foreach (var c in chains.Take(30)) + { + AnsiConsole.MarkupLine($" [grey]{Markup.Escape(c.MessageType.FullName ?? c.MessageType.Name)}[/]"); + } + + if (chains.Length > 30) + { + AnsiConsole.MarkupLine($" [grey]... and {chains.Length - 30} more[/]"); + } + + return false; + } + + var code = GenerateSingleFileCode(handlerGraph, chain, serviceVariableSource); + PrintCodegenResult( + $"Handler chain for message: {chain.MessageType.FullName}", + chain.Description, + code); + return true; + } + + internal static HandlerChain? FindHandlerChain(string search, HandlerChain[] chains) + { + // 1. Exact full name match on the message type + var chain = chains.FirstOrDefault(c => + string.Equals(c.MessageType.FullName, search, StringComparison.OrdinalIgnoreCase)); + + if (chain != null) return chain; + + // 2. Short name match on message type + chain = chains.FirstOrDefault(c => + string.Equals(c.MessageType.Name, search, StringComparison.OrdinalIgnoreCase)); + + if (chain != null) return chain; + + // 3. Handler class name match (e.g. "CreateOrderHandler") + chain = chains.FirstOrDefault(c => + c.Handlers.Any(h => + string.Equals(h.HandlerType.Name, search, StringComparison.OrdinalIgnoreCase))); + + if (chain != null) return chain; + + // 4. Fuzzy contains match — message type full name or handler class name + var matches = chains.Where(c => + (c.MessageType.FullName?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false) || + c.MessageType.Name.Contains(search, StringComparison.OrdinalIgnoreCase) || + c.Handlers.Any(h => h.HandlerType.Name.Contains(search, StringComparison.OrdinalIgnoreCase)) + ).ToArray(); + + if (matches.Length == 1) return matches[0]; + + if (matches.Length > 1) + { + AnsiConsole.MarkupLine($"[yellow]Multiple handlers match '[bold]{Markup.Escape(search)}[/]'. Please be more specific:[/]"); + foreach (var m in matches) + { + AnsiConsole.MarkupLine($" [yellow]{Markup.Escape(m.MessageType.FullName ?? m.MessageType.Name)}[/]"); + } + } + + return null; + } + + private static bool PreviewRouteCode( + string routeInput, + IServiceProvider services, + IServiceVariableSource? serviceVariableSource) + { + // Compute the expected file name the HttpChain would generate for this route. + // This mirrors the logic in HttpChain.determineFileName() so we can match without + // taking a direct compile-time dependency on Wolverine.Http from core Wolverine. + var expectedFileName = RouteInputToFileName(routeInput); + + // Search all registered ICodeFileCollection instances (includes HandlerGraph and + // any supplemental collections such as HttpGraph added by Wolverine.Http). + var allCollections = services.GetServices().ToArray(); + + ICodeFileCollection? foundCollection = null; + ICodeFile? foundFile = null; + + foreach (var collection in allCollections) + { + foreach (var file in collection.BuildFiles()) + { + var fileName = file.FileName.Replace(" ", "_"); + if (string.Equals(fileName, expectedFileName, StringComparison.OrdinalIgnoreCase)) + { + foundCollection = collection; + foundFile = file; + break; + } + } + + if (foundFile != null) break; + } + + if (foundFile == null) + { + AnsiConsole.MarkupLine( + $"[red]No HTTP endpoint found matching '[bold]{Markup.Escape(routeInput)}[/]' " + + $"(expected file name: [bold]{Markup.Escape(expectedFileName)}[/]).[/]"); + AnsiConsole.MarkupLine("[grey]Available HTTP endpoints (file names):[/]"); + + foreach (var collection in allCollections) + { + foreach (var f in collection.BuildFiles()) + { + // Heuristic: HTTP chain file names start with an HTTP method prefix + var fn = f.FileName; + if (fn.StartsWith("GET_", StringComparison.OrdinalIgnoreCase) || + fn.StartsWith("POST_", StringComparison.OrdinalIgnoreCase) || + fn.StartsWith("PUT_", StringComparison.OrdinalIgnoreCase) || + fn.StartsWith("DELETE_", StringComparison.OrdinalIgnoreCase) || + fn.StartsWith("PATCH_", StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine($" [grey]{Markup.Escape(fn)}[/]"); + } + } + } + + return false; + } + + var code = GenerateSingleFileCode(foundCollection!, foundFile, serviceVariableSource); + PrintCodegenResult($"HTTP endpoint: {routeInput}", foundFile.FileName, code); + return true; + } + + /// + /// Converts a route string like "POST /api/orders/{id}" to the file name that HttpChain + /// would generate (e.g. "POST_api_orders_id"). Mirrors HttpChain.determineFileName(). + /// + internal static string RouteInputToFileName(string routeInput) + { + var trimmed = routeInput.Trim(); + var spaceIndex = trimmed.IndexOf(' '); + + string method; + string path; + + if (spaceIndex > 0) + { + method = trimmed[..spaceIndex].ToUpperInvariant(); + path = trimmed[(spaceIndex + 1)..]; + } + else + { + method = string.Empty; + path = trimmed; + } + + // Mirror HttpChain path processing: strip route constraint suffixes, braces, wildcards, dots + var pathParts = path + .Replace("{", "") + .Replace("}", "") + .Replace("*", "") + .Replace("?", "") + .Replace(".", "_") + .Split('/') + .Select(segment => segment.Split(':').First()); + + var segments = (method.Length > 0 ? new[] { method } : Array.Empty()) + .Concat(pathParts); + + return string.Join("_", segments).Replace('-', '_').Replace("__", "_").Trim('_'); + } + + private static string GenerateSingleFileCode( + ICodeFileCollection collection, + ICodeFile file, + IServiceVariableSource? serviceVariableSource) + { + var generatedAssembly = collection.StartAssembly(collection.Rules); + file.AssembleTypes(generatedAssembly); + + // Pass the service variable source only when the collection requires IoC resolution + var svs = collection is ICodeFileCollectionWithServices ? serviceVariableSource : null; + return generatedAssembly.GenerateCode(svs); + } + + private static void PrintCodegenResult(string heading, string description, string code) + { + AnsiConsole.MarkupLine($"[bold green]{Markup.Escape(heading)}[/]"); + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(description)}[/]"); + AnsiConsole.WriteLine(); + // Print the raw code without markup so it is copy-pasteable + Console.WriteLine(code); + } +}