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); 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 public partial class ErrorReportingSpecs
{ {
[Fact] [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 // Arrange
await using var stdErr = new MemoryStream(); await using var stdErr = new MemoryStream();
@@ -29,8 +29,10 @@ namespace CliFx.Tests
// Assert // Assert
exitCode.Should().NotBe(0); exitCode.Should().NotBe(0);
stdErrData.Should().Contain("Kaput"); stdErrData.Should().ContainAll(
stdErrData.Length.Should().BeGreaterThan("Kaput".Length); "System.Exception:",
"Kaput", "at",
"CliFx.Tests");
} }
[Fact] [Fact]
@@ -80,5 +82,95 @@ namespace CliFx.Tests
exitCode.Should().NotBe(0); exitCode.Should().NotBe(0);
stdErrData.Should().NotBeEmpty(); 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.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions;
namespace CliFx.Tests namespace CliFx.Tests
{ {

View File

@@ -143,6 +143,50 @@ namespace CliFx
return 0; 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> /// <summary>
/// Runs the application with specified command line arguments and environment variables, and returns the exit code. /// Runs the application with specified command line arguments and environment variables, and returns the exit code.
/// </summary> /// </summary>
@@ -162,21 +206,23 @@ namespace CliFx
HandleHelpOption(applicationSchema, commandLineInput) ?? HandleHelpOption(applicationSchema, commandLineInput) ??
await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables); 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. // 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. // Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions.
var exitCode = HandleCommandException(commandLineArguments, ce);
// Prefer showing message without stack trace on exceptions coming from CliFx or on CommandException return exitCode;
var errorMessage = !string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException) }
? ex.Message catch (CliFxException cfe)
: ex.ToString(); {
var hResult = HandleCliFxException(cfe);
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); return hResult;
}
return ex is CommandException commandException catch (Exception ex)
? commandException.ExitCode {
: ex.HResult; // 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> /// <summary>
/// Domain exception thrown within CliFx. /// Domain exception thrown within CliFx.
/// </summary> /// </summary>
public partial class CliFxException : Exception public partial class CliFxException : BaseCliFxException
{ {
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CliFxException"/>. /// Initializes an instance of <see cref="CliFxException"/>.
/// </summary> /// </summary>
public CliFxException(string? message) public CliFxException(string? message, bool showHelp = false)
: base(message) : base(message, showHelp)
{ {
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CliFxException"/>. /// Initializes an instance of <see cref="CliFxException"/>.
/// </summary> /// </summary>
public CliFxException(string? message, Exception? innerException) public CliFxException(string? message, Exception? innerException, bool showHelp = false)
: base(message, innerException) : 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. /// 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. /// This exception also allows specifying exit code which will be returned to the calling process.
/// </summary> /// </summary>
public class CommandException : Exception public class CommandException : BaseCliFxException
{ {
private const int DefaultExitCode = -100; private const int DefaultExitCode = -100;
@@ -19,8 +19,9 @@ namespace CliFx.Exceptions
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode) public CommandException(string? message, Exception? innerException,
: base(message, innerException) int exitCode = DefaultExitCode, bool showHelp = false)
: base(message, innerException, showHelp)
{ {
ExitCode = exitCode != 0 ExitCode = exitCode != 0
? exitCode ? exitCode
@@ -30,16 +31,16 @@ namespace CliFx.Exceptions
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
public CommandException(string? message, int exitCode = DefaultExitCode) public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false)
: this(message, null, exitCode) : this(message, null, exitCode, showHelp)
{ {
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
public CommandException(int exitCode = DefaultExitCode) public CommandException(int exitCode = DefaultExitCode, bool showHelp = false)
: this(null, exitCode) : this(null, exitCode, showHelp)
{ {
} }
} }

View File

@@ -430,6 +430,32 @@ Division by zero is not supported.
1337 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 ### 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. 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.