Add support for required options

This commit is contained in:
Patrik Svensson
2025-05-25 00:38:43 +02:00
committed by Patrik Svensson
parent d836ad1805
commit 67c3909bbb
14 changed files with 70 additions and 16 deletions

View File

@ -45,6 +45,6 @@ public sealed class CommandArgumentAttribute : Attribute
// Assign the result. // Assign the result.
Position = position; Position = position;
ValueName = result.Value; ValueName = result.Value;
IsRequired = result.Required; IsRequired = result.IsRequired;
} }
} }

View File

@ -30,6 +30,11 @@ public sealed class CommandOptionAttribute : Attribute
/// </summary> /// </summary>
public bool ValueIsOptional { get; } public bool ValueIsOptional { get; }
/// <summary>
/// Gets a value indicating whether the value is required.
/// </summary>
public bool IsRequired { get; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether this option is hidden from the help text. /// Gets or sets a value indicating whether this option is hidden from the help text.
/// </summary> /// </summary>
@ -39,7 +44,8 @@ public sealed class CommandOptionAttribute : Attribute
/// Initializes a new instance of the <see cref="CommandOptionAttribute"/> class. /// Initializes a new instance of the <see cref="CommandOptionAttribute"/> class.
/// </summary> /// </summary>
/// <param name="template">The option template.</param> /// <param name="template">The option template.</param>
public CommandOptionAttribute(string template) /// <param name="isRequired">Indicates whether the option is required or not.</param>
public CommandOptionAttribute(string template, bool isRequired = false)
{ {
if (template == null) if (template == null)
{ {
@ -54,6 +60,7 @@ public sealed class CommandOptionAttribute : Attribute
ShortNames = result.ShortNames; ShortNames = result.ShortNames;
ValueName = result.Value; ValueName = result.Value;
ValueIsOptional = result.ValueIsOptional; ValueIsOptional = result.ValueIsOptional;
IsRequired = isRequired;
} }
internal bool IsMatch(string name) internal bool IsMatch(string name)

View File

@ -37,6 +37,16 @@ public class CommandRuntimeException : CommandAppException
return new CommandRuntimeException($"Command '{node.Command.Name}' is missing required argument '{argument.Value}'."); return new CommandRuntimeException($"Command '{node.Command.Name}' is missing required argument '{argument.Value}'.");
} }
internal static CommandRuntimeException MissingRequiredOption(CommandTree node, CommandOption option)
{
if (node.Command.Name == CliConstants.DefaultCommandName)
{
return new CommandRuntimeException($"Missing required option '{option.GetOptionName()}'.");
}
return new CommandRuntimeException($"Command '{node.Command.Name}' is missing required argument '{option.GetOptionName()}'.");
}
internal static CommandRuntimeException NoConverterFound(CommandParameter parameter) internal static CommandRuntimeException NoConverterFound(CommandParameter parameter)
{ {
return new CommandRuntimeException($"Could not find converter for type '{parameter.ParameterType.FullName}'."); return new CommandRuntimeException($"Could not find converter for type '{parameter.ParameterType.FullName}'.");

View File

@ -103,7 +103,7 @@ internal sealed class CommandExecutor
} }
// Is this the default and is it called without arguments when there are required arguments? // Is this the default and is it called without arguments when there are required arguments?
if (leaf.Command.IsDefaultCommand && arguments.Count == 0 && leaf.Command.Parameters.Any(p => p.Required)) if (leaf.Command.IsDefaultCommand && arguments.Count == 0 && leaf.Command.Parameters.Any(p => p.IsRequired))
{ {
// Display help for default command. // Display help for default command.
configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command));

View File

@ -9,12 +9,14 @@ internal static class CommandValidator
{ {
foreach (var parameter in node.Unmapped) foreach (var parameter in node.Unmapped)
{ {
if (parameter.Required) if (parameter.IsRequired)
{ {
switch (parameter) switch (parameter)
{ {
case CommandArgument argument: case CommandArgument argument:
throw CommandRuntimeException.MissingRequiredArgument(node, argument); throw CommandRuntimeException.MissingRequiredArgument(node, argument);
case CommandOption option:
throw CommandRuntimeException.MissingRequiredOption(node, option);
} }
} }
} }

View File

@ -212,7 +212,7 @@ internal sealed class ExplainCommand : Command<ExplainCommand.Settings>
parameterNode.AddNode(ValueMarkup("Value", commandArgumentParameter.Value)); parameterNode.AddNode(ValueMarkup("Value", commandArgumentParameter.Value));
} }
parameterNode.AddNode(ValueMarkup("Required", parameter.Required.ToString())); parameterNode.AddNode(ValueMarkup("Required", parameter.IsRequired.ToString()));
if (parameter.Converter != null) if (parameter.Converter != null)
{ {

View File

@ -142,7 +142,7 @@ internal sealed class XmlDocCommand : Command<XmlDocCommand.Settings>
var node = document.CreateElement("Argument"); var node = document.CreateElement("Argument");
node.SetNullableAttribute("Name", argument.Value); node.SetNullableAttribute("Name", argument.Value);
node.SetAttribute("Position", argument.Position.ToString(CultureInfo.InvariantCulture)); node.SetAttribute("Position", argument.Position.ToString(CultureInfo.InvariantCulture));
node.SetBooleanAttribute("Required", argument.Required); node.SetBooleanAttribute("Required", argument.IsRequired);
node.SetEnumAttribute("Kind", argument.ParameterKind); node.SetEnumAttribute("Kind", argument.ParameterKind);
node.SetNullableAttribute("ClrType", argument.ParameterType?.FullName); node.SetNullableAttribute("ClrType", argument.ParameterType?.FullName);
@ -186,7 +186,7 @@ internal sealed class XmlDocCommand : Command<XmlDocCommand.Settings>
node.SetNullableAttribute("Short", option.ShortNames); node.SetNullableAttribute("Short", option.ShortNames);
node.SetNullableAttribute("Long", option.LongNames); node.SetNullableAttribute("Long", option.LongNames);
node.SetNullableAttribute("Value", option.ValueName); node.SetNullableAttribute("Value", option.ValueName);
node.SetBooleanAttribute("Required", option.Required); node.SetBooleanAttribute("Required", option.IsRequired);
node.SetEnumAttribute("Kind", option.ParameterKind); node.SetEnumAttribute("Kind", option.ParameterKind);
node.SetNullableAttribute("ClrType", option.ParameterType?.FullName); node.SetNullableAttribute("ClrType", option.ParameterType?.FullName);

View File

@ -5,12 +5,12 @@ internal static class TemplateParser
public sealed class ArgumentResult public sealed class ArgumentResult
{ {
public string Value { get; set; } public string Value { get; set; }
public bool Required { get; set; } public bool IsRequired { get; set; }
public ArgumentResult(string value, bool required) public ArgumentResult(string value, bool isRequired)
{ {
Value = value; Value = value;
Required = required; IsRequired = isRequired;
} }
} }

View File

@ -86,7 +86,7 @@ internal static class CommandModelValidator
// Arguments // Arguments
foreach (var argument in arguments) foreach (var argument in arguments)
{ {
if (argument.Required && argument.DefaultValue != null) if (argument.IsRequired && argument.DefaultValue != null)
{ {
throw CommandConfigurationException.RequiredArgumentsCannotHaveDefaultValue(argument); throw CommandConfigurationException.RequiredArgumentsCannotHaveDefaultValue(argument);
} }

View File

@ -15,7 +15,8 @@ internal sealed class CommandOption : CommandParameter, ICommandOption
IEnumerable<ParameterValidationAttribute> validators, IEnumerable<ParameterValidationAttribute> validators,
DefaultValueAttribute? defaultValue, bool valueIsOptional) DefaultValueAttribute? defaultValue, bool valueIsOptional)
: base(parameterType, parameterKind, property, description, converter, : base(parameterType, parameterKind, property, description, converter,
defaultValue, deconstructor, valueProvider, validators, false, optionAttribute.IsHidden) defaultValue, deconstructor, valueProvider, validators,
optionAttribute.IsRequired, optionAttribute.IsHidden)
{ {
LongNames = optionAttribute.LongNames; LongNames = optionAttribute.LongNames;
ShortNames = optionAttribute.ShortNames; ShortNames = optionAttribute.ShortNames;

View File

@ -12,7 +12,7 @@ internal abstract class CommandParameter : ICommandParameterInfo, ICommandParame
public PairDeconstructorAttribute? PairDeconstructor { get; } public PairDeconstructorAttribute? PairDeconstructor { get; }
public List<ParameterValidationAttribute> Validators { get; } public List<ParameterValidationAttribute> Validators { get; }
public ParameterValueProviderAttribute? ValueProvider { get; } public ParameterValueProviderAttribute? ValueProvider { get; }
public bool Required { get; set; } public bool IsRequired { get; set; }
public bool IsHidden { get; } public bool IsHidden { get; }
public string PropertyName => Property.Name; public string PropertyName => Property.Name;
@ -39,7 +39,7 @@ internal abstract class CommandParameter : ICommandParameterInfo, ICommandParame
PairDeconstructor = deconstructor; PairDeconstructor = deconstructor;
ValueProvider = valueProvider; ValueProvider = valueProvider;
Validators = new List<ParameterValidationAttribute>(validators ?? Array.Empty<ParameterValidationAttribute>()); Validators = new List<ParameterValidationAttribute>(validators ?? Array.Empty<ParameterValidationAttribute>());
Required = required; IsRequired = required;
IsHidden = isHidden; IsHidden = isHidden;
} }

View File

@ -0,0 +1,7 @@
namespace Spectre.Console.Tests.Data;
public class RequiredOptionsSettings : CommandSettings
{
[CommandOption("--foo <VALUE>", true)]
public string Foo { get; set; }
}

View File

@ -0,0 +1,27 @@
namespace Spectre.Console.Tests.Unit.Cli;
public sealed partial class CommandAppTests
{
public sealed class Options
{
[Fact]
public void Should_Throw_If_Required_Option_Is_Missing()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(config =>
{
config.AddCommand<GenericCommand<RequiredOptionsSettings>>("test");
config.PropagateExceptions();
});
// When
var result = Record.Exception(() => fixture.Run("test"));
// Then
result.ShouldBeOfType<CommandRuntimeException>()
.And(ex =>
ex.Message.ShouldBe("Command 'test' is missing required argument 'foo'."));
}
}
}

View File

@ -1,6 +1,6 @@
namespace Spectre.Console.Tests.Unit.Cli; namespace Spectre.Console.Tests.Unit.Cli;
public sealed partial class CommandApptests public sealed partial class CommandAppTests
{ {
[Fact] [Fact]
public void Should_Treat_Commands_As_Case_Sensitive_If_Specified() public void Should_Treat_Commands_As_Case_Sensitive_If_Specified()