How does clap::ArgGroup enforce mutual exclusivity or requirement relationships between arguments?
clap::ArgGroup creates logical groupings of arguments that enforce constraints: mutual exclusivity (only one argument in the group can be present), requirement (at least one argument in the group must be present), or combined constraints that ensure valid argument combinations at parse time. By organizing related arguments into groups, you define semantic relationships that clap validates automatically, producing clear error messages when constraints are violated.
The Argument Grouping Problem
use clap::{Arg, Command, ArgGroup};
fn grouping_problem() {
// Scenario: A CLI tool with related but conflicting options
//
// --input <file> Read from file
// --stdin Read from stdin
// --url <url> Read from URL
//
// Only ONE of these should be allowed - they're mutually exclusive
// But the user might specify multiple by mistake
// Without ArgGroup:
// User could pass: --input file.txt --stdin
// Your code would need to check and error manually
// With ArgGroup:
// clap automatically validates and shows:
// "error: The argument '--stdin' cannot be used with '--input'"
}ArgGroup automates validation of argument relationships that would otherwise require manual checking.
Basic ArgGroup for Mutual Exclusivity
use clap::{Arg, Command, ArgGroup};
fn basic_mutual_exclusivity() {
let matches = Command::new("myapp")
.arg(Arg::new("input")
.long("input")
.value_name("FILE")
.help("Read from file"))
.arg(Arg::new("stdin")
.long("stdin")
.help("Read from stdin"))
.arg(Arg::new("url")
.long("url")
.value_name("URL")
.help("Read from URL"))
// Create a group: only one of these can be used
.group(ArgGroup::new("source")
.args(["input", "stdin", "url"]))
.try_get_matches_from(["myapp", "--input", "file.txt", "--stdin"]);
match matches {
Ok(_) => println!("Valid arguments"),
Err(e) => {
// Error: "error: The argument '--stdin' cannot be used with '--input'"
println!("{}", e);
}
}
}By default, ArgGroup enforces that only one argument in the group can be present.
Mutual Exclusivity with Required Group
use clap::{Arg, Command, ArgGroup};
fn required_mutual_exclusivity() {
// Common pattern: Exactly one must be chosen
let matches = Command::new("converter")
.arg(Arg::new("json")
.long("json")
.help("Output as JSON"))
.arg(Arg::new("yaml")
.long("yaml")
.help("Output as YAML"))
.arg(Arg::new("xml")
.long("xml")
.help("Output as XML"))
// Group: exactly one must be present (required + mutually exclusive)
.group(ArgGroup::new("format")
.args(["json", "yaml", "xml"])
.required(true))
.try_get_matches_from(["converter"]);
match matches {
Ok(_) => println!("Format selected"),
Err(e) => {
// Error: "error: The following required arguments were not provided:
// <--json|--yaml|--xml>"
println!("{}", e);
}
}
}Combining .required(true) with a group enforces "exactly one of these must be present."
At Least One (Not Mutually Exclusive)
use clap::{Arg, Command, ArgGroup};
fn at_least_one() {
// Pattern: At least one must be present, but multiple are allowed
let matches = Command::new("search")
.arg(Arg::new("name")
.long("name")
.value_name("PATTERN")
.help("Search by name"))
.arg(Arg::new("email")
.long("email")
.value_name("PATTERN")
.help("Search by email"))
.arg(Arg::new("phone")
.long("phone")
.value_name("PATTERN")
.help("Search by phone"))
// Group: at least one is required, but multiple allowed
.group(ArgGroup::new("criteria")
.args(["name", "email", "phone"])
.required(true)) // Without .multiple(false), allows multiple
.try_get_matches_from(["search", "--name", "Alice", "--email", "@example.com"]);
if let Ok(matches) = matches {
// Both --name and --email are present - that's allowed
if matches.contains_id("name") {
println!("Searching by name: {:?}", matches.get_one::<String>("name"));
}
if matches.contains_id("email") {
println!("Searching by email: {:?}", matches.get_one::<String>("email"));
}
}
}A required group without explicit exclusivity allows multiple arguments from the group.
Explicit Multiple Mode
use clap::{Arg, Command, ArgGroup};
fn multiple_mode() {
// Explicitly allow multiple from group
let matches = Command::new("tags")
.arg(Arg::new("urgent")
.long("urgent")
.help("Mark as urgent"))
.arg(Arg::new("important")
.long("important")
.help("Mark as important"))
.arg(Arg::new("review")
.long("review")
.help("Mark for review"))
// Group: can specify any combination
.group(ArgGroup::new("tags")
.args(["urgent", "important", "review"])
.multiple(true)) // Explicitly allow multiple
.try_get_matches_from(["tags", "--urgent", "--review"]);
if let Ok(matches) = matches {
// Both --urgent and --review present - that's fine
if matches.contains_id("urgent") {
println!("Urgent tag set");
}
if matches.contains_id("review") {
println!("Review tag set");
}
}
}.multiple(true) explicitly allows multiple arguments from the group (the default behavior).
Strictly Mutual Exclusive
use clap::{Arg, Command, ArgGroup};
fn strictly_exclusive() {
// Explicitly enforce mutual exclusivity
let matches = Command::new("output")
.arg(Arg::new("quiet")
.short('q')
.long("quiet")
.help("Suppress output"))
.arg(Arg::new("verbose")
.short('v')
.long("verbose")
.help("Verbose output"))
.arg(Arg::new("debug")
.long("debug")
.help("Debug output"))
// Group: only one allowed
.group(ArgGroup::new("verbosity")
.args(["quiet", "verbose", "debug"])
.required(false) // None required, but if present, only one
.multiple(false)) // But only one allowed (explicit)
.try_get_matches_from(["output", "--quiet", "--verbose"]);
match matches {
Ok(_) => println!("Valid"),
Err(e) => {
// Error: "error: The argument '--verbose' cannot be used with '--quiet'"
println!("{}", e);
}
}
}.multiple(false) enforces strict mutual exclusivity - at most one from the group.
Required Group Combinations
use clap::{Arg, Command, ArgGroup};
fn required_exclusive_combinations() {
// Table of required + multiple combinations:
// | required | multiple | Behavior |
// |----------|----------|---------------------------------------------|
// | false | false | At most one from group (mutual exclusive) |
// | false | true | Any combination (or none) |
// | true | false | Exactly one from group (required exclusive) |
// | true | true | At least one, multiple allowed |
// Example: Exactly one required (required=true, multiple=false)
let cmd = Command::new("app")
.arg(Arg::new("format-a").long("format-a"))
.arg(Arg::new("format-b").long("format-b"))
.group(ArgGroup::new("format")
.args(["format-a", "format-b"])
.required(true)
.multiple(false));
// Must provide exactly one of --format-a or --format-b
// Example: At least one, multiple OK (required=true, multiple=true)
let cmd = Command::new("app")
.arg(Arg::new("tag-a").long("tag-a"))
.arg(Arg::new("tag-b").long("tag-b"))
.group(ArgGroup::new("tags")
.args(["tag-a", "tag-b"])
.required(true)
.multiple(true));
// Must provide at least one of --tag-a or --tag-b, both OK
}The combination of required and multiple defines the constraint precisely.
Multiple Groups
use clap::{Arg, Command, ArgGroup};
fn multiple_groups() {
// Arguments can belong to multiple groups
let matches = Command::new("tool")
.arg(Arg::new("input-file")
.long("input-file")
.value_name("FILE"))
.arg(Arg::new("input-stdin")
.long("input-stdin"))
.arg(Arg::new("output-file")
.long("output-file")
.value_name("FILE"))
.arg(Arg::new("output-stdout")
.long("output-stdout"))
// Group for input: exactly one required
.group(ArgGroup::new("input")
.args(["input-file", "input-stdin"])
.required(true))
// Group for output: exactly one required
.group(ArgGroup::new("output")
.args(["output-file", "output-stdout"])
.required(true))
.try_get_matches_from(["tool", "--input-file", "data.txt", "--output-stdout"]);
if let Ok(matches) = matches {
println!("Input file: {:?}", matches.get_one::<String>("input-file"));
println!("Output to stdout: {}", matches.contains_id("output-stdout"));
}
}Separate groups create independent constraints on different argument sets.
Overlapping Groups
use clap::{Arg, Command, ArgGroup};
fn overlapping_groups() {
// An argument can belong to multiple groups
let matches = Command::new("app")
.arg(Arg::new("verbose")
.short('v')
.long("verbose"))
.arg(Arg::new("quiet")
.short('q')
.long("quiet"))
.arg(Arg::new("debug")
.long("debug"))
.arg(Arg::new("trace")
.long("trace"))
// Group 1: Can't use quiet with verbose/debug/trace
.group(ArgGroup::new("not-with-quiet")
.args(["verbose", "debug", "trace", "quiet"])
.multiple(false))
// Group 2: Exactly one level of verbosity
.group(ArgGroup::new("verbosity-level")
.args(["verbose", "debug", "trace"])
.required(false)
.multiple(false))
.try_get_matches_from(["app", "--quiet", "--verbose"]);
match matches {
Ok(_) => println!("Valid"),
Err(e) => {
// Error because --quiet and --verbose are in mutual exclusive group
println!("{}", e);
}
}
}Arguments in multiple groups must satisfy all group constraints simultaneously.
Using ArgGroup with Subcommands
use clap::{Arg, Command, ArgGroup};
fn subcommand_groups() {
let matches = Command::new("git")
.subcommand(Command::new("commit")
.arg(Arg::new("message")
.short('m')
.long("message")
.value_name("MSG"))
.arg(Arg::new("file")
.long("file")
.value_name("FILE")
.help("Read commit message from file"))
// Group within subcommand
.group(ArgGroup::new("commit-message")
.args(["message", "file"])
.required(true))
.arg(Arg::new("all")
.short('a')
.long("all")
.help("Commit all changed files"))
.arg(Arg::new("amend")
.long("amend")
.help("Amend previous commit")))
.try_get_matches_from(["git", "commit", "-m", "Initial commit", "--all"]);
if let Some(commit_matches) = matches.subcommand_matches("commit") {
println!("Message: {:?}", commit_matches.get_one::<String>("message"));
println!("Commit all: {}", commit_matches.contains_id("all"));
}
}Groups work within subcommands, enforcing constraints on subcommand-specific arguments.
Conflicting Arguments Directly
use clap::{Arg, Command};
fn direct_conflicts() {
// Alternative: Use Arg::conflicts_with for pairwise exclusivity
let matches = Command::new("app")
.arg(Arg::new("input")
.long("input")
.value_name("FILE")
.conflicts_with("stdin")) // Direct conflict
.arg(Arg::new("stdin")
.long("stdin")
.conflicts_with("input")) // Bidirectional
.try_get_matches_from(["app", "--input", "file.txt", "--stdin"]);
match matches {
Ok(_) => println!("Valid"),
Err(e) => {
// Error: "error: The argument '--stdin' cannot be used with '--input'"
println!("{}", e);
}
}
// ArgGroup is better when:
// - More than two arguments in the group
// - You want "at least one" semantics
// - You want "exactly one" semantics
// Arg::conflicts_with is better when:
// - Only two arguments conflict
// - No requirement constraint needed
}Arg::conflicts_with handles pairwise exclusivity; ArgGroup scales better for multiple arguments.
Requirement Relationships
use clap::{Arg, Command, ArgGroup};
fn requirement_relationships() {
// ArgGroup can express "at least one required" constraint
// For "A requires B" relationships, use Arg::requires
let matches = Command::new("app")
.arg(Arg::new("config")
.long("config")
.value_name("FILE")
.help("Use config file"))
.arg(Arg::new("port")
.long("port")
.value_name("NUM")
.requires("config")) // --port requires --config
.arg(Arg::new("host")
.long("host")
.value_name("HOST")
.requires("config")) // --host requires --config
.try_get_matches_from(["app", "--port", "8080"]);
match matches {
Ok(_) => println!("Valid"),
Err(e) => {
// Error: "error: The argument '--port' requires '--config' to be provided"
println!("{}", e);
}
}
// This is different from ArgGroup:
// - ArgGroup: relationships between equals (one of these)
// - Arg::requires: dependency relationship (if A then B)
}Arg::requires expresses dependencies; ArgGroup expresses mutual choice or requirement among equals.
Combining Requires and Groups
use clap::{Arg, Command, ArgGroup};
fn combined_constraints() {
let matches = Command::new("database")
.arg(Arg::new("host")
.long("host")
.value_name("HOST")
.default_value("localhost"))
.arg(Arg::new("port")
.long("port")
.value_name("NUM")
.default_value("5432"))
// Authentication options
.arg(Arg::new("password")
.long("password")
.value_name("PASS")
.requires("user")) // --password requires --user
.arg(Arg::new("user")
.long("user")
.value_name("NAME"))
.arg(Arg::new("connection-string")
.long("connection-string")
.value_name("STRING"))
// Group: either use connection string OR individual params
.group(ArgGroup::new("auth-method")
.args(["password", "user", "connection-string"])
.multiple(false))
.try_get_matches_from(["database", "--password", "secret"]);
match matches {
Ok(_) => println!("Valid"),
Err(e) => {
// Error: --password requires --user
println!("{}", e);
}
}
// Both constraints apply:
// 1. Group: Only one of password/user/connection-string
// 2. Requires: --password needs --user
}Groups and requires constraints compose to create complex validation rules.
Error Messages
use clap::{Arg, Command, ArgGroup};
fn error_messages() {
// ArgGroup provides clear error messages
// Missing required argument from group:
let matches = Command::new("app")
.arg(Arg::new("json").long("json"))
.arg(Arg::new("yaml").long("yaml"))
.group(ArgGroup::new("format")
.args(["json", "yaml"])
.required(true))
.try_get_matches_from(["app"]);
match matches {
Ok(_) => {}
Err(e) => {
// Error: "error: The following required arguments were not provided:
// <--json|--yaml>"
println!("{}", e);
}
}
// Multiple arguments in exclusive group:
let matches = Command::new("app")
.arg(Arg::new("json").long("json"))
.arg(Arg::new("yaml").long("yaml"))
.group(ArgGroup::new("format")
.args(["json", "yaml"])
.multiple(false))
.try_get_matches_from(["app", "--json", "--yaml"]);
match matches {
Ok(_) => {}
Err(e) => {
// Error: "error: The argument '--yaml' cannot be used with '--json'"
println!("{}", e);
}
}
}Group constraints produce helpful error messages indicating exactly what's wrong.
Checking Group Matches
use clap::{Arg, Command, ArgGroup};
fn checking_group_matches() {
let matches = Command::new("app")
.arg(Arg::new("input-file")
.long("input-file")
.value_name("FILE"))
.arg(Arg::new("input-stdin")
.long("input-stdin"))
.arg(Arg::new("input-url")
.long("input-url")
.value_name("URL"))
.group(ArgGroup::new("input")
.args(["input-file", "input-stdin", "input-url"])
.required(true))
.try_get_matches_from(["app", "--input-file", "data.txt"])
.unwrap();
// Check which group member was used
if matches.contains_id("input-file") {
println!("Reading from file: {:?}", matches.get_one::<String>("input-file"));
} else if matches.contains_id("input-stdin") {
println!("Reading from stdin");
} else if matches.contains_id("input-url") {
println!("Reading from URL: {:?}", matches.get_one::<String>("input-url"));
}
// Or iterate to find the one that was set
for arg in ["input-file", "input-stdin", "input-url"] {
if matches.contains_id(arg) {
println!("Selected input method: {}", arg);
break;
}
}
}Check group membership using standard contains_id on individual argument names.
Derive API with ArgGroup
use clap::{Parser, Args, ArgGroup};
#[derive(Parser)]
#[command(name = "converter")]
struct Cli {
#[command(flatten)]
format: Format,
}
#[derive(Args)]
#[group(required = true, multiple = false)] // Exactly one required
struct Format {
/// Output as JSON
#[arg(long)]
json: bool,
/// Output as YAML
#[arg(long)]
yaml: bool,
/// Output as XML
#[arg(long)]
xml: bool,
}
fn derive_example() {
let cli = Cli::try_parse_from(["converter", "--json"]);
match cli {
Ok(cli) => {
if cli.format.json {
println!("JSON format selected");
} else if cli.format.yaml {
println!("YAML format selected");
} else if cli.format.xml {
println!("XML format selected");
}
}
Err(e) => {
println!("{}", e);
}
}
// The #[group] attribute on the struct creates an ArgGroup
// - required = true: at least one must be present
// - multiple = false: at most one can be present
// Together: exactly one must be present
}The derive API uses #[group] attribute on flattened structs to create argument groups.
Complete Example: Input/Output Groups
use clap::{Arg, Command, ArgGroup};
fn complete_example() {
let matches = Command::new("data-processor")
.version("1.0")
.about("Process data from various sources")
.arg(Arg::new("input-file")
.long("input-file")
.short('i')
.value_name("FILE")
.help("Read input from file"))
.arg(Arg::new("input-stdin")
.long("input-stdin")
.help("Read input from stdin"))
.arg(Arg::new("input-url")
.long("input-url")
.value_name("URL")
.help("Read input from URL"))
.group(ArgGroup::new("input")
.args(["input-file", "input-stdin", "input-url"])
.required(true))
.arg(Arg::new("output-file")
.long("output-file")
.short('o')
.value_name("FILE")
.help("Write output to file"))
.arg(Arg::new("output-stdout")
.long("output-stdout")
.help("Write output to stdout"))
.group(ArgGroup::new("output")
.args(["output-file", "output-stdout"])
.required(false)) // Default to stdout if not specified
.arg(Arg::new("format-json")
.long("json")
.help("Output as JSON"))
.arg(Arg::new("format-yaml")
.long("yaml")
.help("Output as YAML"))
.arg(Arg::new("format-csv")
.long("csv")
.help("Output as CSV"))
.group(ArgGroup::new("format")
.args(["format-json", "format-yaml", "format-csv"])
.required(true))
.arg(Arg::new("verbose")
.short('v')
.long("verbose")
.help("Verbose output"))
.arg(Arg::new("quiet")
.short('q')
.long("quiet")
.help("Quiet mode"))
.group(ArgGroup::new("verbosity")
.args(["verbose", "quiet"])
.required(false))
.get_matches_from(["data-processor",
"--input-file", "data.txt",
"--output-file", "result.txt",
"--json",
"--verbose"]);
// Determine input source
let input = if matches.contains_id("input-file") {
format!("file: {:?}", matches.get_one::<String>("input-file"))
} else if matches.contains_id("input-stdin") {
"stdin".to_string()
} else {
format!("url: {:?}", matches.get_one::<String>("input-url"))
};
println!("Input: {}", input);
// Determine output destination
let output = if matches.contains_id("output-file") {
format!("file: {:?}", matches.get_one::<String>("output-file"))
} else {
"stdout".to_string()
};
println!("Output: {}", output);
// Determine format (required group)
let format = if matches.contains_id("format-json") {
"json"
} else if matches.contains_id("format-yaml") {
"yaml"
} else {
"csv"
};
println!("Format: {}", format);
// Check verbosity (optional group)
let verbosity = if matches.contains_id("verbose") {
"verbose"
} else if matches.contains_id("quiet") {
"quiet"
} else {
"normal"
};
println!("Verbosity: {}", verbosity);
}A realistic CLI with multiple argument groups demonstrating various constraint combinations.
Summary Table
fn summary() {
// | Group Setting | Behavior |
// |---------------------|------------------------------------------|
// | required=false, | At most one from group (mutual |
// | multiple=false | exclusive, optional) |
// |---------------------|------------------------------------------|
// | required=false, | Any combination allowed |
// | multiple=true | (or none) |
// |---------------------|------------------------------------------|
// | required=true, | Exactly one from group required |
// | multiple=false | (mutual exclusive, required) |
// |---------------------|------------------------------------------|
// | required=true, | At least one from group required, |
// | multiple=true | multiple allowed |
// | Use Case | Group Settings |
// |--------------------------------------|-------------------------|
// | Choose exactly one format | required=true, |
// | (json/yaml/xml) | multiple=false |
// |--------------------------------------|-------------------------|
// | Choose at most one verbosity | required=false, |
// | (quiet/verbose/debug) | multiple=false |
// |--------------------------------------|-------------------------|
// | At least one search criterion | required=true, |
// | (name/email/phone, any combination) | multiple=true |
// |--------------------------------------|-------------------------|
// | Optional tags, any combination | required=false, |
// | (--urgent/--review/--important) | multiple=true (default) |
}Synthesis
Quick reference:
use clap::{Arg, Command, ArgGroup};
let cmd = Command::new("app")
.arg(Arg::new("json").long("json"))
.arg(Arg::new("yaml").long("yaml"))
.arg(Arg::new("xml").long("xml"))
// Exactly one required (mutual exclusive + required)
.group(ArgGroup::new("format")
.args(["json", "yaml", "xml"])
.required(true)
.multiple(false))
// At most one allowed (mutual exclusive, optional)
.group(ArgGroup::new("verbosity")
.args(["quiet", "verbose"])
.required(false)
.multiple(false))
// At least one required (multiple allowed)
.group(ArgGroup::new("search")
.args(["name", "email", "phone"])
.required(true)
.multiple(true));Key insight: ArgGroup solves the problem of validating argument relationships at parse time rather than after parsing. By grouping arguments with .args([...]), you create constraints that clap validates automatically. The combination of required and multiple defines the constraint semantics: required=true, multiple=false means exactly one must be present (mutually exclusive and required); required=false, multiple=false means at most one can be present (mutually exclusive, all optional); required=true, multiple=true means at least one must be present but multiple are allowed; required=false, multiple=true means any combination is valid (including none). Groups create "choose from these" semantics among equals, which differs from Arg::requires that creates "if this, then also that" dependency relationships. Groups also differ from Arg::conflicts_with which handles pairwise exclusivity—the group approach scales better and provides clearer error messages when you have three or more related arguments. Use groups when you have a set of alternatives (input sources, output formats, verbosity levels) where the relationship is about choice rather than dependency. The derive API supports groups via #[group] on flattened argument structs, making declarative CLI definitions clean and type-safe.
