Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ public class CliCommandAttribute : Attribute
/// <para>Default is <see cref="CliNamePrefixConvention.SingleHyphen"/> (e.g. <c>-o</c>).</para>
/// </summary>
public CliNamePrefixConvention ShortFormPrefixConvention { get; set; } = CliNamePrefixConvention.SingleHyphen;


/// <summary>
/// Gets or sets the list of the required mutually exclusive option groups.
/// </summary>
public string[] RequiredGroups { get; set; }
internal static CliCommandAttribute Default { get; } = new();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ public class CliOptionAttribute : Attribute
/// </summary>
public bool AllowMultipleArgumentsPerToken { get; set; }

/// <summary>
/// Gets or sets the name of the group for mutually exclusive options.
/// </summary>
public string GroupName { get; set; }

internal static CliOptionAttribute Default { get; } = new CliOptionAttribute();
}
}
14 changes: 14 additions & 0 deletions src/DotMake.CommandLine.SourceGeneration/Inputs/CliCommandInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>().ToArray();
else
RequiredGroups = Array.Empty<string>();

ParentSymbol = (Parent != null)
? Parent.Symbol //Nested class for sub-command
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -205,6 +214,11 @@ public static CliCommandInput From(GeneratorAttributeSyntaxContext attributeSynt
public IReadOnlyList<CliCommandAccessorInput> CommandAccessors => commandAccessors;
private readonly List<CliCommandAccessorInput> commandAccessors = new();

public Dictionary<string, List<CliOptionInput>> Groups { get; }
= new(StringComparer.OrdinalIgnoreCase);

public IReadOnlyList<string> RequiredGroups { get; }

public sealed override void Analyze(ISymbol symbol)
{
if ((Symbol.DeclaredAccessibility != Accessibility.Public && Symbol.DeclaredAccessibility != Accessibility.Internal)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DotMake.CommandLine.SourceGeneration.Inputs;
Expand Down Expand Up @@ -211,17 +212,21 @@ Instead we will use Bind method to get cached definition instance and call GetCo
sb.AppendLine($"{varRootCommand}?.Add({varDirective});");
}

var optionVarMap = new Dictionary<CliOptionInput, string>();
for (var index = 0; index < optionsWithoutProblem.Length; index++)
{
sb.AppendLine();

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();
Expand Down Expand Up @@ -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<CliOptionInput, string> 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});"
);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -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<string> parentRequiredGroups = Input.Parent?.RequiredGroups ?? Array.Empty<string>();
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)
Expand Down
46 changes: 46 additions & 0 deletions src/DotMake.CommandLine/CliValidationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace DotMake.CommandLine
Expand Down Expand Up @@ -90,7 +91,52 @@ public static void AddValidator(this Argument argument, string validationPattern
ValidateArgumentResult(result, validationResult => RegularExpression(validationResult, validationPattern, validationMessage))
);
}

/// <summary>
/// Adds a validator to <paramref name="command"/> that enforces mutual-exclusion for the provided options.
/// If <paramref name="required"/> is true, at least one option must be specified.
/// </summary>
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<string>())
.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)
Expand Down
Loading