Show help text on demand (#49)

This commit is contained in:
Domn Werner
2020-04-23 00:33:12 -07:00
committed by GitHub
parent 1dab27de55
commit a28223fc8b
10 changed files with 309 additions and 28 deletions

View File

@@ -0,0 +1,20 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using System.Threading.Tasks;
namespace CliFx.Tests.Dummy.Commands
{
/// <summary>
/// Demos how to show an error message then help text from an organizational command.
/// </summary>
[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);
}
}

View File

@@ -0,0 +1,18 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using System.Threading.Tasks;
namespace CliFx.Tests.Dummy.Commands
{
/// <summary>
/// Demos how to show help text from an organizational command.
/// </summary>
[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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<string, string>());
var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd();
// Assert
exitCode.Should().NotBe(0);
stdErrData.Should().Contain("Kaput");
stdErrData.Length.Should().BeGreaterThan("Kaput".Length);
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
namespace CliFx.Tests
{

View File

@@ -143,6 +143,50 @@ namespace CliFx
return 0;
}
/// <summary>
/// Handle <see cref="CommandException"/>s differently from the rest because we want to
/// display it different based on whether we are showing the help text or not.
/// </summary>
private int HandleCommandException(IReadOnlyList<string> 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;
}
/// <summary>
/// Handles <see cref="CliFxException"/>s by printing its error message if it has a value.
/// Otherwise, it prints the full stack trace from the exception.
/// </summary>
/// <param name="cliFxException">The exception to handle.</param>
/// <returns>The exception's HResult.</returns>
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;
}
/// <summary>
/// Runs the application with specified command line arguments and environment variables, and returns the exit code.
/// </summary>
@@ -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;
}
}

View File

@@ -0,0 +1,43 @@
using System;
namespace CliFx.Exceptions
{
/// <summary>
/// Provides the base functionality for exceptions thrown within CliFx
/// or from one of its commands.
/// </summary>
public abstract class BaseCliFxException : Exception
{
/// <summary>
/// Whether to show the help text after handling this exception.
/// </summary>
public bool ShowHelp { get; }
/// <summary>
/// Whether this exception was constructed with a message.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool HasMessage { get; }
/// <summary>
/// Initializes an instance of <see cref="BaseCliFxException"/>.
/// </summary>
protected BaseCliFxException(string? message, bool showHelp = false)
: this(message, null, showHelp)
{
}
/// <summary>
/// Initializes an instance of <see cref="BaseCliFxException"/>.
/// </summary>
protected BaseCliFxException(string? message, Exception? innerException, bool showHelp = false)
: base(message, innerException)
{
HasMessage = string.IsNullOrWhiteSpace(message) ? false : true;
ShowHelp = showHelp;
}
}
}

View File

@@ -9,21 +9,21 @@ namespace CliFx.Exceptions
/// <summary>
/// Domain exception thrown within CliFx.
/// </summary>
public partial class CliFxException : Exception
public partial class CliFxException : BaseCliFxException
{
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string? message)
: base(message)
public CliFxException(string? message, bool showHelp = false)
: base(message, showHelp)
{
}
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string? message, Exception? innerException)
: base(message, innerException)
public CliFxException(string? message, Exception? innerException, bool showHelp = false)
: base(message, innerException, showHelp)
{
}
}

View File

@@ -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.
/// </summary>
public class CommandException : Exception
public class CommandException : BaseCliFxException
{
private const int DefaultExitCode = -100;
@@ -19,8 +19,9 @@ namespace CliFx.Exceptions
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
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
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
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)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
public CommandException(int exitCode = DefaultExitCode)
: this(null, exitCode)
public CommandException(int exitCode = DefaultExitCode, bool showHelp = false)
: this(null, exitCode, showHelp)
{
}
}

View File

@@ -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".
CliFx is made out of "Cli" for "Command Line Interface" and "Fx" for "Framework". It's pronounced as "cliff ex".