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);