From 67c3909bbb9681007ff100e9fedd43ea8f79cb93 Mon Sep 17 00:00:00 2001 From: Patrik Svensson Date: Sun, 25 May 2025 00:38:43 +0200 Subject: [PATCH] Add support for required options --- .../Annotations/CommandArgumentAttribute.cs | 2 +- .../Annotations/CommandOptionAttribute.cs | 9 ++++++- .../CommandRuntimeException.cs | 10 +++++++ .../Internal/CommandExecutor.cs | 2 +- .../Internal/CommandValidator.cs | 4 ++- .../Internal/Commands/ExplainCommand.cs | 2 +- .../Internal/Commands/XmlDocCommand.cs | 4 +-- .../Internal/Configuration/TemplateParser.cs | 6 ++--- .../Modelling/CommandModelValidator.cs | 2 +- .../Internal/Modelling/CommandOption.cs | 5 ++-- .../Internal/Modelling/CommandParameter.cs | 4 +-- .../Data/Settings/RequiredOptionsSettings.cs | 7 +++++ .../Unit/CommandAppTests.Options.cs | 27 +++++++++++++++++++ .../Unit/CommandAppTests.Sensitivity.cs | 2 +- 14 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 src/Tests/Spectre.Console.Cli.Tests/Data/Settings/RequiredOptionsSettings.cs create mode 100644 src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Options.cs diff --git a/src/Spectre.Console.Cli/Annotations/CommandArgumentAttribute.cs b/src/Spectre.Console.Cli/Annotations/CommandArgumentAttribute.cs index a3716059..8bf36453 100644 --- a/src/Spectre.Console.Cli/Annotations/CommandArgumentAttribute.cs +++ b/src/Spectre.Console.Cli/Annotations/CommandArgumentAttribute.cs @@ -45,6 +45,6 @@ public sealed class CommandArgumentAttribute : Attribute // Assign the result. Position = position; ValueName = result.Value; - IsRequired = result.Required; + IsRequired = result.IsRequired; } } \ No newline at end of file diff --git a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs index 098c3f46..c43b9270 100644 --- a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs +++ b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs @@ -30,6 +30,11 @@ public sealed class CommandOptionAttribute : Attribute /// public bool ValueIsOptional { get; } + /// + /// Gets a value indicating whether the value is required. + /// + public bool IsRequired { get; } + /// /// Gets or sets a value indicating whether this option is hidden from the help text. /// @@ -39,7 +44,8 @@ public sealed class CommandOptionAttribute : Attribute /// Initializes a new instance of the class. /// /// The option template. - public CommandOptionAttribute(string template) + /// Indicates whether the option is required or not. + public CommandOptionAttribute(string template, bool isRequired = false) { if (template == null) { @@ -54,6 +60,7 @@ public sealed class CommandOptionAttribute : Attribute ShortNames = result.ShortNames; ValueName = result.Value; ValueIsOptional = result.ValueIsOptional; + IsRequired = isRequired; } internal bool IsMatch(string name) diff --git a/src/Spectre.Console.Cli/CommandRuntimeException.cs b/src/Spectre.Console.Cli/CommandRuntimeException.cs index 669842f1..ca94dacf 100644 --- a/src/Spectre.Console.Cli/CommandRuntimeException.cs +++ b/src/Spectre.Console.Cli/CommandRuntimeException.cs @@ -37,6 +37,16 @@ public class CommandRuntimeException : CommandAppException 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) { return new CommandRuntimeException($"Could not find converter for type '{parameter.ParameterType.FullName}'."); diff --git a/src/Spectre.Console.Cli/Internal/CommandExecutor.cs b/src/Spectre.Console.Cli/Internal/CommandExecutor.cs index be599bc2..9d24ad06 100644 --- a/src/Spectre.Console.Cli/Internal/CommandExecutor.cs +++ b/src/Spectre.Console.Cli/Internal/CommandExecutor.cs @@ -103,7 +103,7 @@ internal sealed class CommandExecutor } // 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. configuration.Settings.Console.SafeRender(helpProvider.Write(model, leaf.Command)); diff --git a/src/Spectre.Console.Cli/Internal/CommandValidator.cs b/src/Spectre.Console.Cli/Internal/CommandValidator.cs index c6ce253e..0f523b84 100644 --- a/src/Spectre.Console.Cli/Internal/CommandValidator.cs +++ b/src/Spectre.Console.Cli/Internal/CommandValidator.cs @@ -9,12 +9,14 @@ internal static class CommandValidator { foreach (var parameter in node.Unmapped) { - if (parameter.Required) + if (parameter.IsRequired) { switch (parameter) { case CommandArgument argument: throw CommandRuntimeException.MissingRequiredArgument(node, argument); + case CommandOption option: + throw CommandRuntimeException.MissingRequiredOption(node, option); } } } diff --git a/src/Spectre.Console.Cli/Internal/Commands/ExplainCommand.cs b/src/Spectre.Console.Cli/Internal/Commands/ExplainCommand.cs index c2b1e594..c0de6e52 100644 --- a/src/Spectre.Console.Cli/Internal/Commands/ExplainCommand.cs +++ b/src/Spectre.Console.Cli/Internal/Commands/ExplainCommand.cs @@ -212,7 +212,7 @@ internal sealed class ExplainCommand : Command parameterNode.AddNode(ValueMarkup("Value", commandArgumentParameter.Value)); } - parameterNode.AddNode(ValueMarkup("Required", parameter.Required.ToString())); + parameterNode.AddNode(ValueMarkup("Required", parameter.IsRequired.ToString())); if (parameter.Converter != null) { diff --git a/src/Spectre.Console.Cli/Internal/Commands/XmlDocCommand.cs b/src/Spectre.Console.Cli/Internal/Commands/XmlDocCommand.cs index 7b7592ca..c5de6bbd 100644 --- a/src/Spectre.Console.Cli/Internal/Commands/XmlDocCommand.cs +++ b/src/Spectre.Console.Cli/Internal/Commands/XmlDocCommand.cs @@ -142,7 +142,7 @@ internal sealed class XmlDocCommand : Command var node = document.CreateElement("Argument"); node.SetNullableAttribute("Name", argument.Value); node.SetAttribute("Position", argument.Position.ToString(CultureInfo.InvariantCulture)); - node.SetBooleanAttribute("Required", argument.Required); + node.SetBooleanAttribute("Required", argument.IsRequired); node.SetEnumAttribute("Kind", argument.ParameterKind); node.SetNullableAttribute("ClrType", argument.ParameterType?.FullName); @@ -186,7 +186,7 @@ internal sealed class XmlDocCommand : Command node.SetNullableAttribute("Short", option.ShortNames); node.SetNullableAttribute("Long", option.LongNames); node.SetNullableAttribute("Value", option.ValueName); - node.SetBooleanAttribute("Required", option.Required); + node.SetBooleanAttribute("Required", option.IsRequired); node.SetEnumAttribute("Kind", option.ParameterKind); node.SetNullableAttribute("ClrType", option.ParameterType?.FullName); diff --git a/src/Spectre.Console.Cli/Internal/Configuration/TemplateParser.cs b/src/Spectre.Console.Cli/Internal/Configuration/TemplateParser.cs index 12594728..6eb7c200 100644 --- a/src/Spectre.Console.Cli/Internal/Configuration/TemplateParser.cs +++ b/src/Spectre.Console.Cli/Internal/Configuration/TemplateParser.cs @@ -5,12 +5,12 @@ internal static class TemplateParser public sealed class ArgumentResult { 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; - Required = required; + IsRequired = isRequired; } } diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelValidator.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelValidator.cs index 86624f5f..a98c2529 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelValidator.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelValidator.cs @@ -86,7 +86,7 @@ internal static class CommandModelValidator // Arguments foreach (var argument in arguments) { - if (argument.Required && argument.DefaultValue != null) + if (argument.IsRequired && argument.DefaultValue != null) { throw CommandConfigurationException.RequiredArgumentsCannotHaveDefaultValue(argument); } diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs index 29e113d5..f2d6a9c9 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs @@ -14,8 +14,9 @@ internal sealed class CommandOption : CommandParameter, ICommandOption CommandOptionAttribute optionAttribute, ParameterValueProviderAttribute? valueProvider, IEnumerable validators, DefaultValueAttribute? defaultValue, bool valueIsOptional) - : base(parameterType, parameterKind, property, description, converter, - defaultValue, deconstructor, valueProvider, validators, false, optionAttribute.IsHidden) + : base(parameterType, parameterKind, property, description, converter, + defaultValue, deconstructor, valueProvider, validators, + optionAttribute.IsRequired, optionAttribute.IsHidden) { LongNames = optionAttribute.LongNames; ShortNames = optionAttribute.ShortNames; diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandParameter.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandParameter.cs index e461d50c..1f8f0594 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandParameter.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandParameter.cs @@ -12,7 +12,7 @@ internal abstract class CommandParameter : ICommandParameterInfo, ICommandParame public PairDeconstructorAttribute? PairDeconstructor { get; } public List Validators { get; } public ParameterValueProviderAttribute? ValueProvider { get; } - public bool Required { get; set; } + public bool IsRequired { get; set; } public bool IsHidden { get; } public string PropertyName => Property.Name; @@ -39,7 +39,7 @@ internal abstract class CommandParameter : ICommandParameterInfo, ICommandParame PairDeconstructor = deconstructor; ValueProvider = valueProvider; Validators = new List(validators ?? Array.Empty()); - Required = required; + IsRequired = required; IsHidden = isHidden; } diff --git a/src/Tests/Spectre.Console.Cli.Tests/Data/Settings/RequiredOptionsSettings.cs b/src/Tests/Spectre.Console.Cli.Tests/Data/Settings/RequiredOptionsSettings.cs new file mode 100644 index 00000000..ff14ed95 --- /dev/null +++ b/src/Tests/Spectre.Console.Cli.Tests/Data/Settings/RequiredOptionsSettings.cs @@ -0,0 +1,7 @@ +namespace Spectre.Console.Tests.Data; + +public class RequiredOptionsSettings : CommandSettings +{ + [CommandOption("--foo ", true)] + public string Foo { get; set; } +} \ No newline at end of file diff --git a/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Options.cs b/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Options.cs new file mode 100644 index 00000000..4dde1d3f --- /dev/null +++ b/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Options.cs @@ -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>("test"); + config.PropagateExceptions(); + }); + + // When + var result = Record.Exception(() => fixture.Run("test")); + + // Then + result.ShouldBeOfType() + .And(ex => + ex.Message.ShouldBe("Command 'test' is missing required argument 'foo'.")); + } + } +} diff --git a/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Sensitivity.cs b/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Sensitivity.cs index 87e3ca63..7c819acb 100644 --- a/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Sensitivity.cs +++ b/src/Tests/Spectre.Console.Cli.Tests/Unit/CommandAppTests.Sensitivity.cs @@ -1,6 +1,6 @@ namespace Spectre.Console.Tests.Unit.Cli; -public sealed partial class CommandApptests +public sealed partial class CommandAppTests { [Fact] public void Should_Treat_Commands_As_Case_Sensitive_If_Specified()