diff --git a/CHANGELOG.md b/CHANGELOG.md index 57bcb2fb..a932e8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ Nerdbank.GitVersioning at pack time; this file groups changes by theme instead o ## Unreleased +### Added — execution outcomes and exit-code policy + +- Every run that reaches the core pipeline now ends in a structured `ReplExecutionOutcome` whose + `ReplExecutionOutcomeKind` distinguishes `Success`, `Help`, `UsageError`, `BindingError`, + `HandlerError`, `HandlerExitCode`, `HandlerException`, `Cancelled`, `Interrupted` (reserved for + process-signal bridges), and `FrameworkError`. The kind is mapped to an integer by the new + `ReplOptions.ExitCodes` (`ExitCodeOptions`) table, then passed to the optional + `ExitCodes.Resolver` hook whose return value is the final exit code. An explicit `Results.Exit(n)` + keeps its code verbatim (`HandlerExitCode`) but is still visible to the resolver. See + `docs/execution-pipeline.md` (stage 12) and `docs/configuration-reference.md`. +- `ExitCodes.Cancelled` (`int?`) turns a caller-token cancellation into an exit code instead of + letting `OperationCanceledException` escape `RunAsync`. It is unset by default, which preserves + the existing throwing behaviour. +- `ReplExecutionContext.Result` exposes the handler's return value to middleware registered with + `app.Use(...)`: readable and replaceable after `await next()`, settable by a short-circuiting + middleware. `ReplNext` and the `Use` signature are unchanged. + +### Changed — breaking: framework exit codes + +- Framework refusals now exit `2` instead of `1`: unknown command, ambiguous prefix, invalid global + or command option, option collision, context validation failure, unknown `--output` format, + ambient-command misuse in one-shot mode (`exit` while disabled, `..`, `complete` without + `--target`), help that cannot be rendered (`UsageError`), and arguments that cannot be bound, + converted, or resolved from context/services (`BindingError`). Handler + failures (`Results.Error`/`Validation`/`NotFound`, exceptions) still exit `1`, help and success + still exit `0`. Set `ExitCodes.UsageError`/`BindingError` back to `1` to restore the old numbers. + The interactive loop reports the same resolved codes in shell-integration `D;` marks. +- Every `Run`/`RunAsync` overload now checks the caller's `CancellationToken` before doing any work: a + token that is already cancelled throws `OperationCanceledException` (or returns + `ExitCodes.Cancelled` when mapped). Previously only `CoreReplApp.RunAsync` performed that check; + the `ReplApp` overloads let a token-ignoring handler run to completion. + +### Compatibility notes — exit codes + +- MCP tool calls (nested sub-invocations) always use the built-in exit-code defaults and ignore + `ExitCodes.Resolver`; they only test for non-zero, so `IsError` is unaffected by the policy. The + agent-visible failure text now reads "exit code 2" for usage and binding refusals. +- Hosted-service start/stop failures in `ReplApp.RunAsync` (with `HostedServiceLifecycle` enabled) + still return `1` directly and do not pass through `ExitCodes`; routing them through the policy is + deferred until the pending process-signal work in the same file lands. +- `Repl.Testing`'s per-command timeout still surfaces as `TimeoutException` when the app under test + maps `ExitCodes.Cancelled`: the handle checks its own timeout token after the run instead of + relying on the exception escaping. +- A handler-thrown `InvalidOperationException` is still rendered as a validation message, but it + is classified `HandlerException` (not `BindingError`); only exceptions raised while binding + arguments are `BindingError`. +- A handler that returns a bare `int` (or any scalar) is unchanged: the value is rendered as data + and the run is a `Success`. The documentation previously implied otherwise; `Results.Exit(n)` + remains the only return-value route to an explicit exit code. +- `Repl.Testing`'s `CommandExecution.ExitCode` follows the configured policy, so application test + suites asserting `1` for unknown commands or invalid options need to expect `2` (or configure + `ExitCodes`). +- `ReplExecutionOutcomeKind.Interrupted` has no table entry and is never produced by the core + pipeline; it is reserved for the process-signal bridge so SIGINT/SIGTERM outcomes can flow through + the same resolver. + ### Added — option visibility - `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`) diff --git a/docs/best-practices.md b/docs/best-practices.md index 9bf3f90a..8fa875e3 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -247,6 +247,34 @@ app.Map("delete {id:int}", handler) .WithAnswer("confirm", "bool", "Confirm the deletion"); ``` +## Make exit codes scriptable + +A headless tool — one command per process, spawned by CI or by a parent program — is judged by its +exit code. Repl classifies every run into a `ReplExecutionOutcomeKind` and maps it through +`ReplOptions.ExitCodes`, so the contract is configured once instead of being re-implemented in every +handler: + +```csharp +app.Options(options => +{ + options.ExitCodes.Help = 3; // a bare invocation printed help and did no work + options.ExitCodes.UsageError = 64; // EX_USAGE: the caller typed it wrong + options.ExitCodes.Cancelled = 130; // return 128+SIGINT instead of throwing +}); +``` + +- Keep usage errors (`2` by default) distinct from handler failures (`1`) so a pipeline can tell + "the invocation was wrong" from "the tool broke". +- Map `Help` to a non-zero code when a bare invocation must not pass a CI step that forgot its + arguments. +- Use `Results.Exit(code)` for codes a specific command owns; use `ExitCodes.Resolver` to apply an + organisation-wide convention to every final outcome — it also sees the `Result` object and the + `Exception`, so it can map on an error code rather than on a message. +- A handler's `int` return value is **data**, rendered like any other value; it never becomes the + exit code. +- Set `ExitCodes.Cancelled` when the caller owns a `CancellationToken` and wants an integer rather + than an `OperationCanceledException` escaping `RunAsync`. + ## Write deterministic tests Use `ReplTestHost` for integration tests with typed results: diff --git a/docs/commands.md b/docs/commands.md index ae67582f..3f9f4001 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -372,9 +372,10 @@ Handlers can return any type. The framework renders the return value through the | `ReplPage` | Rendered as the current page plus `PageInfo`; JSON uses `{ items, pageInfo }` | | `IReplResult` | Structured result with kind prefix (`Results.Ok`, `Error`, `NotFound`...) | | `ReplNavigationResult` | Renders payload and navigates scope (`Results.NavigateUp`, `NavigateTo`) | -| `IExitResult` | Renders optional payload and sets process exit code (`Results.Exit`) | +| `IExitResult` | Renders optional payload and sets the process exit code verbatim (`Results.Exit`); the only return type that carries an exit code — bypasses the `ReplOptions.ExitCodes` table, still visible to `ExitCodes.Resolver` | | `EnterInteractiveResult` | Renders optional payload and enters interactive REPL mode (`Results.EnterInteractive`) | | `void` / `null` | No output | +| `int` and other scalars | Rendered as data like any other value — **never** interpreted as an exit code (`int Count() => 3` prints `3`, exits `0`) | ### Result factory helpers @@ -411,10 +412,22 @@ Tuple semantics: - each element is rendered as a separate output block - navigation results (`NavigateUp`, `NavigateTo`) are only applied on the **last** element - `EnterInteractive` as the last element enters interactive mode after rendering prior elements -- exit code is determined by the last element +- the execution outcome (and therefore the exit code) is determined by the last element - null elements are silently skipped - nested tuples are not flattened — use a flat tuple instead +### Exit codes + +The process exit code is not read off the handler's return value; it is selected from the +structured outcome of the whole run (`ReplExecutionOutcomeKind`) through `ReplOptions.ExitCodes`. +By default a success or help invocation exits `0`, framework refusals — unknown command, invalid +option, unbindable argument — exit `2`, and a handler failure (`Results.Error`, an exception) +exits `1`. `Results.Exit(code)` is the only way for a handler to pick a code directly, and +`ExitCodes.Resolver` is the one place an application can remap every final outcome. A middleware +registered with `app.Use(...)` can read or replace the handler's return value through +`ReplExecutionContext.Result` after awaiting `next()`. See +[execution pipeline — exit code](execution-pipeline.md#12-exit-code) for the full table. + ## Paging large results Handlers that may return large result sets can request `IReplPagingContext`: diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index c05491ba..b90dc5dc 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -133,6 +133,24 @@ Accessed via `ReplOptions.Capabilities`. - `SupportsAnsi` (`bool`, default: `true`) — Declare whether the terminal supports ANSI escape sequences. +## ExitCodeOptions + +Accessed via `ReplOptions.ExitCodes`. Maps each `ReplExecutionOutcomeKind` to the process exit code +of a top-level run; nested MCP sub-invocations always use the defaults and skip the resolver. + +- `Success` (`int`, default: `0`) — Success-like handler result or clean interactive exit. +- `Help` (`int`, default: `0`) — `--help`, a bare invocation that prints help, scoped-context help. +- `UsageError` (`int`, default: `2`) — Unknown command, ambiguous prefix, invalid option, context validation failure, unknown output format. +- `BindingError` (`int`, default: `2`) — A handler argument could not be bound: token conversion failed or was missing, or a binder-resolved value (context value, `[FromServices]` dependency, typed global options service) was unavailable. +- `HandlerError` (`int`, default: `1`) — Handler returned an error-like `IReplResult`. +- `HandlerException` (`int`, default: `1`) — Handler or middleware threw. +- `Cancelled` (`int?`, default: `null`) — Caller-token cancellation. `null` rethrows the `OperationCanceledException`; a value (typically `130`) is returned instead. +- `FrameworkError` (`int`, default: `1`) — Incompatible programmatic adapter or unsupported hosting capability. +- `Resolver` (`Func?`, default: `null`) — Final interception hook. Receives the outcome with its table-mapped `ExitCode`; its return value is the process exit code. Also sees `HandlerExitCode` outcomes (explicit `Results.Exit`), which bypass the table. + +`ReplExecutionOutcomeKind.Interrupted` is reserved for process-signal bridges (SIGINT/SIGTERM) and has +no table entry; the core pipeline never produces it. + ## AmbientCommandOptions Accessed via `ReplOptions.AmbientCommands`. diff --git a/docs/execution-pipeline.md b/docs/execution-pipeline.md index 486c435c..d5760996 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -181,21 +181,30 @@ handler invocation and produce an error result. via `app.Use()`, then invokes the pipeline. The final stage calls `CommandInvoker.InvokeAsync()` which executes the handler delegate. -The invoker supports multiple return types: +The invoker supports synchronous, `Task`, `Task`, `ValueTask`, and `ValueTask` handlers: ```csharp -// Synchronous -int Run() => 0; +// Synchronous, returns data +int Count() => items.Length; -// Async -async Task RunAsync() => 0; -async ValueTask RunAsync() => 0; +// Async, returns data +async Task ListAsync() => await store.ListAsync(); +async ValueTask DescribeAsync() => "ready"; // Void (implicit success) void Run() { } async Task RunAsync() { } ``` +> **A handler's return value is always rendered as data — including `int`.** It never becomes the +> process exit code: `int Count() => 3` prints `3` and exits `0`. To set an explicit exit code, +> return `Results.Exit(code)` (or any `IExitResult`). + +Once the handler has run, its return value is exposed as `ReplExecutionContext.Result`, so a +middleware registered with `app.Use(...)` can inspect or replace it after awaiting `next()`. A +middleware that short-circuits (never calls `next()`) may set `context.Result` to supply the result +that is rendered and classified in the handler's place. + ### 10. Result Processing The raw handler return value is unwrapped and interpreted: @@ -219,13 +228,48 @@ The matching `IOutputTransformer` formats the result and writes it to stdout. ### 12. Exit Code -The final exit code is derived from the result: +Every run that reaches the core pipeline ends in exactly one `ReplExecutionOutcomeKind`, decided +once after every pipeline layer has run. (The one path outside the pipeline is a hosted-service +start/stop failure in `ReplApp.RunAsync` with `HostedServiceLifecycle` enabled, which still returns +`1` directly.) The kind is mapped to an integer by `ReplOptions.ExitCodes` +(`ExitCodeOptions`), then handed to the optional `ExitCodes.Resolver` hook whose return value is +final: + +| Kind | Produced by | Default code | +|---|---|---| +| `Success` | success-like handler result (`text`/`success`, plain data, `void`), ambient commands (`exit`, `..`) that did their job, clean interactive exit | `0` | +| `Help` | `--help`, bare invocation that prints help, scoped-context help | `0` | +| `UsageError` | unknown command, ambiguous prefix, invalid global or command option, context validation failure, unknown `--output` format | `2` | +| `BindingError` | a handler argument could not be bound: token conversion failed or was missing, or a binder-resolved value (context value, `[FromServices]` dependency, typed global options service) was unavailable | `2` | +| `HandlerError` | handler returned `Results.Error` / `Validation` / `NotFound` / `Cancelled` | `1` | +| `HandlerExitCode` | handler returned an `IExitResult` — its code is used verbatim, the table is bypassed | `IExitResult.ExitCode` | +| `HandlerException` | the handler or a middleware threw | `1` | +| `Cancelled` | the run ended with an `OperationCanceledException` — the caller's `CancellationToken`, a cancelled prompt, or a handler that threw it | unmapped: the exception propagates; set `ExitCodes.Cancelled` (e.g. `130`) to return a code instead | +| `Interrupted` | reserved for process-signal bridges (SIGINT/SIGTERM); the core pipeline never produces it | — | +| `FrameworkError` | incompatible programmatic adapter, unsupported hosting capability | `1` | + +```csharp +app.Options(options => +{ + options.ExitCodes.UsageError = 64; // EX_USAGE for scripts that follow sysexits + options.ExitCodes.Help = 3; // a bare invocation must not look like success in CI + options.ExitCodes.Cancelled = 130; // return 128+SIGINT instead of throwing + options.ExitCodes.Resolver = outcome => // final say, sees the structured outcome + outcome.Kind == ReplExecutionOutcomeKind.HandlerException ? 70 : outcome.ExitCode; +}); +``` + +The resolver receives a `ReplExecutionOutcome` (`Kind`, table-mapped `ExitCode`, the final `Result` +object when one exists, and the `Exception` that ended the run when applicable). It runs once per +one-shot run and is not invoked when `Kind` is `Cancelled` and `ExitCodes.Cancelled` is unset — the +exception is rethrown instead. In an interactive session it is invoked once per committed command +(to compute the shell-integration mark code, where a Ctrl+C cancellation carries the conventional +`130` unless `ExitCodes.Cancelled` overrides it) and once more when the session exits. -| Result | Exit Code | -|---|---| -| Success (or void) | `0` | -| Failure | `1` | -| `IExitResult` | `IExitResult.ExitCode` | +Nested sub-invocations (MCP tool calls executed through the Repl pipeline) always use the built-in +defaults and skip the resolver: the policy describes the *process* exit, and nested callers only test +for non-zero. The interactive loop applies the same table and resolver when it reports a command's +exit code in shell-integration marks, so the terminal decoration and the CLI agree. ## Error Handling @@ -238,8 +282,9 @@ Errors at each stage produce targeted diagnostics: - **Binding errors** — renders a message identifying the missing or invalid parameter. - **Handler exceptions** — caught and unwrapped from `TargetInvocationException`, then rendered as an error to stderr. -- **Cancellation** — `OperationCanceledException` is either propagated to the caller - or rendered as a cancellation message, depending on context. +- **Cancellation** — in one-shot mode `OperationCanceledException` propagates to the caller + unless `ReplOptions.ExitCodes.Cancelled` is set, in which case the run returns that code with + `ReplExecutionOutcomeKind.Cancelled`; the interactive loop renders a cancellation message instead. ## Interactive Session Loop diff --git a/docs/interactive-loop.md b/docs/interactive-loop.md index 46c0fb53..76fc355e 100644 --- a/docs/interactive-loop.md +++ b/docs/interactive-loop.md @@ -26,7 +26,7 @@ app.Map("setup", () => Results.EnterInteractive()); // explicit transition 5. Execute the command through the pipeline. 6. Repeat until exit. -When [terminal shell integration](terminal-shell-integration.md) is enabled, the loop brackets each cycle with semantic marks: prompt start before step 1, input start before step 2, the command-line report (VS Code) and output start between steps 3 and 5, and a single command-end mark carrying the exit code after step 5. +When [terminal shell integration](terminal-shell-integration.md) is enabled, the loop brackets each cycle with semantic marks: prompt start before step 1, input start before step 2, the command-line report (VS Code) and output start between steps 3 and 5, and a single command-end mark carrying the exit code (resolved through `ReplOptions.ExitCodes`, like a one-shot run) after step 5. ## Prompt and Autocompletion diff --git a/docs/terminal-shell-integration.md b/docs/terminal-shell-integration.md index 16fa1fac..7cb811c3 100644 --- a/docs/terminal-shell-integration.md +++ b/docs/terminal-shell-integration.md @@ -29,7 +29,7 @@ In interactive REPL mode, each prompt cycle is delimited with the FinalTerm sema | Right before command execution | `C` (output start) | | After the command completes | `D;` (command end) | -Exit codes follow shell conventions: `0` for success, `1` for errors (failed results, unknown commands, validation failures), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C. The interruption decoration is intentionally broad: a handler that throws `OperationCanceledException` for any other reason (its own timeout, a linked token) also reports `130`, because the loop cannot tell who requested the cancellation. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form. +Exit codes follow the same `ReplOptions.ExitCodes` policy as a one-shot run — by default `0` for success, `1` for a failed handler result or exception, `2` for usage errors (unknown commands, ambiguous prefixes, invalid options), and `130` (128+SIGINT) when a command is cancelled with Ctrl+C (`ExitCodes.Cancelled` overrides it when set). The interruption decoration is intentionally broad: a handler that throws `OperationCanceledException` for any other reason (its own timeout, a linked token) also reports `130`, because the loop cannot tell who requested the cancellation. An abandoned cycle — Escape at the prompt, an empty line, or end of input — reports `D` without an exit code, the FinalTerm "command aborted" form. The VS Code `E` mark reports the exact committed command line (with protocol escaping), which makes VS Code's command detection independent of what is visible on screen. Note the privacy implication: whatever was typed at the prompt — including secrets passed as command arguments — is transmitted verbatim to the terminal, which may persist it for command detection and history. This mirrors what VS Code's own shell integration does for regular shells; if commands take secrets, prefer prompting for them interactively instead of passing them as arguments. diff --git a/docs/testing-toolkit.md b/docs/testing-toolkit.md index 2debd05f..a1f82c30 100644 --- a/docs/testing-toolkit.md +++ b/docs/testing-toolkit.md @@ -33,7 +33,7 @@ var execution = await session.RunCommandAsync("hello --no-logo"); ```csharp var execution = await session.RunCommandAsync("contact show --json --no-logo"); -var exitCode = execution.ExitCode; // numeric process-style status +var exitCode = execution.ExitCode; // process-style status, follows ReplOptions.ExitCodes var text = execution.OutputText; // rendered output text var duration = execution.Duration; // elapsed command time ``` diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs index e74ab9c7..1ed19c5b 100644 --- a/src/Repl.Core/CoreReplApp.Execution.cs +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -32,7 +32,6 @@ public ValueTask RunAsync(string[] args, CancellationToken cancellationToke _ = _commands.Count; _ = _middleware.Count; _ = _options; - cancellationToken.ThrowIfCancellationRequested(); return ExecuteCoreAsync(args, _services, cancellationToken: cancellationToken); } @@ -68,23 +67,15 @@ private async ValueTask ExecuteCoreAsync( _options.Interaction.SetObserver(observer: ExecutionObserver); try { - if (ReplSessionIO.IsProgrammatic && !ReplSessionIO.HasCurrentProgrammaticInvocationContract) - { - _ = await RenderOutputAsync( - Results.Validation( - "The programmatic invocation adapter is incompatible with this Repl.Core version. " - + "Update Repl.Mcp to the same package version."), - requestedFormat: null, - cancellationToken) - .ConfigureAwait(false); - return 1; - } - - var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); - if (await TryHandleGlobalDiagnosticsAsync(globalOptions, cancellationToken).ConfigureAwait(false) is { } globalDiagnosticsExitCode) return globalDiagnosticsExitCode; - - return await ExecuteParsedCoreAsync(globalOptions, serviceProvider, isSubInvocation, cancellationToken) + // Inside the try so a token cancelled before the run follows the same Cancelled policy. + cancellationToken.ThrowIfCancellationRequested(); + var outcome = await ExecuteCoreOutcomeAsync(args, serviceProvider, isSubInvocation, cancellationToken) .ConfigureAwait(false); + return ResolveExitCode(outcome, isSubInvocation); + } + catch (OperationCanceledException ex) when (!isSubInvocation && _options.ExitCodes.Cancelled is not null) + { + return ResolveExitCode(ExecutionOutcome.Cancelled(ex), isSubInvocation: false); } finally { @@ -92,7 +83,51 @@ private async ValueTask ExecuteCoreAsync( } } - private async ValueTask ExecuteParsedCoreAsync( + private async ValueTask ExecuteCoreOutcomeAsync( + IReadOnlyList args, + IServiceProvider serviceProvider, + bool isSubInvocation, + CancellationToken cancellationToken) + { + if (ReplSessionIO.IsProgrammatic && !ReplSessionIO.HasCurrentProgrammaticInvocationContract) + { + var contractFailure = Results.Validation( + "The programmatic invocation adapter is incompatible with this Repl.Core version. " + + "Update Repl.Mcp to the same package version."); + _ = await RenderOutputAsync(contractFailure, requestedFormat: null, cancellationToken) + .ConfigureAwait(false); + return ExecutionOutcome.Framework(contractFailure); + } + + var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); + if (await TryHandleGlobalDiagnosticsAsync(globalOptions, cancellationToken).ConfigureAwait(false) is { } globalDiagnostics) + { + return globalDiagnostics; + } + + return await ExecuteParsedCoreAsync(globalOptions, serviceProvider, isSubInvocation, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Applies the exit-code policy exactly once per run. Sub-invocations (nested MCP tool calls) keep the + /// built-in defaults and skip : the policy describes the process + /// exit, and nested callers only test for non-zero. + /// + internal int ResolveExitCode(ExecutionOutcome outcome, bool isSubInvocation) + { + if (isSubInvocation) + { + return ExitCodeOptions.MapDefault(outcome.Kind, outcome.ExplicitExitCode); + } + + var exitCode = _options.ExitCodes.Map(outcome.Kind, outcome.ExplicitExitCode); + return _options.ExitCodes.Resolver is { } resolver + ? resolver(new ReplExecutionOutcome(outcome.Kind, exitCode, outcome.Result, outcome.Exception)) + : exitCode; + } + + private async ValueTask ExecuteParsedCoreAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, bool isSubInvocation, @@ -106,14 +141,14 @@ private async ValueTask ExecuteParsedCoreAsync( using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( + var ambiguousOutcome = await TryHandleAmbiguousPrefixAsync( prefixResolution, globalOptions, resolvedGlobalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false); - if (ambiguousExitCode is not null) return ambiguousExitCode.Value; + if (ambiguousOutcome is not null) return ambiguousOutcome.Value; var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) @@ -121,12 +156,12 @@ private async ValueTask ExecuteParsedCoreAsync( await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } - var preExecutionExitCode = await TryHandlePreExecutionAsync( + var preExecutionOutcome = await TryHandlePreExecutionAsync( resolvedGlobalOptions, serviceProvider, cancellationToken) .ConfigureAwait(false); - if (preExecutionExitCode is not null) return preExecutionExitCode.Value; + if (preExecutionOutcome is not null) return preExecutionOutcome.Value; var resolution = preResolvedRouteResolution ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); @@ -150,7 +185,7 @@ private async ValueTask ExecuteParsedCoreAsync( .ConfigureAwait(false); } - private async ValueTask TryHandleAmbiguousPrefixAsync( + private async ValueTask TryHandleAmbiguousPrefixAsync( PrefixResolutionResult prefixResolution, GlobalInvocationOptions globalOptions, GlobalInvocationOptions resolvedGlobalOptions, @@ -170,7 +205,7 @@ private async ValueTask ExecuteParsedCoreAsync( var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return ExecutionOutcome.Usage(ambiguous); } private static bool ShouldSuppressGlobalBanner( @@ -195,7 +230,7 @@ private static bool ShouldSuppressGlobalBanner( return ResolveWithDiagnostics(globalOptions.RemainingTokens); } - private async ValueTask TryHandlePreExecutionAsync( + private async ValueTask TryHandlePreExecutionAsync( GlobalInvocationOptions options, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -210,7 +245,7 @@ private static bool ShouldSuppressGlobalBanner( if (options.HelpRequested) { var rendered = await RenderHelpAsync(options, cancellationToken).ConfigureAwait(false); - return rendered ? 0 : 1; + return rendered ? ExecutionOutcome.Help : ExecutionOutcome.Usage(); } if (options.RemainingTokens.Count == 0) @@ -223,7 +258,7 @@ private static bool ShouldSuppressGlobalBanner( .ConfigureAwait(false); } - private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( + private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( RouteMatch match, GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -235,7 +270,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( .ConfigureAwait(false); } - var (exitCode, enterInteractive) = await ExecuteMatchedCommandAsync( + var (outcome, enterInteractive) = await ExecuteMatchedCommandAsync( match, globalOptions, serviceProvider, @@ -243,7 +278,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( cancellationToken) .ConfigureAwait(false); - if (enterInteractive || (exitCode == 0 && ShouldEnterInteractive(globalOptions, allowAuto: false))) + if (enterInteractive || (outcome.IsSuccessLike && ShouldEnterInteractive(globalOptions, allowAuto: false))) { var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); @@ -251,7 +286,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); } - return exitCode; + return outcome; } /// @@ -261,7 +296,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( /// outside hosted sessions — stdout/stderr isolation (framework output on stderr, the /// handler payload alone on stdout). /// - internal async ValueTask ExecuteProtocolPassthroughCommandAsync( + internal async ValueTask ExecuteProtocolPassthroughCommandAsync( RouteMatch match, GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -269,28 +304,26 @@ internal async ValueTask ExecuteProtocolPassthroughCommandAsync( { if (ReplSessionIO.IsHostedSession && !match.Route.Command.SupportsHostedProtocolPassthrough) { - _ = await RenderOutputAsync( - Results.Error( - "protocol_passthrough_hosted_not_supported", - $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), - globalOptions.OutputFormat, - cancellationToken) + var refusal = Results.Error( + "protocol_passthrough_hosted_not_supported", + $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."); + _ = await RenderOutputAsync(refusal, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return ExecutionOutcome.Framework(refusal); } using var protocolPassthroughScope = ReplSessionIO.PushProtocolPassthrough(); if (ReplSessionIO.IsSessionActive) { - var (exitCode, _) = await ExecuteMatchedCommandAsync( + var (sessionOutcome, _) = await ExecuteMatchedCommandAsync( match, globalOptions, serviceProvider, scopeTokens: null, cancellationToken) .ConfigureAwait(false); - return exitCode; + return sessionOutcome; } using var protocolScope = ReplSessionIO.SetSession( @@ -300,17 +333,17 @@ internal async ValueTask ExecuteProtocolPassthroughCommandAsync( commandOutput: Console.Out, error: Console.Error, isHostedSession: false); - var (code, _) = await ExecuteMatchedCommandAsync( + var (outcome, _) = await ExecuteMatchedCommandAsync( match, globalOptions, serviceProvider, scopeTokens: null, cancellationToken) .ConfigureAwait(false); - return code; + return outcome; } - private async ValueTask HandleEmptyInvocationAsync( + private async ValueTask HandleEmptyInvocationAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -322,10 +355,10 @@ private async ValueTask HandleEmptyInvocationAsync( var helpText = BuildHumanHelp([]); await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return 0; + return ExecutionOutcome.Help; } - private async ValueTask TryHandleCompletionCommandAsync( + private async ValueTask TryHandleCompletionCommandAsync( GlobalInvocationOptions options, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -342,10 +375,10 @@ private async ValueTask HandleEmptyInvocationAsync( serviceProvider: serviceProvider, cancellationToken: cancellationToken) .ConfigureAwait(false); - return completed ? 0 : 1; + return completed ? ExecutionOutcome.Success : ExecutionOutcome.Usage(); } - private async ValueTask TryHandleAmbientInNonInteractiveAsync( + private async ValueTask TryHandleAmbientInNonInteractiveAsync( GlobalInvocationOptions options, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -373,9 +406,9 @@ private async ValueTask HandleEmptyInvocationAsync( return ambientOutcome switch { - AmbientCommandOutcome.Exit => 0, - AmbientCommandOutcome.Handled => 0, - AmbientCommandOutcome.HandledError => 1, + AmbientCommandOutcome.Exit => ExecutionOutcome.Success, + AmbientCommandOutcome.Handled => ExecutionOutcome.Success, + AmbientCommandOutcome.HandledError => ExecutionOutcome.Usage(), _ => null, }; } @@ -417,7 +450,7 @@ private async ValueTask TryRenderBannerAsync( _bannerRendered.Value = true; } - private async ValueTask TryHandleContextDeeplinkAsync( + private async ValueTask TryHandleContextDeeplinkAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, CancellationToken cancellationToken, @@ -434,7 +467,7 @@ private async ValueTask TryHandleContextDeeplinkAsync( missingArgumentsFailure); _ = await RenderOutputAsync(failure, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return ExecutionOutcome.Usage(failure); } var contextValidation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken) @@ -446,14 +479,14 @@ private async ValueTask TryHandleContextDeeplinkAsync( globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return ExecutionOutcome.Usage(contextValidation.Failure); } if (!ShouldEnterInteractive(globalOptions, allowAuto: true)) { var helpText = BuildHumanHelp(globalOptions.RemainingTokens); await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return 0; + return ExecutionOutcome.Help; } return await RunInteractiveSessionAsync(globalOptions.RemainingTokens.ToArray(), serviceProvider, cancellationToken) @@ -464,7 +497,7 @@ private async ValueTask TryHandleContextDeeplinkAsync( "Maintainability", "MA0051:Method is too long", Justification = "Execution path intentionally keeps validation, binding, middleware and rendering in one place.")] - internal async ValueTask<(int ExitCode, bool EnterInteractive)> ExecuteMatchedCommandAsync( + internal async ValueTask<(ExecutionOutcome Outcome, bool EnterInteractive)> ExecuteMatchedCommandAsync( RouteMatch match, GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -480,12 +513,10 @@ private async ValueTask TryHandleContextDeeplinkAsync( var knownOptionNames = new HashSet(match.Route.OptionSchema.Parameters.Keys, optionComparer); if (TryFindGlobalCommandOptionCollision(globalOptions, knownOptionNames, out var collidingOption)) { - _ = await RenderOutputAsync( - Results.Validation($"Ambiguous option '{collidingOption}'. It is defined as both global and command option."), - globalOptions.OutputFormat, - cancellationToken) + var collision = Results.Validation($"Ambiguous option '{collidingOption}'. It is defined as both global and command option."); + _ = await RenderOutputAsync(collision, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return (1, false); + return (ExecutionOutcome.Usage(collision), false); } var parsedOptions = InvocationOptionParser.Parse( @@ -497,12 +528,10 @@ private async ValueTask TryHandleContextDeeplinkAsync( { var firstError = parsedOptions.Diagnostics .First(diagnostic => diagnostic.Severity == ParseDiagnosticSeverity.Error); - _ = await RenderOutputAsync( - Results.Validation(firstError.Message), - globalOptions.OutputFormat, - cancellationToken) + var optionFailure = Results.Validation(firstError.Message); + _ = await RenderOutputAsync(optionFailure, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return (1, false); + return (ExecutionOutcome.Usage(optionFailure), false); } var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); @@ -515,9 +544,14 @@ private async ValueTask TryHandleContextDeeplinkAsync( activeGraph.Contexts, serviceProvider, cancellationToken); + // Binding and the handler share one try so progress cleanup and rendering stay uniform; the flag + // tells a binder exception (InvalidOperationException, conversion FormatException, …) apart from + // anything thrown after binding — the handler, middleware, user validators, banners, transformers. + var bound = false; try { var arguments = HandlerArgumentBinder.Bind(match.Route.Command.Handler, bindingContext); + bound = true; var contextFailure = await ValidateContextsForMatchAsync( match, matchedPathTokens, @@ -529,7 +563,7 @@ private async ValueTask TryHandleContextDeeplinkAsync( { _ = await RenderOutputAsync(contextFailure, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return (1, false); + return (ExecutionOutcome.Usage(contextFailure), false); } await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputFormat, serviceProvider, cancellationToken) @@ -561,7 +595,7 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma .ConfigureAwait(false); } - return (0, true); + return (ExecutionOutcome.Success, true); } var normalizedResult = ApplyNavigationResult(result, scopeTokens); @@ -573,7 +607,8 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma scopeTokens is not null, globalOptions.ResultFlow) .ConfigureAwait(false); - return (rendered ? ComputeExitCode(normalizedResult) : 1, false); + // RenderOutputAsync returns false only for an unknown requested output format: a usage mistake. + return (rendered ? ClassifyResult(normalizedResult) : ExecutionOutcome.Usage(normalizedResult), false); } catch (OperationCanceledException) { @@ -585,20 +620,18 @@ await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputForma await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); _ = await RenderOutputAsync(Results.Validation(ex.Message), globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return (1, false); + return (bound ? ExecutionOutcome.Thrown(ex) : ExecutionOutcome.Binding(ex), false); } catch (Exception ex) { await TryClearProgressAsync(serviceProvider).ConfigureAwait(false); - var errorMessage = ex is TargetInvocationException { InnerException: not null } tie - ? tie.InnerException?.Message ?? ex.Message - : ex.Message; + var unwrapped = ex is TargetInvocationException { InnerException: { } inner } ? inner : ex; _ = await RenderOutputAsync( - Results.Error("execution_error", errorMessage), + Results.Error("execution_error", unwrapped.Message), globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return (1, false); + return (bound ? ExecutionOutcome.Thrown(unwrapped) : ExecutionOutcome.Binding(unwrapped), false); } } @@ -627,14 +660,14 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro } } - private async ValueTask<(int ExitCode, bool EnterInteractive)> RenderTupleResultAsync( + private async ValueTask<(ExecutionOutcome Outcome, bool EnterInteractive)> RenderTupleResultAsync( ITuple tuple, List? scopeTokens, GlobalInvocationOptions globalOptions, CancellationToken cancellationToken) { var isInteractive = scopeTokens is not null; - var exitCode = 0; + var outcome = ExecutionOutcome.Success; var enterInteractive = false; for (var i = 0; i < tuple.Length; i++) @@ -673,42 +706,38 @@ private static async ValueTask TryClearProgressAsync(IServiceProvider servicePro if (!rendered) { - return (1, false); + return (ExecutionOutcome.Usage(normalized), false); } if (isLast) { - exitCode = ComputeExitCode(normalized); + outcome = ClassifyResult(normalized); } } - return (exitCode, enterInteractive); + return (outcome, enterInteractive); } - private static int ComputeExitCode(object? result) + /// + /// Classifies a rendered handler result. Anything that is not an — including a + /// bare — is data and therefore a success; only carries a code. + /// + private static ExecutionOutcome ClassifyResult(object? result) { if (result is IExitResult exitResult) { - return exitResult.ExitCode; + return ExecutionOutcome.Exit(exitResult); } if (result is not IReplResult replResult) { - return 0; + return result is null ? ExecutionOutcome.Success : new ExecutionOutcome(ReplExecutionOutcomeKind.Success, result); } var kind = replResult.Kind.ToLowerInvariant(); - if (kind is "text" or "success") - { - return 0; - } - - if (kind is "error" or "validation" or "not_found") - { - return 1; - } - - return 1; + return kind is "text" or "success" + ? new ExecutionOutcome(ReplExecutionOutcomeKind.Success, replResult) + : ExecutionOutcome.Handler(replResult); } internal async ValueTask RenderOutputAsync( @@ -863,7 +892,7 @@ await ResultFlowPager.WriteAsync( } } - private async ValueTask TryHandleGlobalDiagnosticsAsync( + private async ValueTask TryHandleGlobalDiagnosticsAsync( GlobalInvocationOptions globalOptions, CancellationToken cancellationToken) { @@ -874,12 +903,10 @@ await ResultFlowPager.WriteAsync( var firstError = globalOptions.Diagnostics .First(diagnostic => diagnostic.Severity == ParseDiagnosticSeverity.Error); - _ = await RenderOutputAsync( - Results.Validation(firstError.Message), - globalOptions.OutputFormat, - cancellationToken) + var globalFailure = Results.Validation(firstError.Message); + _ = await RenderOutputAsync(globalFailure, globalOptions.OutputFormat, cancellationToken) .ConfigureAwait(false); - return 1; + return ExecutionOutcome.Usage(globalFailure); } private static ValueTask TransformPagerPageAsync( @@ -1148,7 +1175,6 @@ internal async ValueTask RenderHelpAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { - object? result = null; var context = new ReplExecutionContext(serviceProvider, cancellationToken); var index = -1; @@ -1157,7 +1183,8 @@ async ValueTask NextAsync() index++; if (index == _middleware.Count) { - result = await CommandInvoker + // Stored on the context so middleware can observe or replace it after awaiting next(). + context.Result = await CommandInvoker .InvokeAsync(handler, arguments) .ConfigureAwait(false); return; @@ -1168,7 +1195,7 @@ async ValueTask NextAsync() } await NextAsync().ConfigureAwait(false); - return result; + return context.Result; } private static object? ApplyNavigationResult(object? result, List? scopeTokens) diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index 3c16807d..8a880578 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -8,11 +8,15 @@ public sealed partial class CoreReplApp private bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool allowAuto) => Interactive.ShouldEnterInteractive(globalOptions, allowAuto); - private ValueTask RunInteractiveSessionAsync( + private async ValueTask RunInteractiveSessionAsync( IReadOnlyList initialScopeTokens, IServiceProvider serviceProvider, - CancellationToken cancellationToken) => - Interactive.RunInteractiveSessionAsync(initialScopeTokens, serviceProvider, cancellationToken); + CancellationToken cancellationToken) + { + await Interactive.RunInteractiveSessionAsync(initialScopeTokens, serviceProvider, cancellationToken) + .ConfigureAwait(false); + return ExecutionOutcome.Success; + } private string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTokens) => Interactive.GetDeepestContextScopePath(matchedPathTokens); diff --git a/src/Repl.Core/ExitCodeOptions.cs b/src/Repl.Core/ExitCodeOptions.cs new file mode 100644 index 00000000..0a44eb13 --- /dev/null +++ b/src/Repl.Core/ExitCodeOptions.cs @@ -0,0 +1,89 @@ +namespace Repl; + +/// +/// Maps categories to process exit codes and exposes a final +/// interception point. Applies to top-level runs only; nested sub-invocations use the built-in defaults. +/// +public sealed class ExitCodeOptions +{ + /// + /// Gets or sets the exit code for . Default 0. + /// + public int Success { get; set; } + + /// + /// Gets or sets the exit code for . Default 0; + /// set it non-zero when a bare invocation must fail in scripted pipelines. + /// + public int Help { get; set; } + + /// + /// Gets or sets the exit code for . Default 2. + /// + public int UsageError { get; set; } = 2; + + /// + /// Gets or sets the exit code for . Default 2. + /// + public int BindingError { get; set; } = 2; + + /// + /// Gets or sets the exit code for . Default 1. + /// + public int HandlerError { get; set; } = 1; + + /// + /// Gets or sets the exit code for . Default 1. + /// + public int HandlerException { get; set; } = 1; + + /// + /// Gets or sets the exit code for . When + /// (the default) the propagates to the caller instead of being + /// converted; 130 (128 + SIGINT) is the usual shell convention. + /// + public int? Cancelled { get; set; } + + /// + /// Gets or sets the exit code for . Default 1. + /// + public int FrameworkError { get; set; } = 1; + + /// + /// Gets or sets a final interception hook invoked with the structured outcome (whose + /// already reflects this table); its return value becomes + /// the process exit code. Invoked once per one-shot run. In an interactive session it is also invoked + /// once per committed command to compute the shell-integration command-end mark, and once more when + /// the session exits. + /// + public Func? Resolver { get; set; } + + // Kept private so no friend assembly can mutate the process-wide defaults used by sub-invocations. + private static readonly ExitCodeOptions s_defaults = new(); + + /// + /// Maps a kind with the built-in defaults, ignoring any application configuration. + /// + internal static int MapDefault(ReplExecutionOutcomeKind kind, int? carriedExitCode) => + s_defaults.Map(kind, carriedExitCode); + + /// + /// Maps a kind to its configured code. is the code the outcome + /// itself carries: the code, or the conventional signal code (130/143) for + /// cancellation and interruption, which overrides when set. + /// + internal int Map(ReplExecutionOutcomeKind kind, int? carriedExitCode) => + kind switch + { + ReplExecutionOutcomeKind.Success => Success, + ReplExecutionOutcomeKind.Help => Help, + ReplExecutionOutcomeKind.UsageError => UsageError, + ReplExecutionOutcomeKind.BindingError => BindingError, + ReplExecutionOutcomeKind.HandlerError => HandlerError, + ReplExecutionOutcomeKind.HandlerExitCode => carriedExitCode ?? Success, + ReplExecutionOutcomeKind.HandlerException => HandlerException, + ReplExecutionOutcomeKind.Cancelled => Cancelled ?? carriedExitCode ?? FrameworkError, + ReplExecutionOutcomeKind.Interrupted => carriedExitCode ?? FrameworkError, + _ => FrameworkError, + }; +} diff --git a/src/Repl.Core/Internal/ExecutionOutcome.cs b/src/Repl.Core/Internal/ExecutionOutcome.cs new file mode 100644 index 00000000..7965b78e --- /dev/null +++ b/src/Repl.Core/Internal/ExecutionOutcome.cs @@ -0,0 +1,46 @@ +namespace Repl; + +/// +/// Internal carrier threaded through the execution pipeline until the exit-code policy is applied once +/// at the top level. Mirrors minus the resolved code. +/// +/// Outcome category. +/// Final result object, when one exists. +/// Exception that ended the run, when one did. +/// +/// Code carried by the outcome itself: the code, or the conventional signal +/// code for a cancellation/interruption. The table still decides for every other kind. +/// +internal readonly record struct ExecutionOutcome( + ReplExecutionOutcomeKind Kind, + object? Result = null, + Exception? Exception = null, + int? ExplicitExitCode = null) +{ + public static ExecutionOutcome Success { get; } = new(ReplExecutionOutcomeKind.Success); + + public static ExecutionOutcome Help { get; } = new(ReplExecutionOutcomeKind.Help); + + public static ExecutionOutcome Usage(object? rendered = null) => new(ReplExecutionOutcomeKind.UsageError, rendered); + + public static ExecutionOutcome Binding(Exception exception) => new(ReplExecutionOutcomeKind.BindingError, Exception: exception); + + public static ExecutionOutcome Handler(object? result) => new(ReplExecutionOutcomeKind.HandlerError, result); + + public static ExecutionOutcome Exit(IExitResult exitResult) => + new(ReplExecutionOutcomeKind.HandlerExitCode, exitResult, ExplicitExitCode: exitResult.ExitCode); + + public static ExecutionOutcome Thrown(Exception exception) => new(ReplExecutionOutcomeKind.HandlerException, Exception: exception); + + public static ExecutionOutcome Cancelled(Exception exception, int? conventionalExitCode = null) => + new(ReplExecutionOutcomeKind.Cancelled, Exception: exception, ExplicitExitCode: conventionalExitCode); + + public static ExecutionOutcome Framework(object? rendered) => new(ReplExecutionOutcomeKind.FrameworkError, rendered); + + /// + /// True when the outcome should not prevent an automatic transition into the interactive loop. + /// + public bool IsSuccessLike => + Kind is ReplExecutionOutcomeKind.Success or ReplExecutionOutcomeKind.Help + || (Kind == ReplExecutionOutcomeKind.HandlerExitCode && ExplicitExitCode == 0); +} diff --git a/src/Repl.Core/ReplExecutionContext.cs b/src/Repl.Core/ReplExecutionContext.cs index a665210f..dfa13842 100644 --- a/src/Repl.Core/ReplExecutionContext.cs +++ b/src/Repl.Core/ReplExecutionContext.cs @@ -31,4 +31,11 @@ public ReplExecutionContext(IServiceProvider services, CancellationToken cancell /// Gets the cancellation token for the current execution. /// public CancellationToken CancellationToken { get; } + + /// + /// Gets or sets the handler's return value. Populated once the pipeline reaches the handler, so a + /// middleware can inspect or replace it after awaiting ; a middleware that + /// short-circuits may set it to supply the result that is rendered and classified instead. + /// + public object? Result { get; set; } } diff --git a/src/Repl.Core/ReplExecutionOutcome.cs b/src/Repl.Core/ReplExecutionOutcome.cs new file mode 100644 index 00000000..01c301c7 --- /dev/null +++ b/src/Repl.Core/ReplExecutionOutcome.cs @@ -0,0 +1,25 @@ +namespace Repl; + +/// +/// Structured description of how a top-level run ended, handed to +/// after every framework layer has run. +/// +/// Outcome category. +/// +/// Exit code selected by the table, or the verbatim code of an +/// when is . +/// +/// +/// Final result object when one exists: the normalized handler result, or the +/// the framework rendered for a refusal. +/// +/// Exception that ended the run, when the outcome was caused by one. +/// +/// The primary constructor is frozen: future members are added as init-only body properties so +/// consumers that construct outcomes (e.g. to unit-test a resolver) keep binary compatibility. +/// +public sealed record ReplExecutionOutcome( + ReplExecutionOutcomeKind Kind, + int ExitCode, + object? Result = null, + Exception? Exception = null); diff --git a/src/Repl.Core/ReplExecutionOutcomeKind.cs b/src/Repl.Core/ReplExecutionOutcomeKind.cs new file mode 100644 index 00000000..fd4e9bd7 --- /dev/null +++ b/src/Repl.Core/ReplExecutionOutcomeKind.cs @@ -0,0 +1,66 @@ +namespace Repl; + +/// +/// Classifies how a top-level run ended, independently of the exit code eventually returned. +/// Values are explicit and append-only so consumers can persist or switch on them safely. +/// +public enum ReplExecutionOutcomeKind +{ + /// + /// The handler completed and produced a success-like result (or no result); also an ambient command + /// (exit, ..) that did its job, and a clean interactive session exit. + /// + Success = 0, + + /// + /// The invocation rendered help instead of running a command: help request, bare invocation, or + /// scoped-context help. + /// + Help = 1, + + /// + /// The framework refused the invocation before the handler ran: unknown command, ambiguous + /// prefix, invalid option, unknown output format, or context validation failure. + /// + UsageError = 2, + + /// + /// Handler arguments could not be bound: a token failed to convert or was missing, or a value the + /// binder resolves itself (context value, [FromServices] dependency, typed global options + /// service) was unavailable. + /// + BindingError = 3, + + /// + /// The handler ran and returned a failure result (error, validation, not_found, …). + /// + HandlerError = 4, + + /// + /// The handler returned an ; its exit code is used verbatim. + /// + HandlerExitCode = 5, + + /// + /// The handler, a middleware, or user code running after binding (validators, banners, output + /// transformers) threw an exception that the framework reported. + /// + HandlerException = 6, + + /// + /// The run ended with an : typically the caller-supplied + /// , but also a cancelled interactive prompt or a handler that threw it. + /// + Cancelled = 7, + + /// + /// The run was interrupted by a process signal (SIGINT, Ctrl+Break, SIGTERM). Reserved for + /// process-signal bridges; the core pipeline never produces it. + /// + Interrupted = 8, + + /// + /// The framework itself failed: incompatible adapter contract or an unsupported hosting capability. + /// + FrameworkError = 9, +} diff --git a/src/Repl.Core/ReplOptions.cs b/src/Repl.Core/ReplOptions.cs index ac32c0b9..8197a0cf 100644 --- a/src/Repl.Core/ReplOptions.cs +++ b/src/Repl.Core/ReplOptions.cs @@ -15,6 +15,7 @@ public ReplOptions() Output = new OutputOptions(); Binding = new BindingOptions(); Capabilities = new CapabilityOptions(); + ExitCodes = new ExitCodeOptions(); AmbientCommands = new AmbientCommandOptions(); Interaction = new InteractionOptions(); ShellCompletion = new ShellCompletionOptions(); @@ -45,6 +46,11 @@ public ReplOptions() /// public CapabilityOptions Capabilities { get; } + /// + /// Gets exit-code policy options: per-outcome codes and the final interception hook. + /// + public ExitCodeOptions ExitCodes { get; } + /// /// Gets ambient command options. /// diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs index 2add4b6b..d6c5db78 100644 --- a/src/Repl.Core/Session/InteractiveSession.cs +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -51,7 +51,7 @@ internal bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool }; } - internal async ValueTask RunInteractiveSessionAsync( + internal async ValueTask RunInteractiveSessionAsync( IReadOnlyList initialScopeTokens, IServiceProvider serviceProvider, CancellationToken cancellationToken) @@ -83,7 +83,7 @@ internal async ValueTask RunInteractiveSessionAsync( lastHistoryEntry = updatedHistory; if (exit) { - return 0; + return; } } catch @@ -206,7 +206,10 @@ internal async ValueTask RunInteractiveSessionAsync( isInteractiveSession: true, cancellationToken) .ConfigureAwait(false); - return (ambientOutcome, ambientOutcome == AmbientCommandOutcome.HandledError ? 1 : 0); + var ambientExecution = ambientOutcome == AmbientCommandOutcome.HandledError + ? ExecutionOutcome.Usage() + : ExecutionOutcome.Success; + return (ambientOutcome, app.ResolveExitCode(ambientExecution, isSubInvocation: false)); } if (resolution.Kind == CommittedKind.Ambiguous) @@ -216,7 +219,7 @@ internal async ValueTask RunInteractiveSessionAsync( var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolution.Prefix); _ = await app.RenderOutputAsync(ambiguous, resolution.Options.OutputFormat, cancellationToken, isInteractive: true) .ConfigureAwait(false); - return (AmbientCommandOutcome.Handled, 1); + return (AmbientCommandOutcome.Handled, app.ResolveExitCode(ExecutionOutcome.Usage(ambiguous), isSubInvocation: false)); } // Help or Routed: both flow through the command-cancellation scope so Ctrl-C and @@ -240,15 +243,16 @@ private async ValueTask ExecuteWithCancellationAsync( return await ExecuteInteractiveInputAsync(resolution, cycle, commandCts.Token) .ConfigureAwait(false); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { await ReplSessionIO.Output.WriteLineAsync("Cancelled.").ConfigureAwait(false); // 128 + SIGINT(2): the shell convention for an interrupted command, so // shell-integration marks decorate it as interrupted rather than failed. An // outer-token cancellation (host shutdown) is NOT matched here — it propagates // to ExecuteCommittedInputAsync's OCE catch, which closes the cycle with an - // aborted D (no exit code) rather than a failure. - return 130; + // aborted D (no exit code) rather than a failure. The exit-code table and resolver + // apply like for any other outcome; ExitCodes.Cancelled overrides the convention. + return app.ResolveExitCode(ExecutionOutcome.Cancelled(ex, conventionalExitCode: 130), isSubInvocation: false); } finally { @@ -469,7 +473,7 @@ private async ValueTask ExecuteInteractiveInputAsync( if (globalOptions.HelpRequested) { var rendered = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); - return rendered ? 0 : 1; + return app.ResolveExitCode(rendered ? ExecutionOutcome.Help : ExecutionOutcome.Usage(), isSubInvocation: false); } // Reuse the single routing-graph snapshot and route resolution captured in @@ -485,12 +489,13 @@ private async ValueTask ExecuteInteractiveInputAsync( // Same execution contract as the CLI one-shot path — hosted-capability guard, // protocol-passthrough scope, and stream isolation — so a handler probing // IsProtocolPassthrough observes the same value in both modes. - return await app.ExecuteProtocolPassthroughCommandAsync(match, globalOptions, cycle.ServiceProvider, cancellationToken) + var passthroughOutcome = await app.ExecuteProtocolPassthroughCommandAsync(match, globalOptions, cycle.ServiceProvider, cancellationToken) .ConfigureAwait(false); + return app.ResolveExitCode(passthroughOutcome, isSubInvocation: false); } - var (exitCode, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, cycle.ServiceProvider, cycle.ScopeTokens, cancellationToken).ConfigureAwait(false); - return exitCode; + var (outcome, _) = await app.ExecuteMatchedCommandAsync(match, globalOptions, cycle.ServiceProvider, cycle.ScopeTokens, cancellationToken).ConfigureAwait(false); + return app.ResolveExitCode(outcome, isSubInvocation: false); } return await HandleUnmatchedInteractiveInputAsync(activeGraph, resolution, globalOptions, cycle, cancellationToken) @@ -522,7 +527,7 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); - return 1; + return app.ResolveExitCode(ExecutionOutcome.Usage(contextValidation.Failure), isSubInvocation: false); } cycle.ScopeTokens.Clear(); @@ -533,7 +538,7 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( await app.InvokeBannerAsync(contextBanner, serviceProvider, cancellationToken).ConfigureAwait(false); } - return 0; + return app.ResolveExitCode(ExecutionOutcome.Success, isSubInvocation: false); } var failure = app.CreateRouteResolutionFailureResult( @@ -546,7 +551,7 @@ private async ValueTask HandleUnmatchedInteractiveInputAsync( cancellationToken, isInteractive: true) .ConfigureAwait(false); - return 1; + return app.ResolveExitCode(ExecutionOutcome.Usage(failure), isSubInvocation: false); } internal async ValueTask TryHandleAmbientCommandAsync( diff --git a/src/Repl.IntegrationTests/Given_AdvancedRouteConstraints.cs b/src/Repl.IntegrationTests/Given_AdvancedRouteConstraints.cs index 830bec81..35ae5e73 100644 --- a/src/Repl.IntegrationTests/Given_AdvancedRouteConstraints.cs +++ b/src/Repl.IntegrationTests/Given_AdvancedRouteConstraints.cs @@ -161,7 +161,7 @@ public void When_UnconstrainedSegmentBindsToUriParameter_Then_NonUriInputIsRejec var output = ConsoleCaptureHelper.Capture(() => sut.Run(["target", "not-a-uri", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("parameter 'value'"); output.Text.Should().Contain("expected: uri"); @@ -221,7 +221,7 @@ public void When_EmailConstraintValueIsInvalid_Then_FrameworkReturnsValidationEr var output = ConsoleCaptureHelper.Capture(() => sut.Run(["add", "test", "123", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("parameter 'email'"); output.Text.Should().Contain("expected: email"); @@ -237,7 +237,7 @@ public void When_EmailConstraintArgumentIsMissing_Then_FrameworkReturnsMissingPa var output = ConsoleCaptureHelper.Capture(() => sut.Run(["add", "test", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("Missing value for parameter 'email'"); output.Text.Should().Contain("expected: email"); @@ -253,7 +253,7 @@ public void When_MultipleRouteArgumentsAreMissing_Then_FrameworkReturnsMissingPa var output = ConsoleCaptureHelper.Capture(() => sut.Run(["add", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("Missing values for parameters: name, email."); } diff --git a/src/Repl.IntegrationTests/Given_CommandAliases.cs b/src/Repl.IntegrationTests/Given_CommandAliases.cs index 907a3bc8..42bcf751 100644 --- a/src/Repl.IntegrationTests/Given_CommandAliases.cs +++ b/src/Repl.IntegrationTests/Given_CommandAliases.cs @@ -28,7 +28,7 @@ public void When_AliasDoesNotMatchTerminalSegment_Then_CommandIsNotResolved() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["ls", "list"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown command"); } diff --git a/src/Repl.IntegrationTests/Given_CommandSuggestions.cs b/src/Repl.IntegrationTests/Given_CommandSuggestions.cs index f002e7e8..1424fbc7 100644 --- a/src/Repl.IntegrationTests/Given_CommandSuggestions.cs +++ b/src/Repl.IntegrationTests/Given_CommandSuggestions.cs @@ -27,7 +27,7 @@ public void When_CommandPrefixIsAmbiguous_Then_FrameworkReturnsValidationError() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "l"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Ambiguous command prefix 'l'."); output.Text.Should().Contain("list"); output.Text.Should().Contain("load"); @@ -42,7 +42,7 @@ public void When_CliCommandIsUnknown_Then_ErrorIncludesDidYouMeanSuggestion() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["helo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown command 'helo'."); output.Text.Should().Contain("Did you mean 'hello'?"); } @@ -57,7 +57,7 @@ public void When_BestSuggestionIsHiddenCommand_Then_HiddenCommandIsNotExposed() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["helpm"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown command 'helpm'."); output.Text.Should().NotContain("helpme"); } @@ -72,7 +72,7 @@ public void When_PrefixWouldResolveOnlyToHiddenCommand_Then_HiddenCommandIsNotIn var output = ConsoleCaptureHelper.Capture(() => sut.Run(["help"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown command 'help'."); output.Text.Should().NotContain("secret"); } diff --git a/src/Repl.IntegrationTests/Given_ContextHierarchyBinding.cs b/src/Repl.IntegrationTests/Given_ContextHierarchyBinding.cs index 05a47ae9..2b9eb27d 100644 --- a/src/Repl.IntegrationTests/Given_ContextHierarchyBinding.cs +++ b/src/Repl.IntegrationTests/Given_ContextHierarchyBinding.cs @@ -102,7 +102,7 @@ public void When_UsingFromServicesWithMissingKey_Then_ValidationErrorIsReturned( var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "42", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unable to resolve parameter 'source' from services with key 'beta'."); } @@ -121,7 +121,7 @@ public void When_UsingFromServicesWithoutKeyAgainstOnlyKeyedRegistration_Then_Va var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "42", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unable to resolve parameter 'source' from services."); } @@ -209,7 +209,7 @@ public void When_UsingFromContextAllAttributeOnScalar_Then_ValidationErrorIsRetu var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "42", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("[FromContext(All = true)] requires a collection parameter type."); } diff --git a/src/Repl.IntegrationTests/Given_ContextValidationAndDeeplinking.cs b/src/Repl.IntegrationTests/Given_ContextValidationAndDeeplinking.cs index f7c921b2..f505754b 100644 --- a/src/Repl.IntegrationTests/Given_ContextValidationAndDeeplinking.cs +++ b/src/Repl.IntegrationTests/Given_ContextValidationAndDeeplinking.cs @@ -28,7 +28,7 @@ public void When_ContextValidationFails_Then_HandlerIsNotInvokedAndExitCodeIsNon var exitCode = sut.Run(["contact", "99", "show"]); - exitCode.Should().Be(1); + exitCode.Should().Be(2); handlerCalled.Should().BeFalse(); } @@ -111,7 +111,7 @@ public void When_ContextValidationFails_Then_FrameworkValidationMessageIsRendere var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "99", "show"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation: Scope validation failed for 'contact {id:int}'."); output.Text.Should().Contain("id: 99"); } @@ -130,7 +130,7 @@ public void When_ContextValidationReturnsString_Then_MessageIsRenderedAsValidati var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "99", "show"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation: Contact not found."); } diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index d58e3fa6..ebdddca8 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -30,7 +30,7 @@ public void When_GlobalAndCommandOptionCollide_Then_ValidationErrorIsReturned() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["ping", "--tenant", "acme", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Ambiguous option '--tenant'"); } diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 10064208..b73f0848 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -441,7 +441,7 @@ public void When_TypedGlobalOptionsServiceIsMissing_Then_RuntimeErrorMentionsUse var output = ConsoleCaptureHelper.Capture( () => sut.Run(["show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("UseGlobalOptions"); output.Text.Should().Contain(nameof(MissingServiceGlobalOptions)); } diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 3eb090f8..baff6370 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -1031,7 +1031,7 @@ public void When_RequestingHelpWithUnknownFormat_Then_CommandFailsWithExplicitEr var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--output:toml"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Error: unknown output format 'toml'."); } diff --git a/src/Repl.IntegrationTests/Given_ModulePresence.cs b/src/Repl.IntegrationTests/Given_ModulePresence.cs index 045280c1..8cb140f6 100644 --- a/src/Repl.IntegrationTests/Given_ModulePresence.cs +++ b/src/Repl.IntegrationTests/Given_ModulePresence.cs @@ -54,7 +54,7 @@ public void When_ModuleIsCliOnly_Then_HostedSessionDoesNotResolveIt() using var output = new StringWriter(); var hostedExitCode = sut.Run(["ops", "ping", "--no-logo"], new InMemoryHost(input, output)); - hostedExitCode.Should().Be(1); + hostedExitCode.Should().Be(2); output.ToString().Should().Contain("Unknown command"); } @@ -73,7 +73,7 @@ public void When_ModulePresenceUsesInjectableServicePredicate_Then_ModuleAvailab gate.Enabled = false; sut.InvalidateRouting(); var disabled = ConsoleCaptureHelper.Capture(() => sut.Run(["feature", "ping", "--no-logo"])); - disabled.ExitCode.Should().Be(1); + disabled.ExitCode.Should().Be(2); disabled.Text.Should().Contain("Unknown command"); } @@ -94,7 +94,7 @@ public void When_ModulePresenceUsesInjectableChannelPredicate_Then_HostedSession using var output = new StringWriter(); var hostedExitCode = sut.Run(["ops", "ping", "--no-logo"], new InMemoryHost(input, output)); - hostedExitCode.Should().Be(1); + hostedExitCode.Should().Be(2); output.ToString().Should().Contain("Unknown command"); } @@ -117,7 +117,7 @@ public void When_ScopedMapModuleUsesInjectablePredicate_Then_ModuleAvailabilityF gate.Enabled = false; sut.InvalidateRouting(); var disabled = ConsoleCaptureHelper.Capture(() => sut.Run(["tenant", "feature", "ping", "--no-logo"])); - disabled.ExitCode.Should().Be(1); + disabled.ExitCode.Should().Be(2); disabled.Text.Should().Contain("Unknown command"); } diff --git a/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs b/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs index aa54f510..e7845332 100644 --- a/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs +++ b/src/Repl.IntegrationTests/Given_OptionAttributeOverrides.cs @@ -85,7 +85,7 @@ public void When_ArityOverriddenViaAttributeToZeroOrOne_Then_RepeatedOptionIsRej var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--items", "ga", "--items", "bu", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("accepts at most one value"); } @@ -127,7 +127,7 @@ public void When_TokenMatchesCaseInsensitiveAliasAndAnotherOption_Then_Ambiguity var output = ConsoleCaptureHelper.Capture(() => sut.Run(["probe", "--mode", "zo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Ambiguous option '--mode'"); } @@ -140,7 +140,7 @@ public void When_EnumFlagWithoutOverrideAndCasingDiffers_Then_TokenIsRejected() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["say", "--BU", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown option '--BU'"); } @@ -181,7 +181,7 @@ public void When_ExplicitCaseSensitiveOverrideUnderGlobalInsensitive_Then_Casing var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--Channel", "zo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown option '--Channel'"); } @@ -207,7 +207,7 @@ public void When_GroupPropertyArityOverriddenToZeroOrOne_Then_RepeatedOptionIsRe var output = ConsoleCaptureHelper.Capture(() => sut.Run(["wear", "--patches", "ga", "--patches", "bu", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("accepts at most one value"); } @@ -220,7 +220,7 @@ public void When_RepeatedEnumValuesDifferByCaseUnderGlobalCaseSensitive_Then_Con var output = ConsoleCaptureHelper.Capture(() => sut.Run(["say", "--syllable", "Ga", "--syllable", "GA", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("received multiple enum values"); } @@ -264,7 +264,7 @@ public void When_OneOrMoreArityOptionIsAbsent_Then_BindingFailsWithoutInvokingHa var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("requires at least one value"); invoked.Should().BeFalse(); } @@ -296,7 +296,7 @@ public void When_GroupPropertyOneOrMoreArityIsAbsent_Then_BindingFailsWithoutInv var output = ConsoleCaptureHelper.Capture(() => sut.Run(["wear", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("requires at least one value"); invoked.Should().BeFalse(); } @@ -310,7 +310,7 @@ public void When_ArgumentOnlyOneOrMoreArityIsAbsent_Then_BindingFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["copy", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("requires at least one value"); } @@ -336,7 +336,7 @@ public void When_ExplicitExactlyOneArityOptionIsAbsent_Then_BindingFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("requires exactly one value"); } @@ -349,7 +349,7 @@ public void When_RenamedOneOrMoreOptionIsAbsent_Then_MessageUsesCanonicalToken() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Option '--item' requires"); } @@ -393,7 +393,7 @@ public void When_CaseSensitiveTwinTokensRegistered_Then_SuggestionPreservesEachC var output = ConsoleCaptureHelper.Capture(() => sut.Run(["cfg", "--MODEs", "zo", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Did you mean '--MODE'"); } @@ -431,7 +431,7 @@ public void When_ZeroOrOneCollectionReceivesTwoPositionals_Then_BindingFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "ga", "bu", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("accepts at most one value"); } @@ -444,7 +444,7 @@ public void When_ExactlyOneCollectionReceivesTwoPositionals_Then_BindingFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "ga", "bu", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("requires exactly one value"); } @@ -457,7 +457,7 @@ public void When_GroupZeroOrOneCollectionReceivesTwoPositionals_Then_BindingFail var output = ConsoleCaptureHelper.Capture(() => sut.Run(["wear", "ga", "bu", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("accepts at most one value"); } diff --git a/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs b/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs index 8a8930d0..317b0589 100644 --- a/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs +++ b/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs @@ -13,7 +13,7 @@ public void When_UnknownCommandOptionInStrictMode_Then_ValidationErrorIsReturned var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--txet", "hello", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown option '--txet'"); output.Text.Should().Contain("--text"); } @@ -41,7 +41,7 @@ public void When_OptionNameCaseDiffersAndDefaultSensitivity_Then_ValidationError var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--Text", "hello", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unknown option '--Text'"); } diff --git a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs index 530b9b11..f2d5b29c 100644 --- a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -293,7 +293,7 @@ public void When_GroupPropertyGetsNamedAndPositional_Then_InvocationFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["search", "--query", "alpha", "beta", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("cannot receive both named and positional values"); } diff --git a/src/Repl.IntegrationTests/Given_OutputFormatting.cs b/src/Repl.IntegrationTests/Given_OutputFormatting.cs index 366a84f8..5a9e19ad 100644 --- a/src/Repl.IntegrationTests/Given_OutputFormatting.cs +++ b/src/Repl.IntegrationTests/Given_OutputFormatting.cs @@ -353,7 +353,7 @@ public void When_RenderingWithUnknownFormat_Then_ClearErrorIsShownAndExitCodeIsN var output = ConsoleCaptureHelper.Capture(() => sut.Run(["contact", "show", "--output:toml"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Error: unknown output format 'toml'."); } diff --git a/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs b/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs index 969869ab..78ad5bfe 100644 --- a/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs +++ b/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs @@ -44,7 +44,7 @@ public void When_ParameterIsOptionOnlyAndOnlyPositionalValueIsProvided_Then_Invo var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set", "42", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Unable to bind parameter 'value'"); } @@ -59,7 +59,7 @@ public void When_ParameterReceivesNamedAndPositionalValues_Then_InvocationFailsW var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set", "--value", "alpha", "beta", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("cannot receive both named and positional values"); } diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index f96ddbaa..0e18f853 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -44,7 +44,7 @@ public void When_AmbiguousPrefixOverlapsDynamicPassthroughRoute_Then_BannerIsNot var output = ConsoleCaptureHelper.Capture(() => sut.Run(["mcp", "l"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Test banner"); output.Text.Should().Contain("Ambiguous command prefix 'l'."); } @@ -328,7 +328,7 @@ public void When_CliProtocolPassthroughRuns_Then_CliOnlyContextValidationIsNotBy var output = ConsoleCaptureHelper.CaptureStdOutAndErr( () => sut.Run(["mcp", "start", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.StdOut.Should().BeNullOrWhiteSpace(); output.StdErr.Should().Contain("Validation: context gate failed"); } diff --git a/src/Repl.IntegrationTests/Given_ReplRuntime.cs b/src/Repl.IntegrationTests/Given_ReplRuntime.cs index 87481ad0..3d0fb9df 100644 --- a/src/Repl.IntegrationTests/Given_ReplRuntime.cs +++ b/src/Repl.IntegrationTests/Given_ReplRuntime.cs @@ -84,26 +84,26 @@ public void When_RunningExitCommandInNonInteractiveMode_Then_ExitCodeIsZeroWitho [TestMethod] [Description("Regression guard: verifies running exit command when disabled in one-shot mode so that user gets explicit failure.")] - public void When_RunningExitCommandInNonInteractiveModeAndExitDisabled_Then_ExitCodeIsOneWithError() + public void When_RunningExitCommandInNonInteractiveModeAndExitDisabled_Then_UsageExitCodeWithError() { var sut = ReplApp.Create(); sut.Options(options => options.AmbientCommands.ExitCommandEnabled = false); var output = ConsoleCaptureHelper.Capture(() => sut.Run(["exit", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Error: exit command is disabled."); } [TestMethod] [Description("Regression guard: verifies using '..' in one-shot mode so that interactive-only ambient command fails explicitly.")] - public void When_RunningUpAmbientCommandInNonInteractiveMode_Then_ExitCodeIsOneWithModeError() + public void When_RunningUpAmbientCommandInNonInteractiveMode_Then_UsageExitCodeWithModeError() { var sut = ReplApp.Create(); var output = ConsoleCaptureHelper.Capture(() => sut.Run(["..", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Error: '..' is available only in interactive mode."); } diff --git a/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs b/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs index 3ad511c9..d1c92cf4 100644 --- a/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs +++ b/src/Repl.IntegrationTests/Given_ShellCompletionSetup.cs @@ -525,7 +525,7 @@ public void When_CompletionCommandIsInvokedInHostedSession_Then_CommandIsUnknown var exitCode = sut.Run(["completion", "status", "--no-logo"], host); - exitCode.Should().Be(1); + exitCode.Should().Be(2); outputWriter.ToString().Should().Contain("Unknown command"); } diff --git a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs index 0b799295..f4db8cd1 100644 --- a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs +++ b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs @@ -104,7 +104,7 @@ public void When_InvalidDateRangeLiteral_Then_InvocationFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["report", "--period", "not-a-range", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("not a valid date range literal"); } @@ -118,7 +118,7 @@ public void When_DateRangeDurationIsSubDay_Then_InvocationFails() var output = ConsoleCaptureHelper.Capture(() => sut.Run(["report", "--period", "2024-01-15@8h", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("whole days"); } } diff --git a/src/Repl.IntegrationTests/Given_TemporalTypes.cs b/src/Repl.IntegrationTests/Given_TemporalTypes.cs index 4f3d85d5..7307e8f5 100644 --- a/src/Repl.IntegrationTests/Given_TemporalTypes.cs +++ b/src/Repl.IntegrationTests/Given_TemporalTypes.cs @@ -310,7 +310,7 @@ public void When_UnconstrainedSegmentBindsToTimeSpanParameter_Then_InvalidDurati var output = ConsoleCaptureHelper.Capture(() => sut.Run(["delay", "tomorrow", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("parameter 'value'"); output.Text.Should().Contain("expected: timespan"); @@ -326,7 +326,7 @@ public void When_UsingDateConstraintWithInvalidDateLiteral_Then_InputIsRejected( var output = ConsoleCaptureHelper.Capture(() => sut.Run(["day", "2026/02/19", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("parameter 'value'"); output.Text.Should().Contain("expected: date"); @@ -342,7 +342,7 @@ public void When_UsingTimeSpanConstraintWithMalformedIsoLiteral_Then_InputIsReje var output = ConsoleCaptureHelper.Capture(() => sut.Run(["delay", "PT", "show", "--no-logo"])); - output.ExitCode.Should().Be(1); + output.ExitCode.Should().Be(2); output.Text.Should().Contain("Validation:"); output.Text.Should().Contain("parameter 'value'"); output.Text.Should().Contain("expected: timespan"); diff --git a/src/Repl.IntegrationTests/Given_TestingToolkit.cs b/src/Repl.IntegrationTests/Given_TestingToolkit.cs index 434c285c..2a588e08 100644 --- a/src/Repl.IntegrationTests/Given_TestingToolkit.cs +++ b/src/Repl.IntegrationTests/Given_TestingToolkit.cs @@ -202,6 +202,30 @@ public async Task When_CommandExceedsTimeout_Then_RunCommandAsyncThrowsTimeoutEx assertion.Which.Message.Should().Contain("timeout"); } + [TestMethod] + [Description("Regression guard: verifies the per-command timeout still surfaces as TimeoutException when the app maps ExitCodes.Cancelled so that a hung command is never reported as an ordinary exit code.")] + public async Task When_CommandExceedsTimeoutAndCancelledIsMapped_Then_RunCommandAsyncStillThrowsTimeoutException() + { + await using var host = ReplTestHost.Create( + () => + { + var app = ReplApp.Create().UseDefaultInteractive(); + app.Options(options => options.ExitCodes.Cancelled = 130); + app.Map("slow", async (CancellationToken ct) => + { + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + return "done"; + }); + return app; + }, + options => options.CommandTimeout = TimeSpan.FromMilliseconds(50)); + await using var session = await host.OpenSessionAsync(); + + Func action = () => session.RunCommandAsync("slow --no-logo").AsTask(); + + await action.Should().ThrowAsync(); + } + [TestMethod] [Description("Regression guard: verifies ANSI normalization defaults and can be disabled at host level.")] public async Task When_OutputContainsAnsi_Then_NormalizationBehaviorIsConfigurable() diff --git a/src/Repl.McpTests/Given_McpExitCodePolicy.cs b/src/Repl.McpTests/Given_McpExitCodePolicy.cs new file mode 100644 index 00000000..dc0915b7 --- /dev/null +++ b/src/Repl.McpTests/Given_McpExitCodePolicy.cs @@ -0,0 +1,31 @@ +using ModelContextProtocol.Protocol; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpExitCodePolicy +{ + [TestMethod] + [Description("Regression guard: verifies neither the exit-code table nor the resolver applies to MCP sub-invocations so that a process-level remap cannot hide a failed tool call from the agent.")] + public async Task When_ExitCodePolicyMapsEverythingToZero_Then_ToolCallStillReportsError() + { + await using var fixture = await McpTestFixture.CreateAsync(app => + { + app.Options(options => + { + options.ExitCodes.HandlerError = 0; + options.ExitCodes.Resolver = static _ => 0; + }); + app.Map("boom", () => Results.Error("boom", "nope")) + .ReadOnly(); + }).ConfigureAwait(false); + + var result = await fixture.Client.CallToolAsync( + toolName: "boom", + arguments: new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); + + result.IsError.Should().BeTrue(); + string.Join('\n', result.Content.OfType().Select(static block => block.Text)) + .Should().Contain("nope"); + } +} diff --git a/src/Repl.Testing/ReplSessionHandle.cs b/src/Repl.Testing/ReplSessionHandle.cs index e7461b8d..07ac6dfe 100644 --- a/src/Repl.Testing/ReplSessionHandle.cs +++ b/src/Repl.Testing/ReplSessionHandle.cs @@ -89,16 +89,22 @@ private async ValueTask ExecuteCommandCoreAsync( { exitCode = await _app.RunAsync(args, host, _services, _runOptions, token).ConfigureAwait(false); } - catch (OperationCanceledException) when (timeout is not null && timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (IsCommandTimeout(timeout, cancellationToken)) { - throw new TimeoutException( - $"Command '{commandText}' exceeded timeout of {_options.CommandTimeout.TotalMilliseconds:0} ms."); + throw CreateTimeoutException(commandText); } finally { _app.Core.ExecutionObserver = null; } + // An app that maps ReplOptions.ExitCodes.Cancelled returns a code instead of throwing; the + // timeout must still surface as a diagnostic rather than as an ordinary exit code. + if (IsCommandTimeout(timeout, cancellationToken)) + { + throw CreateTimeoutException(commandText); + } + var outputText = output.ToString(); if (_options.NormalizeAnsi) { @@ -236,6 +242,14 @@ private static List BuildTimeline( return timeline; } + // The timeout fired (and not the caller's own token) — whether the run threw or, with a mapped + // ExitCodes.Cancelled, returned an exit code. + private static bool IsCommandTimeout(CancellationTokenSource? timeout, CancellationToken cancellationToken) => + timeout is not null && timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested; + + private TimeoutException CreateTimeoutException(string commandText) => + new($"Command '{commandText}' exceeded timeout of {_options.CommandTimeout.TotalMilliseconds:0} ms."); + private CancellationTokenSource? CreateTimeoutSource(CancellationToken cancellationToken) { if (_options.CommandTimeout <= TimeSpan.Zero || _options.CommandTimeout == Timeout.InfiniteTimeSpan) diff --git a/src/Repl.Tests/Given_ExitCodes.cs b/src/Repl.Tests/Given_ExitCodes.cs new file mode 100644 index 00000000..de3e1e8f --- /dev/null +++ b/src/Repl.Tests/Given_ExitCodes.cs @@ -0,0 +1,597 @@ +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_ExitCodes +{ + [TestMethod] + [Description("Regression guard: verifies a text-returning handler is classified Success so that the process exits 0.")] + public void When_HandlerReturnsText_Then_KindIsSuccessAndExitCodeIsZero() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["hello"], out _); + + exitCode.Should().Be(0); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Success); + } + + [TestMethod] + [Description("Regression guard: verifies --help is classified Help so that scripted callers can tell a no-op from real work.")] + public void When_HelpIsRequested_Then_KindIsHelpAndExitCodeIsZero() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["--help"], out _); + + exitCode.Should().Be(0); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Help); + } + + [TestMethod] + [Description("Regression guard: verifies a bare invocation that prints help is classified Help and keeps exiting 0 by default.")] + public void When_BareInvocationPrintsHelp_Then_KindIsHelpAndExitCodeIsZero() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, [], out var output); + + exitCode.Should().Be(0); + output.Should().Contain("hello"); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Help); + } + + [TestMethod] + [Description("Regression guard: verifies the Help code is configurable so that a bare invocation can fail a CI step that ran the tool with no arguments.")] + public void When_HelpIsMappedToNonZero_Then_BareInvocationReturnsMappedCode() + { + var sut = CreateApp(recorder: null, options => options.ExitCodes.Help = 64); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, [], out _); + + exitCode.Should().Be(64); + } + + [TestMethod] + [Description("Regression guard: verifies an unknown command is a UsageError with exit code 2 so that it is distinguishable from a handler failure.")] + public void When_CommandIsUnknown_Then_KindIsUsageErrorAndExitCodeIsTwo() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["nope"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.UsageError); + recorder.Last.Result.Should().BeAssignableTo(); + } + + [TestMethod] + [Description("Regression guard: verifies an ambiguous command prefix is a UsageError so that typos never masquerade as handler errors.")] + public void When_PrefixIsAmbiguous_Then_KindIsUsageError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("contact list", () => "list"); + sut.Map("contact load", () => "load"); + + var exitCode = Run(sut, ["contact", "l"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.UsageError); + } + + [TestMethod] + [Description("Regression guard: verifies an unknown command option is a UsageError so that misuse and breakage return different codes.")] + public void When_CommandOptionIsUnknown_Then_KindIsUsageError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", (string name) => name); + + var exitCode = Run(sut, ["hello", "--bogus", "x"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.UsageError); + } + + [TestMethod] + [Description("Regression guard: verifies an invalid global option is a UsageError so that global parse diagnostics follow the usage code.")] + public void When_GlobalOptionIsInvalid_Then_KindIsUsageError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["hello", "--output"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.UsageError); + } + + [TestMethod] + [Description("Regression guard: verifies an unknown output format is a UsageError so that a bad --output value is reported as misuse.")] + public void When_OutputFormatIsUnknown_Then_KindIsUsageError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("hello", () => new { Name = "world" }); + + var exitCode = Run(sut, ["hello", "--output:toml"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.UsageError); + } + + [TestMethod] + [Description("Regression guard: verifies a missing required parameter is a BindingError with exit code 2 so that binding failures are distinct from handler failures.")] + public void When_RequiredParameterIsMissing_Then_KindIsBindingErrorAndExitCodeIsTwo() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("set", (int value) => value); + + var exitCode = Run(sut, ["set"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.BindingError); + recorder.Last.Exception.Should().NotBeNull(); + } + + [TestMethod] + [Description("Regression guard: verifies a parameter conversion failure is a BindingError so that invalid values report the binding code.")] + public void When_ParameterConversionFails_Then_KindIsBindingError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("set", (int value) => value); + + var exitCode = Run(sut, ["set", "abc"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.BindingError); + } + + [TestMethod] + [DataRow("error")] + [DataRow("validation")] + [DataRow("not_found")] + [DataRow("cancelled")] + [Description("Regression guard: verifies handler-returned failure results are HandlerError with exit code 1 so that existing handler contracts keep their code.")] + public void When_HandlerReturnsFailureResult_Then_KindIsHandlerErrorAndExitCodeIsOne(string kind) + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("fail", () => kind switch + { + "error" => Results.Error("boom", "failed"), + "validation" => Results.Validation("invalid"), + "not_found" => Results.NotFound("missing"), + _ => Results.Cancelled("stopped"), + }); + + var exitCode = Run(sut, ["fail"], out _); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerError); + recorder.Last.Result.Should().BeAssignableTo(); + } + + [TestMethod] + [Description("Regression guard: verifies an explicit IExitResult bypasses the table so that handler-owned codes are never remapped silently.")] + public void When_HandlerReturnsExitResult_Then_CodePassesThroughAndKindIsHandlerExitCode() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.HandlerError = 7); + sut.Map("quit", () => Results.Exit(42)); + + var exitCode = Run(sut, ["quit"], out _); + + exitCode.Should().Be(42); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerExitCode); + recorder.Last.ExitCode.Should().Be(42); + } + + [TestMethod] + [Description("Regression guard: verifies an unhandled handler exception is HandlerException and exposes the unwrapped exception to the resolver.")] + public void When_HandlerThrows_Then_KindIsHandlerExceptionAndExceptionIsExposed() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("boom", Boom); + + var exitCode = Run(sut, ["boom"], out _); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerException); + recorder.Last.Exception!.Message.Should().Be("boom"); + + static string Boom() => throw new FormatException("boom"); + } + + [TestMethod] + [Description("Regression guard: verifies a handler-thrown InvalidOperationException is HandlerException, not BindingError, so that binder failures stay distinguishable.")] + public void When_HandlerThrowsInvalidOperationException_Then_KindIsHandlerExceptionNotBindingError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.BindingError = 9); + sut.Map("boom", Boom); + + var exitCode = Run(sut, ["boom"], out var output); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerException); + output.Should().Contain("boom"); + + static string Boom() => throw new InvalidOperationException("boom"); + } + + [TestMethod] + [Description("Regression guard: verifies cancellation still propagates as an exception when Cancelled is unmapped so that existing callers keep their contract.")] + public async Task When_TokenIsCancelledAndCancelledIsUnmapped_Then_OperationCanceledExceptionPropagates() + { + using var cts = new CancellationTokenSource(); + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("work", (CancellationToken ct) => + { + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + return "unreachable"; + }); + using var session = OpenSession(out _); + + var act = async () => await sut.RunAsync(["work"], cts.Token).ConfigureAwait(false); + + await act.Should().ThrowAsync().ConfigureAwait(false); + recorder.Last.Should().BeNull("the resolver must not run when cancellation is left unmapped"); + } + + [TestMethod] + [Description("Regression guard: verifies the exit ambient command handled in one-shot mode is classified Success so that a CI-oriented Help mapping never marks it as failed.")] + public void When_ExitAmbientCommandRunsInOneShotMode_Then_KindIsSuccess() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.Help = 3); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["exit"], out _); + + exitCode.Should().Be(0); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Success); + } + + [TestMethod] + [Description("Regression guard: verifies an exception thrown by a middleware is HandlerException so that pipeline failures share the handler-failure code.")] + public void When_MiddlewareThrows_Then_KindIsHandlerException() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Use((_, _) => throw new FormatException("middleware boom")); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["hello"], out _); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerException); + recorder.Last.Exception!.Message.Should().Be("middleware boom"); + } + + [TestMethod] + [Description("Regression guard: verifies a handler-thrown OperationCanceledException follows the Cancelled mapping so that prompt or self-imposed cancellations get the cancellation code.")] + public void When_HandlerThrowsOperationCanceledAndCancelledIsMapped_Then_KindIsCancelled() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.Cancelled = 130); + sut.Map("boom", string () => throw new OperationCanceledException()); + + var exitCode = Run(sut, ["boom"], out _); + + exitCode.Should().Be(130); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Cancelled); + } + + [TestMethod] + [Description("Regression guard: verifies mapped cancellation returns the configured code and Kind Cancelled so that headless tools get an integer for cancellation.")] + public async Task When_TokenIsCancelledAndCancelledIsMapped_Then_ExitCodeIs130AndKindIsCancelled() + { + using var cts = new CancellationTokenSource(); + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.Cancelled = 130); + sut.Map("work", (CancellationToken ct) => + { + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + return "unreachable"; + }); + using var session = OpenSession(out _); + + var exitCode = await sut.RunAsync(["work"], cts.Token); + + exitCode.Should().Be(130); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Cancelled); + recorder.Last.Exception.Should().BeAssignableTo(); + } + + [TestMethod] + [Description("Regression guard: verifies a token cancelled before the run is subject to the same Cancelled mapping so that early cancellation is not a special case.")] + public async Task When_PreCancelledTokenAndCancelledIsMapped_Then_ExitCodeIs130() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder, options => options.ExitCodes.Cancelled = 130); + sut.Map("work", () => "never"); + using var session = OpenSession(out _); + + var exitCode = await sut.RunAsync(["work"], cts.Token); + + exitCode.Should().Be(130); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Cancelled); + } + + [TestMethod] + [Description("Regression guard: verifies the UsageError code is configurable so that applications can publish their own exit-code contract.")] + public void When_UsageErrorIsRemapped_Then_ConfiguredCodeIsReturned() + { + var sut = CreateApp(recorder: null, options => options.ExitCodes.UsageError = 64); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["nope"], out _); + + exitCode.Should().Be(64); + } + + [TestMethod] + [Description("Regression guard: verifies the resolver receives the table-mapped code and that its return value is final.")] + public void When_ResolverIsSet_Then_ItReceivesMappedCodeAndItsReturnWins() + { + var seen = new List(); + var sut = CreateApp(recorder: null, options => options.ExitCodes.Resolver = outcome => + { + seen.Add(outcome.ExitCode); + return outcome.ExitCode + 10; + }); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["nope"], out _); + + exitCode.Should().Be(12); + seen.Should().Equal(2); + } + + [TestMethod] + [Description("Regression guard: verifies the resolver can override an explicit IExitResult so that one interception point governs every final outcome.")] + public void When_ResolverSeesExitResult_Then_ItCanOverrideIt() + { + var sut = CreateApp(recorder: null, options => options.ExitCodes.Resolver = outcome => + outcome.Kind == ReplExecutionOutcomeKind.HandlerExitCode ? 99 : outcome.ExitCode); + sut.Map("quit", () => Results.Exit(5)); + + var exitCode = Run(sut, ["quit"], out _); + + exitCode.Should().Be(99); + } + + [TestMethod] + [Description("Regression guard: verifies a dependency the binder cannot resolve is a BindingError so that the classification of service-resolution failures is deliberate, not incidental.")] + public void When_FromServicesDependencyIsMissing_Then_KindIsBindingError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("show", ([FromServices] IMissingDependency dependency) => dependency.ToString()); + + var exitCode = Run(sut, ["show"], out _); + + exitCode.Should().Be(2); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.BindingError); + } + + [TestMethod] + [Description("Regression guard: verifies every outcome kind has its own table entry so that a kind added later cannot silently fall through to the FrameworkError default.")] + public void When_EveryKindIsMapped_Then_NoKindFallsThroughToTheDefaultArm() + { + var table = new ExitCodeOptions + { + Success = 10, + Help = 11, + UsageError = 12, + BindingError = 13, + HandlerError = 14, + HandlerException = 15, + Cancelled = 16, + FrameworkError = 17, + }; + const int carried = 99; + + foreach (var kind in Enum.GetValues()) + { + var mapped = table.Map(kind, carried); + var expected = kind switch + { + ReplExecutionOutcomeKind.HandlerExitCode or ReplExecutionOutcomeKind.Interrupted => carried, + ReplExecutionOutcomeKind.FrameworkError => table.FrameworkError, + _ => mapped, + }; + mapped.Should().Be(expected, $"{kind} must map through its own arm"); + if (kind != ReplExecutionOutcomeKind.FrameworkError) + { + mapped.Should().NotBe(table.FrameworkError, $"{kind} must not fall through to the default arm"); + } + } + } + + [TestMethod] + [Description("Regression guard: verifies a handler returning an int renders it as data and exits 0 so that scalar results never become exit codes.")] + public void When_HandlerReturnsInt_Then_ValueIsRenderedAndKindIsSuccess() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("count", () => 3); + + var exitCode = Run(sut, ["count"], out var output); + + exitCode.Should().Be(0); + output.Should().Contain("3"); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.Success); + recorder.Last.Result.Should().Be(3); + } + + [TestMethod] + [Description("Regression guard: verifies the last tuple element decides the outcome so that tuple rendering follows the single-result rules.")] + public void When_LastTupleElementIsError_Then_KindIsHandlerErrorAndResultIsLastElement() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("pair", () => ("first", Results.Error("boom", "failed"))); + + var exitCode = Run(sut, ["pair"], out _); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerError); + recorder.Last.Result.Should().BeAssignableTo() + .Which.Kind.Should().Be("error"); + } + + [TestMethod] + [Description("Regression guard: verifies middleware can observe the handler result after next() so that cross-cutting concerns can inspect outcomes.")] + public void When_MiddlewareObservesResultAfterNext_Then_ContextResultHoldsHandlerReturn() + { + object? observed = null; + var sut = CreateApp(recorder: null); + sut.Use(async (context, next) => + { + await next().ConfigureAwait(false); + observed = context.Result; + }); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["hello"], out _); + + exitCode.Should().Be(0); + observed.Should().Be("world"); + } + + [TestMethod] + [Description("Regression guard: verifies a middleware-replaced result is rendered and classified so that middleware can transform outcomes.")] + public void When_MiddlewareReplacesResultAfterNext_Then_ReplacementIsRenderedAndClassified() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Use(async (context, next) => + { + await next().ConfigureAwait(false); + context.Result = Results.Error("replaced", "middleware failed it"); + }); + sut.Map("hello", () => "world"); + + var exitCode = Run(sut, ["hello"], out var output); + + exitCode.Should().Be(1); + output.Should().Contain("middleware failed it"); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.HandlerError); + } + + [TestMethod] + [Description("Regression guard: verifies a short-circuiting middleware can supply a result so that it is rendered in place of the handler's.")] + public void When_MiddlewareShortCircuitsAndSetsResult_Then_ResultIsRendered() + { + var handlerCalled = false; + var sut = CreateApp(recorder: null); + sut.Use((context, _) => + { + context.Result = "from-middleware"; + return ValueTask.CompletedTask; + }); + sut.Map("hello", () => + { + handlerCalled = true; + return "world"; + }); + + var exitCode = Run(sut, ["hello"], out var output); + + exitCode.Should().Be(0); + handlerCalled.Should().BeFalse(); + output.Should().Contain("from-middleware"); + } + + [TestMethod] + [Description("Regression guard: verifies a hosted protocol-passthrough refusal is a FrameworkError so that hosting-capability gaps are not reported as usage mistakes.")] + public void When_ProtocolPassthroughIsRefusedInHostedSession_Then_KindIsFrameworkError() + { + var recorder = new OutcomeRecorder(); + var sut = CreateApp(recorder); + sut.Map("mcp start", () => Results.Exit(0)) + .AsProtocolPassthrough(); + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = sut.Run(["mcp", "start"], host); + + exitCode.Should().Be(1); + recorder.Last!.Kind.Should().Be(ReplExecutionOutcomeKind.FrameworkError); + } + + private static ReplApp CreateApp(OutcomeRecorder? recorder, Action? configure = null) + { + var app = ReplApp.Create(); + app.Options(options => + { + options.Interactive.InteractivePolicy = InteractivePolicy.Prevent; + options.Output.BannerEnabled = false; + if (recorder is not null) + { + options.ExitCodes.Resolver = recorder.Record; + } + + configure?.Invoke(options); + }); + return app; + } + + private static int Run(ReplApp sut, string[] args, out string output) + { + using var session = OpenSession(out var writer); + var exitCode = sut.Run(args); + output = writer.ToString(); + return exitCode; + } + + private static IDisposable OpenSession(out StringWriter writer) + { + writer = new StringWriter(); + return ReplSessionIO.SetSession(writer, TextReader.Null, commandOutput: writer, error: writer); + } + + private sealed class OutcomeRecorder + { + public ReplExecutionOutcome? Last { get; private set; } + + public int Record(ReplExecutionOutcome outcome) + { + Last = outcome; + return outcome.ExitCode; + } + } + + private interface IMissingDependency; + + private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost + { + public TextReader Input { get; } = input; + + public TextWriter Output { get; } = output; + } +} diff --git a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs index bf2c3f84..bed579b5 100644 --- a/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs +++ b/src/Repl.Tests/Given_InteractiveSession_ShellIntegrationMarks.cs @@ -46,8 +46,8 @@ public void When_CommandReturnsError_Then_CommandEndReportsExitCodeOne() } [TestMethod] - [Description("An unknown command resolves to a route-resolution failure and reports exit code 1 in the command-end mark.")] - public void When_UnknownCommandIsEntered_Then_CommandEndReportsExitCodeOne() + [Description("An unknown command resolves to a route-resolution failure and reports the usage exit code (2) in the command-end mark.")] + public void When_UnknownCommandIsEntered_Then_CommandEndReportsUsageExitCode() { using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); @@ -56,12 +56,12 @@ public void When_UnknownCommandIsEntered_Then_CommandEndReportsExitCodeOne() var raw = RunInteractiveSession(harness, sut, "zorglub\rexit\r"); - raw.Should().Contain("]133;D;1"); + raw.Should().Contain("]133;D;2"); } [TestMethod] - [Description("An ambiguous command prefix renders its error inside the normal lifecycle and reports exit code 1 in the command-end mark, like any other failed input.")] - public void When_AmbiguousPrefixIsCommitted_Then_CommandEndReportsExitCodeOne() + [Description("An ambiguous command prefix renders its error inside the normal lifecycle and reports the usage exit code (2) in the command-end mark, like any other failed input.")] + public void When_AmbiguousPrefixIsCommitted_Then_CommandEndReportsUsageExitCode() { using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); @@ -72,7 +72,54 @@ public void When_AmbiguousPrefixIsCommitted_Then_CommandEndReportsExitCodeOne() var raw = RunInteractiveSession(harness, sut, "ga\rexit\r"); raw.Should().Contain("Ambiguous command prefix"); - raw.Should().Contain("]133;D;1"); + raw.Should().Contain("]133;D;2"); + } + + [TestMethod] + [Description("The command-end mark follows the configured exit-code table, so an application that remaps usage errors sees its own code in the terminal decoration.")] + public void When_UsageErrorIsRemapped_Then_CommandEndMarkFollowsTable() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Options(options => options.ExitCodes.UsageError = 64); + sut.Map("ping", () => "pong"); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "zorglub\rexit\r"); + + raw.Should().Contain("]133;D;64"); + } + + [TestMethod] + [Description("The command-end mark goes through ExitCodes.Resolver, so an application-wide exit-code convention is visible in the terminal decoration too.")] + public void When_ResolverIsConfigured_Then_CommandEndMarkUsesItsReturnValue() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Options(options => options.ExitCodes.Resolver = outcome => + outcome.Kind == ReplExecutionOutcomeKind.HandlerError ? 70 : outcome.ExitCode); + sut.Map("fail", () => Results.Error("boom", "failed")); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "fail\rexit\r"); + + raw.Should().Contain("]133;D;70"); + } + + [TestMethod] + [Description("A configured ExitCodes.Cancelled replaces the conventional 130 in the command-end mark of a cancelled interactive command.")] + public void When_CancelledIsConfigured_Then_CancelledCommandEndMarkUsesConfiguredCode() + { + using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); + var sut = CreateMarkedApp(); + sut.Options(options => options.ExitCodes.Cancelled = 7); + sut.Map("boom", string () => throw new OperationCanceledException()); + var harness = new TerminalHarness(cols: 80, rows: 12); + + var raw = RunInteractiveSession(harness, sut, "boom\rexit\r"); + + raw.Should().Contain("Cancelled."); + raw.Should().Contain("]133;D;7"); } [TestMethod] @@ -301,8 +348,8 @@ public void When_IntegrationNotConfigured_Then_ShellIntegrationStatusNamesTheGat } [TestMethod] - [Description("A failed completion ambient command (complete without --target) reports exit code 1 in the command-end mark instead of decorating the failure as success.")] - public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() + [Description("A failed completion ambient command (complete without --target) reports the usage exit code (2) in the command-end mark instead of decorating the failure as success.")] + public void When_CompleteAmbientCommandFails_Then_CommandEndReportsUsageExitCode() { using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); @@ -312,12 +359,12 @@ public void When_CompleteAmbientCommandFails_Then_CommandEndReportsExitCodeOne() var raw = RunInteractiveSession(harness, sut, "complete\rexit\r"); raw.Should().Contain("Error: complete requires --target"); - raw.Should().Contain("]133;D;1"); + raw.Should().Contain("]133;D;2"); } [TestMethod] - [Description("An ambient help invocation that fails to render (unknown output format) reports exit code 1 in the command-end mark, matching the non-ambient --help path.")] - public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeOne() + [Description("An ambient help invocation that fails to render (unknown output format) reports the usage exit code (2) in the command-end mark, matching the non-ambient --help path.")] + public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsUsageExitCode() { using var env = new EnvironmentVariableScope(TerminalTestEnvironments.Neutral); var sut = CreateMarkedApp(); @@ -326,7 +373,7 @@ public void When_HelpAmbientCommandFailsToRender_Then_CommandEndReportsExitCodeO var raw = RunInteractiveSession(harness, sut, "help --output:bogus\rexit\r"); - raw.Should().Contain("]133;D;1"); + raw.Should().Contain("]133;D;2"); } [TestMethod]