diff --git a/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs b/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs new file mode 100644 index 00000000..062ac0a7 --- /dev/null +++ b/FirstClassErrors.Cli.UnitTests/CliApplicationExitCodeTests.cs @@ -0,0 +1,92 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.Cli.UnitTests; + +/// +/// The exit codes the entry point answers with for a command line that never reaches a command. They are part of +/// the tool's published contract (decision: ADR-0067), and the parse path is the one a command's own tests cannot +/// reach — it fails before any command is constructed. +/// +[TestSubject(typeof(CliApplication))] +public sealed class CliApplicationExitCodeTests { + + #region Statics members declarations + + private static (int exitCode, string error) Run(params string[] args) { + TextWriter original = Console.Error; + StringWriter captured = new(); + try { + Console.SetError(captured); + + return (CliApplication.RunAsync(args).GetAwaiter().GetResult(), captured.ToString()); + } finally { + Console.SetError(original); + } + } + + #endregion + + [Fact(DisplayName = "An unknown command is a usage error (64), reported on standard error.")] + public void AnUnknownCommandIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("frobnicate"); + + // Verify: the code says "this invocation is wrong", and the run says so rather than exiting silently. + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("frobnicate"); + Check.That(error).Contains("fce --help"); + } + + [Fact(DisplayName = "An unknown command inside a branch is a usage error (64) too.")] + public void AnUnknownCommandInsideABranchIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("catalog", "frobnicate"); + + // Verify + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("frobnicate"); + } + + [Fact(DisplayName = "An option given without its value is a usage error (64).")] + public void AnOptionWithoutItsValueIsAUsageError() { + // Exercise + (int exitCode, string error) = Run("generate", "--solution"); + + // Verify + Check.That(exitCode).IsEqualTo(64); + Check.That(error).Contains("solution"); + } + + // The regression this pins: before the handler existed, the parser's own failure path returned -1 — a value in no + // exit-code table — and wrote nothing to either stream, so a mistyped command produced nothing at all to read. + [Fact(DisplayName = "A refused command line never exits with the parser's own -1, silently.")] + public void ARefusedCommandLineNeverExitsWithMinusOne() { + // Exercise + (int exitCode, string error) = Run("frobnicate"); + + // Verify + Check.That(exitCode).IsNotEqualTo(-1); + Check.That(error).IsNotEmpty(); + } + + [Fact(DisplayName = "Asking for help is not a usage error: it succeeds (0).")] + public void AskingForHelpSucceeds() { + // Exercise + (int exitCode, string _) = Run("--help"); + + // Verify: --help is what the usage error tells the caller to run, so it must not itself be an error. + Check.That(exitCode).IsEqualTo(0); + } + + [Fact(DisplayName = "The entry point rejects a null argument array.")] + public void RejectsANullArgumentArray() { + Check.ThatCode(() => CliApplication.RunAsync(null!)).Throws(); + } + +} diff --git a/FirstClassErrors.Cli/CliApplication.cs b/FirstClassErrors.Cli/CliApplication.cs new file mode 100644 index 00000000..64ccce7e --- /dev/null +++ b/FirstClassErrors.Cli/CliApplication.cs @@ -0,0 +1,86 @@ +#region Usings declarations + +using Spectre.Console.Cli; + +#endregion + +namespace FirstClassErrors.Cli; + +/// +/// Builds and runs the fce command tree. It exists as a type rather than as the body of +/// Program.cs so the entry point can be exercised by a test: a top-level program's statements are +/// reachable only by launching a process, and the exit code a bad command line produces is part of the tool's +/// published contract (decision: ADR-0067). +/// +/// +/// The command tree's own failures are reported inside each command, which returns +/// after a terse line. What reaches the handler here is what the commands never +/// see: a command line the parser refused, so no command ever ran. +/// +internal static class CliApplication { + + #region Statics members declarations + + /// + /// Runs the command tree over . + /// + /// The process arguments, as given on the command line. + /// The exit code, always one of . + internal static Task RunAsync(string[] args) { + if (args is null) { throw new ArgumentNullException(nameof(args)); } + + CommandApp app = new(); + app.Configure(Configure); + + return app.RunAsync(args); + } + + private static void Configure(IConfigurator config) { + config.SetApplicationName("fce"); + config.SetExceptionHandler(HandleUncaught); + + config.AddCommand("generate") + .WithDescription("Generate error documentation from a solution or from assemblies."); + + config.AddBranch("catalog", catalog => { + catalog.SetDescription("Track the error catalog as a versioned contract (baseline + diff)."); + catalog.AddCommand("update").WithDescription("Create or refresh the catalog baseline (deliberately accept the current contract)."); + catalog.AddCommand("diff").WithDescription("Compare the current catalog against the baseline and report the changes."); + }); + + config.AddBranch("config", configuration => { + configuration.SetDescription("Manage the configuration file (fce.json)."); + configuration.AddCommand("init").WithDescription("Create the configuration file."); + configuration.AddCommand("show").WithDescription("Print the current configuration."); + + configuration.AddBranch("renderer", renderer => { + renderer.SetDescription("Manage the custom renderer libraries referenced by the configuration."); + renderer.AddCommand("add").WithDescription("Register a renderer library."); + renderer.AddCommand("remove").WithDescription("Unregister a renderer library."); + renderer.AddCommand("list").WithDescription("List available renderers (built-in and configured)."); + }); + }); + } + + /// + /// Answers for whatever escapes the command tree, and keeps that answer inside . + /// + /// + /// Without a handler the parser's own failure path returned -1 — a value in no exit-code table, and + /// silent on both streams, so a mistyped command produced nothing at all to read. A wrong command line is a + /// usage error, which names; anything else reaching here is a failure the + /// commands did not catch, and it reports as one rather than borrowing the usage code. + /// + private static int HandleUncaught(Exception exception, ITypeResolver? resolver) { + _ = resolver; + bool usage = exception is CommandParseException or CommandTemplateException or CommandConfigurationException; + + Console.Error.WriteLine($"error: {exception.Message}"); + if (usage) { Console.Error.WriteLine("Run 'fce --help' to see the available commands."); } + + return usage ? ExitCodes.UsageError : ExitCodes.Failure; + } + + #endregion + +} diff --git a/FirstClassErrors.Cli/ExitCodes.cs b/FirstClassErrors.Cli/ExitCodes.cs index 8e84ef0e..6e710691 100644 --- a/FirstClassErrors.Cli/ExitCodes.cs +++ b/FirstClassErrors.Cli/ExitCodes.cs @@ -24,6 +24,15 @@ internal static class ExitCodes { /// internal const int ChangesDetected = 2; + /// + /// The command line could not be parsed: an unknown command, a malformed option, an argument the settings + /// reject. Distinct from so a pipeline can tell "this invocation is wrong" — which a + /// retry will never fix — from "the tool ran and could not finish". 64 is EX_USAGE, the + /// conventional value for a command-line usage error; already owns 2, + /// the other convention for it. + /// + internal const int UsageError = 64; + /// /// The run was cancelled (Ctrl+C). The conventional value for a process killed by a signal is /// 128 + signal, and SIGINT is signal 2 — an abort, not a failure. diff --git a/FirstClassErrors.Cli/Program.cs b/FirstClassErrors.Cli/Program.cs index bba2bda8..04a6f99f 100644 --- a/FirstClassErrors.Cli/Program.cs +++ b/FirstClassErrors.Cli/Program.cs @@ -2,38 +2,9 @@ using FirstClassErrors.Cli; -using Spectre.Console.Cli; - #endregion -CommandApp app = new(); - -app.Configure(config => { - config.SetApplicationName("fce"); - - config.AddCommand("generate") - .WithDescription("Generate error documentation from a solution or from assemblies."); - - config.AddBranch("catalog", catalog => { - catalog.SetDescription("Track the error catalog as a versioned contract (baseline + diff)."); - catalog.AddCommand("update").WithDescription("Create or refresh the catalog baseline (deliberately accept the current contract)."); - catalog.AddCommand("diff").WithDescription("Compare the current catalog against the baseline and report the changes."); - }); - - config.AddBranch("config", configuration => { - configuration.SetDescription("Manage the configuration file (fce.json)."); - configuration.AddCommand("init").WithDescription("Create the configuration file."); - configuration.AddCommand("show").WithDescription("Print the current configuration."); - - configuration.AddBranch("renderer", renderer => { - renderer.SetDescription("Manage the custom renderer libraries referenced by the configuration."); - renderer.AddCommand("add").WithDescription("Register a renderer library."); - renderer.AddCommand("remove").WithDescription("Unregister a renderer library."); - renderer.AddCommand("list").WithDescription("List available renderers (built-in and configured)."); - }); - }); -}); - -// Spectre handles argument parsing, validation errors and --help. Runtime failures are handled inside each command -// so the tool reports them as a terse "error: …" line rather than a stack trace. -return await app.RunAsync(args); +// The command tree, its exception handling and its exit codes live in CliApplication, so a test can reach them +// without launching a process. Spectre handles argument parsing and --help; runtime failures are handled inside +// each command so the tool reports them as a terse "error: …" line rather than a stack trace. +return await CliApplication.RunAsync(args); diff --git a/doc/handwritten/for-users/CatalogVersioningReference.en.md b/doc/handwritten/for-users/CatalogVersioningReference.en.md index e10c5b48..c4e828de 100644 --- a/doc/handwritten/for-users/CatalogVersioningReference.en.md +++ b/doc/handwritten/for-users/CatalogVersioningReference.en.md @@ -43,6 +43,7 @@ Exit codes: | --- | --- | | `0` | Baseline created, already current, or replaced successfully. | | `1` | Execution error or baseline schema newer than the tool. | +| `64` | The command line was refused: unknown command, or an option given without its value. | | `130` | Execution interrupted. | ## `fce catalog diff` @@ -60,6 +61,7 @@ Exit codes: | `0` | No change reaches the threshold selected by `--fail-on`. | | `2` | At least one change reaches that threshold. | | `1` | Execution error: missing baseline, failed extraction, invalid file, and so on. | +| `64` | The command line was refused: unknown command, or an option given without its value. | | `130` | Execution interrupted. | ### Failure policy: `--fail-on` diff --git a/doc/handwritten/for-users/CatalogVersioningReference.fr.md b/doc/handwritten/for-users/CatalogVersioningReference.fr.md index 1e8f4ea9..ee4c0615 100644 --- a/doc/handwritten/for-users/CatalogVersioningReference.fr.md +++ b/doc/handwritten/for-users/CatalogVersioningReference.fr.md @@ -43,6 +43,7 @@ Codes de sortie : | --- | --- | | `0` | Baseline créée, déjà à jour ou remplacée avec succès. | | `1` | Erreur d'exécution ou schéma de baseline plus récent que l'outil. | +| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. | | `130` | Exécution interrompue. | ## `fce catalog diff` @@ -60,6 +61,7 @@ Codes de sortie : | `0` | Aucun changement n'atteint le seuil défini par `--fail-on`. | | `2` | Au moins un changement atteint ce seuil. | | `1` | Erreur d'exécution : baseline manquante, extraction impossible, fichier invalide, etc. | +| `64` | Ligne de commande refusée : commande inconnue, ou option fournie sans sa valeur. | | `130` | Exécution interrompue. | ### Politique d'échec : `--fail-on`