diff --git a/src/Clet/Hosting/CletExitCodes.cs b/src/Clet/Hosting/CletExitCodes.cs new file mode 100644 index 0000000..8819af1 --- /dev/null +++ b/src/Clet/Hosting/CletExitCodes.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Terminal.Gui.Cli; + +namespace Clet; + +internal static class CletExitCodes +{ + public static int FromResult (CommandResult result) + { + if (result.Status == CommandStatus.Error && result.ErrorCode == "input-too-large") + { + return ExitCodes.ValidationError; + } + + return ExitCodes.FromResult (result); + } + + public static int MapPackageExit (int exitCode, string stdout) + { + if (exitCode == ExitCodes.UsageError && HasInputTooLargeCode (stdout)) + { + return ExitCodes.ValidationError; + } + + return exitCode; + } + + private static bool HasInputTooLargeCode (string stdout) + { + try + { + using JsonDocument document = JsonDocument.Parse (stdout); + + return document.RootElement.TryGetProperty ("code", out JsonElement code) + && code.ValueKind == JsonValueKind.String + && code.GetString () == "input-too-large"; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Clet/Hosting/Program.cs b/src/Clet/Hosting/Program.cs index 3bb4837..ef24443 100644 --- a/src/Clet/Hosting/Program.cs +++ b/src/Clet/Hosting/Program.cs @@ -7,25 +7,90 @@ internal static class Program public static async Task Main (string[] args) { CletLogging.Initialize (); + CliHostOptions parserOptions = CreateOptions (); - CliHost host = new (options => - { - options.ApplicationName = "clet"; - options.Version = VersionInfo.GetCletVersion (); - options.HelpProvider = new CletHelpProvider (); - options.ResourceAssembly = typeof (Program).Assembly; - - // clet-specific global options - options.GlobalOptions.Add (new GlobalOptionDescriptor ("allow-file", null, - "Explicitly allow reading a file path (bypasses extension + cwd checks).", false, Repeatable: true)); - options.GlobalOptions.Add (new GlobalOptionDescriptor ("allow-binary", null, - "Permit binary file content (NUL bytes).", true)); - options.GlobalOptions.Add (new GlobalOptionDescriptor ("no-browse", null, - "Disable browser-mode navigation for viewer clets.", true)); - }); + CliHost host = new (ConfigureOptions); BuiltInCommands.RegisterAll (host.Registry); - return await host.RunAsync (args); + if (TryHandleOversizedInitial (args, host.Registry, parserOptions, out int exitCode)) + { + return exitCode; + } + + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + int packageExitCode = await host.RunAsync (args, stdout: stdout, stderr: stderr); + string stdoutText = stdout.ToString (); + string stderrText = stderr.ToString (); + + Console.Out.Write (stdoutText); + Console.Error.Write (stderrText); + + return CletExitCodes.MapPackageExit (packageExitCode, stdoutText); + } + + private static bool TryHandleOversizedInitial ( + string[] args, + ICommandRegistry registry, + CliHostOptions options, + out int exitCode) + { + exitCode = ExitCodes.UsageError; + ArgParser parser = new (options.GlobalOptions, int.MaxValue); + ArgParser.ParseResult rootParse = parser.Parse (args); + + if (!rootParse.Success + || rootParse.RootFlag is not null + || rootParse.Alias is null + || !registry.TryResolve (rootParse.Alias, out _)) + { + return false; + } + + CommandRunOptions? runOptions = rootParse.Options; + + if (runOptions is null + || runOptions.Initial is null + || runOptions.Initial.Length <= options.MaxInitialChars) + { + return false; + } + + CommandResult result = new ( + CommandStatus.Error, + null, + "input-too-large", + $"--initial exceeds the maximum length of {options.MaxInitialChars} characters."); + + ResultWriter.Write (result, runOptions.JsonOutput, Console.Out, Console.Error, runOptions.OutputPath); + exitCode = CletExitCodes.FromResult (result); + + return true; + } + + private static CliHostOptions CreateOptions () + { + CliHostOptions options = new (); + ConfigureOptions (options); + + return options; + } + + private static void ConfigureOptions (CliHostOptions options) + { + options.ApplicationName = "clet"; + options.Version = VersionInfo.GetCletVersion (); + options.HelpProvider = new CletHelpProvider (); + options.ResourceAssembly = typeof (Program).Assembly; + + // clet-specific global options + options.GlobalOptions.Add (new GlobalOptionDescriptor ("allow-file", null, + "Explicitly allow reading a file path (bypasses extension + cwd checks).", false, Repeatable: true)); + options.GlobalOptions.Add (new GlobalOptionDescriptor ("allow-binary", null, + "Permit binary file content (NUL bytes).", true)); + options.GlobalOptions.Add (new GlobalOptionDescriptor ("no-browse", null, + "Disable browser-mode navigation for viewer clets.", true)); } } diff --git a/tests/Clet.SmokeTests/CletSmokeTests.cs b/tests/Clet.SmokeTests/CletSmokeTests.cs index c3b689d..b46d6e7 100644 --- a/tests/Clet.SmokeTests/CletSmokeTests.cs +++ b/tests/Clet.SmokeTests/CletSmokeTests.cs @@ -111,7 +111,7 @@ public async Task MdOversizedStdin_ExitsWithError () (int exit, string stdout, _) = await CletProcess.RunAsync ( ["md", "--json"], stdin: oversized); - Assert.Equal (2, exit); + Assert.Equal (65, exit); Assert.Contains ("input-too-large", stdout); Assert.Contains ("\"status\":\"error\"", stdout); } diff --git a/tests/Clet.UnitTests/ExitCodesTests.cs b/tests/Clet.UnitTests/ExitCodesTests.cs index 280e377..99199d0 100644 --- a/tests/Clet.UnitTests/ExitCodesTests.cs +++ b/tests/Clet.UnitTests/ExitCodesTests.cs @@ -22,16 +22,13 @@ public void Constants_MatchSpec () [InlineData ((int)CommandStatus.Cancelled, null, 130)] [InlineData ((int)CommandStatus.NoResult, null, 1)] [InlineData ((int)CommandStatus.Error, "validation", 65)] - // NOTE: spec requires input-too-large → 65, but the package maps all unknown error codes to 2. - // Tracked upstream: Terminal.Gui.Cli needs a custom exit code mapper or treating - // "input-too-large" as a validation error. See PR #185 review discussion. - [InlineData ((int)CommandStatus.Error, "input-too-large", 2)] + [InlineData ((int)CommandStatus.Error, "input-too-large", 65)] [InlineData ((int)CommandStatus.Error, "io", 74)] [InlineData ((int)CommandStatus.Error, "anything-else", 2)] public void FromResult_MapsStatusToExit (int statusInt, string? errorCode, int expected) { CommandResult result = new ((CommandStatus)statusInt, null, errorCode, null); - Assert.Equal (expected, ExitCodes.FromResult (result)); + Assert.Equal (expected, CletExitCodes.FromResult (result)); } }