Files
Tyrrrz-CliFx/CliFx.Tests/HelpSpecs.cs
T
dualfroz 5e1eff9f24 feat: add command usage examples
Add an Examples string array property to CommandAttribute so command authors can document example invocations. Each example contains only the arguments that follow the command name; the executable and command name are prepended automatically when the example is rendered.

Examples are rendered as extra lines in the USAGE section of the help text. The source generator resolves the values from the attribute and emits them into the command descriptor consumed at runtime.
2026-08-28 10:12:58 +02:00

1060 lines
34 KiB
C#

using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Tests.Utils;
using CliFx.Tests.Utils.Extensions;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;
namespace CliFx.Tests;
public class HelpSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
{
[Fact]
public async Task I_can_request_help_by_passing_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
}
[Fact]
public async Task I_can_request_help_by_passing_the_manually_bound_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand, ICommandWithHelpOption
{
[CommandOption("help", 'h', Description = "Custom help text.")]
public bool IsHelpRequested { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("OPTIONS", "-h", "--help", "Custom help text.");
}
[Fact]
public async Task I_can_request_help_by_passing_no_arguments_if_the_default_command_is_not_registered()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync([], new Dictionary<string, string>());
// Assert
exitCode.Should().NotBe(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
}
[Fact]
public async Task I_can_request_help_for_a_specific_named_command_by_passing_its_name_and_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd", Description = "Description of a named command.")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd child")]
public partial class NamedChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("Description of a named command.");
}
[Fact]
public async Task I_can_request_help_for_a_specific_nested_named_command_by_passing_its_name_and_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd child", Description = "Description of a named child command.")]
public partial class NamedChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "sub", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("Description of a named child command.");
}
[Fact]
public async Task I_can_request_help_by_passing_invalid_arguments()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommand(NoOpCommand.Descriptor)
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync(
["invalid-command", "--invalid-option"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().NotBe(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
var stdErr = FakeConsole.ReadErrorString();
stdErr.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task I_can_request_help_to_see_the_application_title_description_and_version()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.UseConsole(FakeConsole)
.SetTitle("App title")
.SetDescription("App description")
.SetVersion("App version")
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAll("App title", "App description", "App version");
}
[Fact]
public async Task I_can_request_help_to_see_the_command_description()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command(Description = "Description of the default command.")]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("DESCRIPTION", "Description of the default command.");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_a_named_command()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("USAGE", "[command]", "...");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_all_parameters()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public required string Foo { get; set; }
[CommandParameter(1)]
public required string Bar { get; set; }
[CommandParameter(2)]
public required string MultiWordName123 { get; set; }
[CommandParameter(3)]
public required IReadOnlyList<string> Baz { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder("USAGE", "<foo>", "<bar>", "<multi-word-name123>", "<baz...>");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_all_required_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandOption("foo")]
public required string Foo { get; set; }
[CommandOption("bar")]
public string? Bar { get; set; }
[CommandOption("baz")]
public required IReadOnlyList<string> Baz { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder("USAGE", "--foo <value>", "--baz <values...>", "[options]");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_examples_of_a_command()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("foo", Examples = new[] { "--bar 42", "-b 42" })]
public partial class Command : ICommand
{
[CommandOption("bar", 'b')]
public int Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["foo", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder("USAGE", "foo [options]", "foo --bar 42", "foo -b 42", "OPTIONS");
}
[Fact]
public async Task I_do_not_see_any_usage_examples_if_the_command_does_not_define_any()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("foo")]
public partial class Command : ICommand
{
[CommandOption("bar", 'b')]
public int Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["foo", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("foo [options]");
stdOut.Should().NotContain("foo --bar 42");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_inputs()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandParameter(0, Name = "foo", Description = "Description of foo.")]
public string? Foo { get; set; }
[CommandOption("bar", Description = "Description of bar.")]
public string? Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Description of foo.",
"OPTIONS",
"--bar",
"Description of bar."
);
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_inputs_including_inherited_ones()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public abstract class GrandParentCommand : ICommand
{
[CommandParameter(0, Name = "foo", Description = "Description of foo.")]
public string? Foo { get; set; }
public abstract ValueTask ExecuteAsync(IConsole console);
}
public abstract class ParentCommand : GrandParentCommand
{
[CommandOption("bar", Description = "Description of bar.")]
public string? Bar { get; set; }
}
[Command]
public partial class Command : ParentCommand
{
[CommandOption("baz", Description = "Description of baz.")]
public string? Baz { get; set; }
public override ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Description of foo.",
"OPTIONS",
"--baz",
"Description of baz.",
"--bar",
"Description of bar."
);
}
[Fact]
public async Task I_can_request_help_to_see_the_conventional_help_and_version_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"-h",
"--help",
"Shows help text",
"--version",
"Shows version information"
);
}
[Fact]
public async Task I_can_request_help_on_a_named_command_to_see_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd")]
public partial class Command : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("OPTIONS", "-h", "--help", "Shows help text");
stdOut.Should().NotContainAny("--version", "Shows version information");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_valid_values_for_inputs_bound_to_enum_properties()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public CustomEnum Foo { get; set; }
[CommandOption("bar")]
public CustomEnum Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Choices:",
"One",
"Two",
"Three",
"OPTIONS",
"--bar",
"Choices:",
"One",
"Two",
"Three"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_valid_values_for_inputs_bound_to_nullable_enum_properties()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public CustomEnum? Foo { get; set; }
[CommandOption("bar")]
public CustomEnum? Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Choices:",
"One",
"Two",
"Three",
"OPTIONS",
"--bar",
"Choices:",
"One",
"Two",
"Three"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_environment_variables_of_options_that_use_them_as_fallback()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandOption("foo", EnvironmentVariable = "ENV_FOO")]
public CustomEnum Foo { get; set; }
[CommandOption("bar", EnvironmentVariable = "ENV_BAR")]
public CustomEnum Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"--foo",
"Environment variable:",
"ENV_FOO",
"--bar",
"Environment variable:",
"ENV_BAR"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_default_values_of_non_required_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandOption("foo")]
public object? Foo { get; set; } = 42;
[CommandOption("bar")]
public string? Bar { get; set; } = "hello";
[CommandOption("baz")]
public IReadOnlyList<string>? Baz { get; set; } = new[] {"one", "two", "three"};
[CommandOption("qwe")]
public bool Qwe { get; set; } = true;
[CommandOption("qop")]
public int? Qop { get; set; } = 1337;
[CommandOption("zor")]
public TimeSpan Zor { get; set; } = TimeSpan.FromMinutes(123);
[CommandOption("lol")]
public CustomEnum Lol { get; set; } = CustomEnum.Two;
[CommandOption("hmm")]
public required string Hmm { get; set; } = "not printed";
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"--foo",
"Default:",
"42",
"--bar",
"Default:",
"hello",
"--baz",
"Default:",
"one",
"two",
"three",
"--qwe",
"Default:",
"true",
"--qop",
"Default:",
"1337",
"--zor",
"Default:",
"02:03:00",
"--lol",
"Default:",
"Two"
);
stdOut.Should().NotContain("not printed");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_immediate_child_commands()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd1", Description = "Description of one command.")]
public partial class FirstCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2", Description = "Description of another command.")]
public partial class SecondCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child", Description = "Description of another command's child command.")]
public partial class SecondCommandChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd3 child", Description = "Description of an orphaned command.")]
public partial class ThirdCommandChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"COMMANDS",
"cmd1",
"Description of one command.",
"cmd2",
"Description of another command.",
// "cmd2 child" will appear as an immediate command because it does not
// have a more specific parent.
"cmd3 child",
"Description of an orphaned command."
);
// "cmd2 child" will still appear in the list of "cmd2" subcommands,
// but its description will not be visible.
stdOut.Should().NotContain("Description of another command's child command.");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_immediate_grand_child_commands()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd1")]
public partial class FirstCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd1 child1")]
public partial class FirstCommandFirstChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2")]
public partial class SecondCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child11")]
public partial class SecondCommandFirstChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child2")]
public partial class SecondCommandSecondChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"COMMANDS",
"cmd1",
"Subcommands:",
"cmd1 child1",
"cmd2",
"Subcommands:",
"cmd2 child1",
"cmd2 child2"
);
}
[Fact]
public async Task I_can_request_version_information_by_passing_the_conventional_version_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommand(NoOpCommand.Descriptor)
.SetVersion("v6.9")
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--version"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("v6.9");
}
[Fact]
public async Task I_can_request_version_information_by_passing_the_manually_bound_version_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand, ICommandWithVersionOption
{
[CommandOption("version", Description = "Custom version text.")]
public bool IsVersionRequested { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.SetVersion("v1.0")
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--version"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("v1.0");
}
}