diff --git a/CliFx.Tests.Dummy/Commands/ShowErrorMessageThenHelpTextOnErrorCommand.cs b/CliFx.Tests.Dummy/Commands/ShowErrorMessageThenHelpTextOnErrorCommand.cs new file mode 100644 index 0000000..6a2e453 --- /dev/null +++ b/CliFx.Tests.Dummy/Commands/ShowErrorMessageThenHelpTextOnErrorCommand.cs @@ -0,0 +1,20 @@ +using CliFx.Attributes; +using CliFx.Exceptions; +using System.Threading.Tasks; + +namespace CliFx.Tests.Dummy.Commands +{ + /// + /// Demos how to show an error message then help text from an organizational command. + /// + [Command("cmd-err", Description = "This is an organizational command. " + + "I don't do anything except provide a route to my subcommands. " + + "If you use just me, I print an error message then the help text " + + "to remind you of my subcommands.")] + public class ShowErrorMessageThenHelpTextOnCommandExceptionCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException("It is an error to use me without a subcommand. " + + "Please refer to the help text below for guidance.", showHelp: true); + } +} \ No newline at end of file diff --git a/CliFx.Tests.Dummy/Commands/ShowHelpTextOnErrorCommand.cs b/CliFx.Tests.Dummy/Commands/ShowHelpTextOnErrorCommand.cs new file mode 100644 index 0000000..3e6fc85 --- /dev/null +++ b/CliFx.Tests.Dummy/Commands/ShowHelpTextOnErrorCommand.cs @@ -0,0 +1,18 @@ +using CliFx.Attributes; +using CliFx.Exceptions; +using System.Threading.Tasks; + +namespace CliFx.Tests.Dummy.Commands +{ + /// + /// Demos how to show help text from an organizational command. + /// + [Command("cmd", Description = "This is an organizational command. " + + "I don't do anything except provide a route to my subcommands. " + + "If you use just me, I print the help text to remind you of my subcommands.")] + public class ShowHelpTextOnCommandExceptionCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException(null, showHelp: false); + } +} \ No newline at end of file diff --git a/CliFx.Tests/ErrorReportingSpecs.Commands.cs b/CliFx.Tests/ErrorReportingSpecs.Commands.cs index c4e5a5f..d8e247c 100644 --- a/CliFx.Tests/ErrorReportingSpecs.Commands.cs +++ b/CliFx.Tests/ErrorReportingSpecs.Commands.cs @@ -27,5 +27,39 @@ namespace CliFx.Tests public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode); } + + [Command("exc")] + private class ShowHelpTextOnlyCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(null, showHelp: true); + } + + [Command("exc sub")] + private class ShowHelpTextOnlySubCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => default; + } + + [Command("exc")] + private class ShowErrorMessageThenHelpTextCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => + throw new CommandException("Error message.", showHelp: true); + } + + [Command("exc sub")] + private class ShowErrorMessageThenHelpTextSubCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => default; + } + + [Command("exc")] + private class StackTraceOnlyCommand : ICommand + { + [CommandOption("msg", 'm')] + public string? Message { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(null); + } } } \ No newline at end of file diff --git a/CliFx.Tests/ErrorReportingSpecs.cs b/CliFx.Tests/ErrorReportingSpecs.cs index 9515d7d..329b8c9 100644 --- a/CliFx.Tests/ErrorReportingSpecs.cs +++ b/CliFx.Tests/ErrorReportingSpecs.cs @@ -9,7 +9,7 @@ namespace CliFx.Tests public partial class ErrorReportingSpecs { [Fact] - public async Task Command_may_throw_a_generic_exception_which_exits_and_prints_full_error_details() + public async Task Command_may_throw_a_generic_exception_which_exits_and_prints_error_message_and_stack_trace() { // Arrange await using var stdErr = new MemoryStream(); @@ -29,8 +29,10 @@ namespace CliFx.Tests // Assert exitCode.Should().NotBe(0); - stdErrData.Should().Contain("Kaput"); - stdErrData.Length.Should().BeGreaterThan("Kaput".Length); + stdErrData.Should().ContainAll( + "System.Exception:", + "Kaput", "at", + "CliFx.Tests"); } [Fact] @@ -80,5 +82,95 @@ namespace CliFx.Tests exitCode.Should().NotBe(0); stdErrData.Should().NotBeEmpty(); } + + [Fact] + public async Task Command_may_throw_a_specialized_exception_which_shows_only_the_help_text() + { + // Arrange + await using var stdOut = new MemoryStream(); + await using var stdErr = new MemoryStream(); + + var console = new VirtualConsole(output: stdOut); + + var application = new CliApplicationBuilder() + .AddCommand(typeof(ShowHelpTextOnlyCommand)) + .AddCommand(typeof(ShowHelpTextOnlySubCommand)) + .UseConsole(console) + .Build(); + + // Act + await application.RunAsync(new[] { "exc" }); + var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd(); + var stdErrData = console.Output.Encoding.GetString(stdErr.ToArray()).TrimEnd(); + + // Assert + stdErrData.Should().BeEmpty(); + stdOutData.Should().ContainAll( + "Usage", + "[command]", + "Options", + "-h|--help", "Shows help text.", + "Commands", + "sub", + "You can run", "to show help on a specific command." + ); + } + + [Fact] + public async Task Command_may_throw_specialized_exception_which_shows_the_error_message_then_the_help_text() + { + // Arrange + await using var stdOut = new MemoryStream(); + await using var stdErr = new MemoryStream(); + var console = new VirtualConsole(output: stdOut, error: stdErr); + + var application = new CliApplicationBuilder() + .AddCommand(typeof(ShowErrorMessageThenHelpTextCommand)) + .AddCommand(typeof(ShowErrorMessageThenHelpTextSubCommand)) + .UseConsole(console) + .Build(); + + // Act + await application.RunAsync(new[] { "exc" }); + var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd(); + var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd(); + + // Assert + stdErrData.Should().Be("Error message."); + stdOutData.Should().ContainAll( + "Usage", + "[command]", + "Options", + "-h|--help", "Shows help text.", + "Commands", + "sub", + "You can run", "to show help on a specific command." + ); + } + + [Fact] + public async Task Command_may_throw_a_specialized_exception_which_shows_only_a_stack_trace_and_no_help_text() + { + // Arrange + await using var stdErr = new MemoryStream(); + var console = new VirtualConsole(error: stdErr); + + var application = new CliApplicationBuilder() + .AddCommand(typeof(GenericExceptionCommand)) + .UseConsole(console) + .Build(); + + // Act + var exitCode = await application.RunAsync( + new[] { "exc", "-m", "Kaput" }, + new Dictionary()); + + var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd(); + + // Assert + exitCode.Should().NotBe(0); + stdErrData.Should().Contain("Kaput"); + stdErrData.Length.Should().BeGreaterThan("Kaput".Length); + } } } \ No newline at end of file diff --git a/CliFx.Tests/HelpTextSpecs.Commands.cs b/CliFx.Tests/HelpTextSpecs.Commands.cs index dccc2a8..4a3198c 100644 --- a/CliFx.Tests/HelpTextSpecs.Commands.cs +++ b/CliFx.Tests/HelpTextSpecs.Commands.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using CliFx.Attributes; +using CliFx.Exceptions; namespace CliFx.Tests { diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index d4b48b4..e892e26 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -143,6 +143,50 @@ namespace CliFx return 0; } + /// + /// Handle s differently from the rest because we want to + /// display it different based on whether we are showing the help text or not. + /// + private int HandleCommandException(IReadOnlyList commandLineArguments, CommandException commandException) + { + var showHelp = commandException.ShowHelp; + + var errorMessage = commandException.HasMessage + ? commandException.Message + : commandException.ToString(); + + _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); + + if (showHelp) + { + var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes); + var commandLineInput = CommandLineInput.Parse(commandLineArguments); + var commandSchema = applicationSchema.TryFindCommand(commandLineInput) ?? + CommandSchema.StubDefaultCommand; + _helpTextWriter.Write(applicationSchema, commandSchema); + } + + return commandException.ExitCode; + } + + /// + /// Handles s by printing its error message if it has a value. + /// Otherwise, it prints the full stack trace from the exception. + /// + /// The exception to handle. + /// The exception's HResult. + private int HandleCliFxException(CliFxException cliFxException) + { + // Prefer showing message without stack trace on CliFxExceptions. + var errorMessage = cliFxException.HasMessage + ? cliFxException.Message + : cliFxException.ToString(); + + _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); + + return cliFxException.HResult; + } + /// /// Runs the application with specified command line arguments and environment variables, and returns the exit code. /// @@ -162,21 +206,23 @@ namespace CliFx HandleHelpOption(applicationSchema, commandLineInput) ?? await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables); } - catch (Exception ex) + catch (CommandException ce) { // We want to catch exceptions in order to print errors and return correct exit codes. // Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions. - - // Prefer showing message without stack trace on exceptions coming from CliFx or on CommandException - var errorMessage = !string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException) - ? ex.Message - : ex.ToString(); - - _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); - - return ex is CommandException commandException - ? commandException.ExitCode - : ex.HResult; + var exitCode = HandleCommandException(commandLineArguments, ce); + return exitCode; + } + catch (CliFxException cfe) + { + var hResult = HandleCliFxException(cfe); + return hResult; + } + catch (Exception ex) + { + // For all other errors, we just write the entire thing to stderr. + _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.ToString())); + return ex.HResult; } } diff --git a/CliFx/Exceptions/BaseCliFxException.cs b/CliFx/Exceptions/BaseCliFxException.cs new file mode 100644 index 0000000..1b9b320 --- /dev/null +++ b/CliFx/Exceptions/BaseCliFxException.cs @@ -0,0 +1,43 @@ +using System; + +namespace CliFx.Exceptions +{ + /// + /// Provides the base functionality for exceptions thrown within CliFx + /// or from one of its commands. + /// + public abstract class BaseCliFxException : Exception + { + /// + /// Whether to show the help text after handling this exception. + /// + public bool ShowHelp { get; } + + /// + /// Whether this exception was constructed with a message. + /// + /// + /// We cannot check against the 'Message' property because it will always return + /// a default message if it was constructed with a null value or is currently null. + /// + public bool HasMessage { get; } + + /// + /// Initializes an instance of . + /// + protected BaseCliFxException(string? message, bool showHelp = false) + : this(message, null, showHelp) + { + } + + /// + /// Initializes an instance of . + /// + protected BaseCliFxException(string? message, Exception? innerException, bool showHelp = false) + : base(message, innerException) + { + HasMessage = string.IsNullOrWhiteSpace(message) ? false : true; + ShowHelp = showHelp; + } + } +} \ No newline at end of file diff --git a/CliFx/Exceptions/CliFxException.cs b/CliFx/Exceptions/CliFxException.cs index 47eaa9b..ca36ca5 100644 --- a/CliFx/Exceptions/CliFxException.cs +++ b/CliFx/Exceptions/CliFxException.cs @@ -9,21 +9,21 @@ namespace CliFx.Exceptions /// /// Domain exception thrown within CliFx. /// - public partial class CliFxException : Exception + public partial class CliFxException : BaseCliFxException { /// /// Initializes an instance of . /// - public CliFxException(string? message) - : base(message) + public CliFxException(string? message, bool showHelp = false) + : base(message, showHelp) { } /// /// Initializes an instance of . /// - public CliFxException(string? message, Exception? innerException) - : base(message, innerException) + public CliFxException(string? message, Exception? innerException, bool showHelp = false) + : base(message, innerException, showHelp) { } } diff --git a/CliFx/Exceptions/CommandException.cs b/CliFx/Exceptions/CommandException.cs index f43b4e9..8d9b4fb 100644 --- a/CliFx/Exceptions/CommandException.cs +++ b/CliFx/Exceptions/CommandException.cs @@ -7,7 +7,7 @@ namespace CliFx.Exceptions /// Use this exception if you want to report an error that occured during execution of a command. /// This exception also allows specifying exit code which will be returned to the calling process. /// - public class CommandException : Exception + public class CommandException : BaseCliFxException { private const int DefaultExitCode = -100; @@ -19,8 +19,9 @@ namespace CliFx.Exceptions /// /// Initializes an instance of . /// - public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode) - : base(message, innerException) + public CommandException(string? message, Exception? innerException, + int exitCode = DefaultExitCode, bool showHelp = false) + : base(message, innerException, showHelp) { ExitCode = exitCode != 0 ? exitCode @@ -30,16 +31,16 @@ namespace CliFx.Exceptions /// /// Initializes an instance of . /// - public CommandException(string? message, int exitCode = DefaultExitCode) - : this(message, null, exitCode) + public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false) + : this(message, null, exitCode, showHelp) { } /// /// Initializes an instance of . /// - public CommandException(int exitCode = DefaultExitCode) - : this(null, exitCode) + public CommandException(int exitCode = DefaultExitCode, bool showHelp = false) + : this(null, exitCode, showHelp) { } } diff --git a/Readme.md b/Readme.md index 8f6796d..d877a85 100644 --- a/Readme.md +++ b/Readme.md @@ -430,6 +430,32 @@ Division by zero is not supported. 1337 ``` +You can use the `showHelp` parameter to choose whether to show the help text after handling an exception. For example, you can tell CliFx to show `ExampleCommand`'s help text upon an error like this: + +```c# +[Command] +public class ExampleCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + throw new CommandException(showHelp: true); + } +} +``` + +To display an error message before the help text, throw the `CommandException` like this: + +```c# +[Command] +public class ExampleCommand : ICommand +{ + public ValueTask ExecuteAsync(IConsole console) + { + throw new CommandException("My custom error message.", showHelp: true); + } +} +``` + ### Graceful cancellation It is possible to gracefully cancel execution of a command and preform any necessary cleanup. By default an app gets forcefully killed when it receives an interrupt signal (Ctrl+C or Ctrl+Break), but you can easily override this behavior. @@ -670,4 +696,4 @@ Frequency=3124994 Hz, Resolution=320.0006 ns, Timer=TSC ## Etymology -CliFx is made out of "Cli" for "Command Line Interface" and "Fx" for "Framework". It's pronounced as "cliff ex". \ No newline at end of file +CliFx is made out of "Cli" for "Command Line Interface" and "Fx" for "Framework". It's pronounced as "cliff ex".