diff --git a/README.md b/README.md index faf53b96..c2822ec0 100644 --- a/README.md +++ b/README.md @@ -1334,6 +1334,95 @@ public class ValidationCliCommand context.ShowValues(); } } +``` + +## Mutually Exclusive Options Validation + +**Mutually exclusive options** are options that belong to the same group but cannot be used together. +At most one option from the group can be specified. +**Example**: You shouldn’t ask a CLI to output both JSON and XML at the same time. + +To declare that options are mutually exclusive, assign them the same `GroupName`. + +```csharp +[CliCommand(Description = "Display different file formats")] +public class FormatCommand +{ + [CliOption(GroupName = "Format", Description = "Output as XML")] + public bool Xml { get; set; } + + [CliOption(GroupName = "Format", Description = "Output as JSON")] + public bool Json { get; set; } + + [CliOption(Description = "Source file name")] + public FileInfo Source { get; set; } + + [CliOption(Description = "Verbosity level", Required = false)] + public string Verbose { get; set; } + + public void Run(CliContext context) + { + context.ShowValues(); + } +} +``` + +In the above example, the `Xml` and `Json` options are mutually exclusive because they share the same `GroupName` value `"Format"`. + +If the user tries to specify both options together, the CLI will display an error: + +```console +mytool --xml --json +``` + +> **Error:** +> Options in group 'Format' are mutually exclusive. You must specify only one of: `-x|--xml`, `-j|--json` + +--- + +### Required Groups + +Sometimes you want to enforce that **exactly one option from a group must be specified**. This is done by setting the `RequiredGroups` property in `CliCommandAttribute` and listing the group names. + +```csharp +[CliCommand(RequiredGroups = new[] { "auth" })] +public class ReportCommand +{ + // Group 1: Output format (mutually exclusive) + [CliOption(GroupName = "output-format")] + public bool Json { get; set; } + + [CliOption(GroupName = "output-format")] + public bool Xml { get; set; } + + // Group 2: Authentication (required group) + [CliOption(GroupName = "auth")] + public string ApiKey { get; set; } + + [CliOption(GroupName = "auth")] + public string Token { get; set; } + + public void Run(CliContext context) + { + context.ShowValues(); + } +} +``` + +In this example: +- The `"output-format"` group is **mutually exclusive**: you can choose JSON or XML, but not both. +- The `"auth"` group is **required**: you must specify exactly one of `--apikey` or `--token`. + +If the user does not provide any option from the required group, the CLI will display an error: + +```console +mytool --json +``` + +> **Error:** +> You must specify exactly one option in required group 'auth': `-ak|--api-key`, `-t|--token` + + ``` ## Completions diff --git a/src/DotMake.CommandLine.Shared/Attributes/CliCommandAttribute.cs b/src/DotMake.CommandLine.Shared/Attributes/CliCommandAttribute.cs index 25d1ebd2..6b9ee1d3 100644 --- a/src/DotMake.CommandLine.Shared/Attributes/CliCommandAttribute.cs +++ b/src/DotMake.CommandLine.Shared/Attributes/CliCommandAttribute.cs @@ -234,7 +234,11 @@ public class CliCommandAttribute : Attribute /// Default is (e.g. -o). /// public CliNamePrefixConvention ShortFormPrefixConvention { get; set; } = CliNamePrefixConvention.SingleHyphen; - + + /// + /// Gets or sets the list of the required mutually exclusive option groups. + /// + public string[] RequiredGroups { get; set; } internal static CliCommandAttribute Default { get; } = new(); } } diff --git a/src/DotMake.CommandLine.Shared/Attributes/CliOptionAttribute.cs b/src/DotMake.CommandLine.Shared/Attributes/CliOptionAttribute.cs index 83e82418..d131437b 100644 --- a/src/DotMake.CommandLine.Shared/Attributes/CliOptionAttribute.cs +++ b/src/DotMake.CommandLine.Shared/Attributes/CliOptionAttribute.cs @@ -233,6 +233,11 @@ public class CliOptionAttribute : Attribute /// public bool AllowMultipleArgumentsPerToken { get; set; } + /// + /// Gets or sets the name of the group for mutually exclusive options. + /// + public string GroupName { get; set; } + internal static CliOptionAttribute Default { get; } = new CliOptionAttribute(); } } diff --git a/src/DotMake.CommandLine.SourceGeneration/Inputs/CliCommandInput.cs b/src/DotMake.CommandLine.SourceGeneration/Inputs/CliCommandInput.cs index c0eb691d..867e7046 100644 --- a/src/DotMake.CommandLine.SourceGeneration/Inputs/CliCommandInput.cs +++ b/src/DotMake.CommandLine.SourceGeneration/Inputs/CliCommandInput.cs @@ -37,6 +37,10 @@ public CliCommandInput(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attr ShortFormAutoGenerate = (CliNameAutoGenerate)shortFormAutoGenerateValue; if (AttributeArguments.TryGetValue(nameof(CliCommandAttribute.ShortFormPrefixConvention), out var shortFormPrefixValue)) ShortFormPrefixConvention = (CliNamePrefixConvention)shortFormPrefixValue; + if (AttributeArguments.TryGetValues(nameof(CliCommandAttribute.RequiredGroups), out var requiredGroupsValue)) + RequiredGroups = requiredGroupsValue.OfType().ToArray(); + else + RequiredGroups = Array.Empty(); ParentSymbol = (Parent != null) ? Parent.Symbol //Nested class for sub-command @@ -120,6 +124,11 @@ public CliCommandInput(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attr } } + // Build Mutual Option Groups + Groups = Options.Where(o => !string.IsNullOrEmpty(o.GroupName)) + .GroupBy(o => o.GroupName!). + ToDictionary(g => g.Key, g => g.ToList()); + //Disable warning for missing handler, instead show help when no handler //if (Handler == null) // AddDiagnostic(DiagnosticDescriptors.WarningClassHasNotHandler, false, CliCommandHandlerInfo.DiagnosticName); @@ -205,6 +214,11 @@ public static CliCommandInput From(GeneratorAttributeSyntaxContext attributeSynt public IReadOnlyList CommandAccessors => commandAccessors; private readonly List commandAccessors = new(); + public Dictionary> Groups { get; } + = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList RequiredGroups { get; } + public sealed override void Analyze(ISymbol symbol) { if ((Symbol.DeclaredAccessibility != Accessibility.Public && Symbol.DeclaredAccessibility != Accessibility.Internal) diff --git a/src/DotMake.CommandLine.SourceGeneration/Inputs/CliOptionInput.cs b/src/DotMake.CommandLine.SourceGeneration/Inputs/CliOptionInput.cs index d13d20d4..6fc8262d 100644 --- a/src/DotMake.CommandLine.SourceGeneration/Inputs/CliOptionInput.cs +++ b/src/DotMake.CommandLine.SourceGeneration/Inputs/CliOptionInput.cs @@ -37,6 +37,12 @@ public CliOptionInput(ISymbol symbol, SyntaxNode syntaxNode, AttributeData attri ? propertyDeclarationSyntax.Initializer.Value.IsKind(SyntaxKind.NullLiteralExpression) || propertyDeclarationSyntax.Initializer.Value.IsKind(SyntaxKind.SuppressNullableWarningExpression) : Symbol.Type.IsReferenceType || Symbol.IsRequired; + + if (AttributeArguments.TryGetValue(nameof(CliOptionAttribute.GroupName), out var groupName)) + { + GroupName = groupName as string; + Required = false; + } } public CliOptionInput(GeneratorAttributeSyntaxContext attributeSyntaxContext) @@ -53,16 +59,16 @@ public CliOptionInput(GeneratorAttributeSyntaxContext attributeSyntaxContext) public CliCommandInput Parent { get; } - public AttributeArguments AttributeArguments { get; } public int Order { get; } public bool Required { get; } - public CliArgumentParserInput ArgumentParser { get; } + public string GroupName { get; private set; } + public sealed override void Analyze(ISymbol symbol) { if ((symbol.DeclaredAccessibility != Accessibility.Public && symbol.DeclaredAccessibility != Accessibility.Internal) diff --git a/src/DotMake.CommandLine.SourceGeneration/Outputs/CliCommandOutput.cs b/src/DotMake.CommandLine.SourceGeneration/Outputs/CliCommandOutput.cs index b6ee1c3e..d79b3af2 100644 --- a/src/DotMake.CommandLine.SourceGeneration/Outputs/CliCommandOutput.cs +++ b/src/DotMake.CommandLine.SourceGeneration/Outputs/CliCommandOutput.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using DotMake.CommandLine.SourceGeneration.Inputs; @@ -211,6 +212,7 @@ Instead we will use Bind method to get cached definition instance and call GetCo sb.AppendLine($"{varRootCommand}?.Add({varDirective});"); } + var optionVarMap = new Dictionary(); for (var index = 0; index < optionsWithoutProblem.Length; index++) { sb.AppendLine(); @@ -218,10 +220,13 @@ Instead we will use Bind method to get cached definition instance and call GetCo var cliOptionInput = optionsWithoutProblem[index]; var cliOptionOutput = new CliOptionOutput(cliOptionInput); var varOption = $"option{index}"; + optionVarMap[cliOptionInput] = varOption; cliOptionOutput.AppendCSharpCreateString(sb, varOption, varNamer, varBindingContext); sb.AppendLine($"{varCommand}.Add({varOption});"); } + RenderValidators(sb, Input, optionVarMap, varCommand); + for (var index = 0; index < argumentsWithoutProblem.Length; index++) { sb.AppendLine(); @@ -426,5 +431,26 @@ public void AppendCSharpCreateString(CodeStringBuilder sb, string varName, strin sb.AppendLine($"{varNamer}.AddAlias({varName}, \"{Input.Symbol.Name}\", \"{alias}\");"); } } + + public void RenderValidators(CodeStringBuilder sb, CliCommandInput input, Dictionary optionVarMap, string varCommand) + { + foreach (var kvp in input.Groups) + { + var groupName = kvp.Key; + var options = kvp.Value; + var isRequired = Input.RequiredGroups.Contains(groupName).ToString().ToLower(); + var requiredHint = isRequired == "true" ? "required" : string.Empty; + + sb.AppendLine(); + sb.AppendLine($"// Validator for {requiredHint} mutually exclusive option group: '{groupName}'"); + + var optionVars = string.Join(", ", options.Select(o => optionVarMap[o])); + + sb.AppendLine( + $"global::DotMake.CommandLine.CliValidationExtensions.AddMutualValidator(" + + $"{varCommand}, \"{groupName}\", {isRequired}, {optionVars});" + ); + } + } } } diff --git a/src/DotMake.CommandLine.SourceGeneration/Outputs/CliOptionOutput.cs b/src/DotMake.CommandLine.SourceGeneration/Outputs/CliOptionOutput.cs index be655bc4..2d591516 100644 --- a/src/DotMake.CommandLine.SourceGeneration/Outputs/CliOptionOutput.cs +++ b/src/DotMake.CommandLine.SourceGeneration/Outputs/CliOptionOutput.cs @@ -1,9 +1,11 @@ -using System.Collections.Generic; using DotMake.CommandLine.SourceGeneration.Inputs; using DotMake.CommandLine.SourceGeneration.Util; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.Linq; namespace DotMake.CommandLine.SourceGeneration.Outputs { @@ -50,10 +52,25 @@ public void AppendCSharpCreateString(CodeStringBuilder sb, string varName, strin if (!PropertyMappings.TryGetValue(kvp.Key, out var propertyName)) propertyName = kvp.Key; + string valueString; if (Input.AttributeArguments.TryGetResourceProperty(kvp.Key, out var resourceProperty)) - sb.AppendLine($"{propertyName} = {resourceProperty.ToReferenceString()},"); + valueString = resourceProperty.ToReferenceString(); else - sb.AppendLine($"{propertyName} = {kvp.Value.ToCSharpString()},"); + valueString = kvp.Value.ToCSharpString(); + + // Append groupName to Description in help output + if (kvp.Key == nameof(CliOptionAttribute.Description) && !string.IsNullOrEmpty(Input.GroupName)) + { + var group = Input.GroupName; + IReadOnlyList parentRequiredGroups = Input.Parent?.RequiredGroups ?? Array.Empty(); + var isRequiredGroup = parentRequiredGroups.Any(r => string.Equals(r, group, StringComparison.OrdinalIgnoreCase)); + var suffixLiteral = isRequiredGroup + ? $"\" [Group: '{group}', required]\"" + : $"\" [Group: '{group}']\""; + valueString = $"{valueString} + {suffixLiteral}"; + } + + sb.AppendLine($"{propertyName} = {valueString},"); break; case nameof(CliOptionAttribute.Arity): //Note that ArgumentArity from System.CommandLine is not an enum (a struct) diff --git a/src/DotMake.CommandLine/CliValidationExtensions.cs b/src/DotMake.CommandLine/CliValidationExtensions.cs index 1f3ddbae..5646e7f4 100644 --- a/src/DotMake.CommandLine/CliValidationExtensions.cs +++ b/src/DotMake.CommandLine/CliValidationExtensions.cs @@ -2,6 +2,7 @@ using System.CommandLine; using System.CommandLine.Parsing; using System.IO; +using System.Linq; using System.Text.RegularExpressions; namespace DotMake.CommandLine @@ -90,7 +91,52 @@ public static void AddValidator(this Argument argument, string validationPattern ValidateArgumentResult(result, validationResult => RegularExpression(validationResult, validationPattern, validationMessage)) ); } + + /// + /// Adds a validator to that enforces mutual-exclusion for the provided options. + /// If is true, at least one option must be specified. + /// + public static void AddMutualValidator(this Command command, string groupName, bool required, params Option[] options) + { + if (command == null) throw new ArgumentNullException(nameof(command)); + if (options == null || options.Length < 2) return; + + command.Validators.Add(result => + { + var providedCount = options.Count(opt => result.GetResult(opt) is not null); + + // Build display strings for error messages + var optionDisplays = options.Select(opt => + string.Join("|", (opt.Aliases ?? Array.Empty()) + .Concat(new[] { opt.Name }) + .OrderBy(x => x.Length)) + ); + var joinedDisplays = string.Join(", ", optionDisplays); + if (required) + { + if (providedCount == 0) + { + var resource = "You must specify exactly one option in required group '{0}': {1}"; + result.AddError(string.Format(resource, groupName, joinedDisplays)); + } + else if (providedCount > 1) + { + var resource = "Options in required group '{0}' are mutually exclusive. You must specify exactly one of: {1}"; + result.AddError(string.Format(resource, groupName, joinedDisplays)); + } + } + else + { + if (providedCount > 1) + { + var resource = "Options in group '{0}' are mutually exclusive. You must specify only one of: {1}"; + result.AddError(string.Format(resource, groupName, joinedDisplays)); + } + } + }); + } + private class ValidationResult { public ValidationResult(string value) diff --git a/src/TestApp/Commands/MutualExclusiveCliCommand.cs b/src/TestApp/Commands/MutualExclusiveCliCommand.cs new file mode 100644 index 00000000..91803388 --- /dev/null +++ b/src/TestApp/Commands/MutualExclusiveCliCommand.cs @@ -0,0 +1,91 @@ +#pragma warning disable CS1591 +using DotMake.CommandLine; + + +namespace TestApp.Commands +{ + + // This class demonstrates the use of mutually exclusive options in a CLI application. + + [CliCommand] + public class MutualExclusiveCliCommand + { + + #region Report Command + + // The ReportCommand allows users to generate reports in different formats. + // It enforces two rules: + // 1. Output format options (--json, --xml) are mutually exclusive. + // 2. Authentication options (--apikey, --token) are required: exactly one must be provided. + [CliCommand( + Description = "Generate a report with a chosen output format. Requires authentication via API key or token.", + RequiredGroups = new[] { "auth" } + )] + public class ReportCommand + { + // Group 1: Output format (mutually exclusive) + [CliOption( + GroupName = "output-format", + Description = "Output the report in JSON format." + )] + public bool Json { get; set; } + + [CliOption( + GroupName = "output-format", + Description = "Output the report in XML format." + )] + public bool Xml { get; set; } + + // Group 2: Authentication (required group) + [CliOption( + GroupName = "auth", + Description = "Authenticate using an API key (exactly one of --apikey or --token must be provided)." + )] + public string ApiKey { get; set; } + + [CliOption( + GroupName = "auth", + Description = "Authenticate using a bearer token (exactly one of --apikey or --token must be provided)." + )] + public string Token { get; set; } + + // Execution logic + public void Run(CliContext context) + { + // Display parsed values for demonstration purposes + context.ShowValues(); + + // Example: Here you could add logic to generate the report + // based on the selected format and authentication method. + } + } + #endregion + + #region Format Command + + // The FormatCommand demonstrates basic usage of Mutually exclusive grouped options. + [CliCommand(Description = "Display different file formats")] + public class FormatCommand + { + //Group Format mutually exclusive + [CliOption(GroupName = "Format", Description = "Output as XML")] + public bool Xml { get; set; } + + [CliOption(GroupName = "Format", Description = "Output as JSON")] + public bool Json { get; set; } + + [CliOption(Description = "Source file name")] + public FileInfo Source { get; set; } + + [CliOption(Description = "Verbosity level", Required = false)] + public string Verbose { get; set; } + + public void Run(CliContext context) + { + context.ShowValues(); + } + } + + #endregion + } +} diff --git a/src/TestApp/Program.cs b/src/TestApp/Program.cs index 866f97bf..93cd26c5 100644 --- a/src/TestApp/Program.cs +++ b/src/TestApp/Program.cs @@ -39,7 +39,7 @@ //Cli.Run(args); //Cli.Run(args); //Cli.Run(args); - +//Cli.Run(args); //Using Cli.RunAsync: //await Cli.RunAsync(args); //await Cli.RunAsync(args);