From 780e89b9540505afd09298553f503d34a34f7641 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:52:39 +0000 Subject: [PATCH 01/17] feat(spec): a flag that has to be given on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--version`, `--dump-config`: asking for one means the rest of the command line has nothing to act on. The spec could say that a flag conflicts with named other flags and had no way to say it conflicts with *everything* — and no way at all to put a positional on either side of a conflict, since a selector names a flag. flag "--dump" exclusive=#true Enforced in both parsers against everything the command declares, positionals included, which is what makes it more than being in a group with every other flag. Only what was supplied counts, the rule `conflicts` already follows: a flag with a default standing beside an exclusive one is nobody saying anything, and counting it would make the exclusive flag unusable on any command that has a default. The bridge carries it, unlike `requires` — `Arg::is_exclusive_set` is public — so a CLI that already declares this in clap keeps it on the way through. Both `gen-shadow` dialects write it too, since clap and the derive can each say it. Co-Authored-By: Claude Opus 5 --- argv/src/spec.rs | 9 +++++ conformance/tests/post_binding.rs | 51 +++++++++++++++++++++++++ derive/src/codegen.rs | 40 ++++++++++++++++++++ derive/src/lib.rs | 1 + derive/src/model.rs | 10 ++++- docs/spec/reference/flag.md | 16 ++++++++ lib/src/parse.rs | 62 +++++++++++++++++++++++++++++++ lib/src/spec/flag.rs | 38 +++++++++++++++++++ xtask/src/shadow.rs | 6 +++ 9 files changed, 232 insertions(+), 1 deletion(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index e12981c60..4b5d8bb91 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -611,6 +611,11 @@ pub struct FlagMeta<'a> { /// flag win, this reports it: the combination has no meaning, so honouring one /// side silently would hide a mistake. pub conflicts: &'a [&'a str], + /// Whether this flag must be given on its own. + /// + /// The whole-command form of [`conflicts`](Self::conflicts): everything the command + /// declares counts, positionals included. + pub exclusive: bool, /// Flags that must also be given when this one is. /// /// The positive form of [`conflicts`](Self::conflicts), and the mirror image of @@ -648,6 +653,7 @@ impl FlagMeta<'_> { var_max: None, overrides: &[], conflicts: &[], + exclusive: false, requires: &[], required_if: &[], required_unless: &[], @@ -1151,6 +1157,9 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: write_single_default(out, meta.default)?; write_single_list(out, "overrides", meta.overrides)?; write_single_list(out, "conflicts", meta.conflicts)?; + if meta.exclusive { + out.push_str(" exclusive=#true"); + } write_single_list(out, "requires", meta.requires)?; write_single_list(out, "required_if", meta.required_if)?; write_single_list(out, "required_unless", meta.required_unless)?; diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index 3532cdafd..86ec9ccc4 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -796,3 +796,54 @@ fn a_conflict_answers_before_an_unsatisfied_group_does() { assert_eq!(two.file.as_deref(), Some("f")); assert!(two.url.is_none() && two.yaml && !two.json); } + +/// A CLI with a flag that has to be alone. +#[derive(Cli)] +#[usage(bin = "ex2")] +struct Exclusively { + /// Dump the spec and leave + #[usage(long, exclusive)] + dump: bool, + /// Print more + #[usage(short = 'v', long)] + verbose: bool, + /// What to act on + target: Option, +} + +#[test] +fn an_exclusive_flag_has_to_be_alone() { + let a = argv(["--dump"]); + let ex = Exclusively::parse_from(&a).expect("alone is the point"); + assert!(ex.dump && !ex.verbose && ex.target.is_none()); + + // Another flag. + let a = argv(["--dump", "-v"]); + assert!(matches!( + Exclusively::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + // And a positional, which is what makes this more than a conflict with every other + // flag: `conflicts` has nowhere to name an argument. + let a = argv(["--dump", "t"]); + assert!(matches!( + Exclusively::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + // Without it, nothing changes. + let a = argv(["-v", "t"]); + let ex = Exclusively::parse_from(&a).expect("the rest of the CLI is unaffected"); + assert!(ex.verbose); + assert_eq!(ex.target.as_deref(), Some("t")); +} + +#[test] +fn exclusive_reaches_the_spec() { + let kdl = Exclusively::to_kdl(); + assert!(kdl.contains("exclusive=#true"), "{kdl}"); + let spec: usage::Spec = kdl.parse().expect("the emitted spec should parse"); + let dump = spec.cmd.flags.iter().find(|f| f.name == "dump").unwrap(); + assert!(dump.exclusive); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index d88e6e7ab..fe38bbbfd 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -912,6 +912,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { let overrides = &field.overrides; let conflicts = &field.conflicts; let requires = &field.requires; + let exclusive = field.exclusive; let required_if = &field.required_if; let required_unless = &field.required_unless; @@ -947,6 +948,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { overrides: &[#(#overrides),*], conflicts: &[#(#conflicts),*], requires: &[#(#requires),*], + exclusive: #exclusive, required_if: &[#(#required_if),*], required_unless: &[#(#required_unless),*], ..usage_argv::spec::FlagMeta::EMPTY @@ -3304,6 +3306,43 @@ fn post_binding(cli: &Cli) -> TokenStream { }) }); + // An exclusive flag is a conflict with the whole command rather than with a named + // flag, so it is written as one check per *other* declaration — positionals included, + // which is what makes it more than being in a group with every other flag. + // + // Only what was given counts, as `conflicts` reads it: a defaulted field standing + // beside an exclusive flag is nobody saying anything, and counting it would make the + // flag unusable on any command that has a default. + let exclusive_checks = cli + .fields + .iter() + .filter(|f| f.exclusive) + .flat_map(move |f| { + let given = format_ident!("__given_{}", f.ident); + let name = &f.name; + cli.fields + .iter() + .filter(move |other| other.ident != f.ident) + .filter(|other| { + !matches!(other.kind, Kind::Subcommand { .. } | Kind::Flatten { .. }) + }) + .map(move |other| { + let other_given = format_ident!("__given_{}", other.ident); + let other_name = &other.name; + quote! { + if partial.#given && partial.#other_given { + return ::std::result::Result::Err( + ::usage_argv::Error::ConflictingFlags { + name: #other_name, + other: #name, + }, + ); + } + } + }) + .collect::>() + }); + // Groups, checked once per group rather than per member: both questions a group asks // — how many members were given, and whether that is enough — are about the set. // @@ -3447,6 +3486,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // more useful of the two answers when a conflict has also left something // unfilled, and it is the one usage-lib reports. #(#conflict_checks)* + #(#exclusive_checks)* #(#group_exclusivity_checks)* #(#requirement_checks)* #(#flattened_checks)* diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 320a5daac..1d925c273 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -214,6 +214,7 @@ //! | `conflicts = "--other"` | a flag this one cannot be given with | //! | `requires = "--other"` | a flag that must also be given when this one is | //! | `group = "input"` | the group this flag is one of; see below | +//! | `exclusive` | this flag has to be given on its own, positionals included | //! | `required_if = "--other"` | a flag whose presence makes this one necessary | //! | `required_unless = "--other"` | a flag whose presence makes this one unnecessary | //! diff --git a/derive/src/model.rs b/derive/src/model.rs index 14941c398..431edff3a 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -205,6 +205,8 @@ pub struct Field { /// one lives on the flag the rule is about, which is where clap puts it and where a /// reader looks for it. pub requires: Vec, + /// Whether this flag must be given on its own — clap's `exclusive`. + pub exclusive: bool, /// The group this flag belongs to, if any. Properties live on the group's own /// declaration; membership lives here, because a field is where a reader looks to /// see what a flag is part of. @@ -1053,6 +1055,7 @@ impl Field { overrides: Vec::new(), conflicts: Vec::new(), requires: Vec::new(), + exclusive: false, group: None, required_if: Vec::new(), required_unless: Vec::new(), @@ -1148,6 +1151,7 @@ impl Field { overrides: Vec::new(), conflicts: Vec::new(), requires: Vec::new(), + exclusive: false, group: None, required_if: Vec::new(), required_unless: Vec::new(), @@ -1207,6 +1211,7 @@ impl Field { let mut conflicts: Vec = Vec::new(); let mut requires: Vec = Vec::new(); let mut group: Option = None; + let mut exclusive = false; let mut required_if: Vec = Vec::new(); let mut required_unless: Vec = Vec::new(); @@ -1300,6 +1305,7 @@ impl Field { "conflicts" => conflicts = selectors(&meta)?, "requires" => requires = selectors(&meta)?, "group" => group = Some(string_value(&meta)?), + "exclusive" => exclusive = flag_value(&meta)?, "required_if" => required_if = selectors(&meta)?, "required_unless" => required_unless = selectors(&meta)?, "value_enum" => value_enum = flag_value(&meta)?, @@ -1343,7 +1349,8 @@ impl Field { `short`, `negate`, `global`, `var`, `variadic`, \ `count`, `hide`, `arg`, `env`, `default`, `choices`, \ `var_min`, `var_max`, `value_enum`, `value_hint`, `overrides`, \ - `conflicts`, `requires`, `group`, `required_if`, \ + `conflicts`, `requires`, `group`, `exclusive`, \ + `required_if`, \ `required_unless`, `help_heading`, `value_name`, \ `verbatim_doc_comment`, \ `required`, and `double_dash`" @@ -1890,6 +1897,7 @@ impl Field { overrides, conflicts, requires, + exclusive, group, required_if, required_unless, diff --git a/docs/spec/reference/flag.md b/docs/spec/reference/flag.md index bc99f77d8..eb51fe8d4 100644 --- a/docs/spec/reference/flag.md +++ b/docs/spec/reference/flag.md @@ -32,6 +32,7 @@ flag "--file " required_unless="--dir" // either --file or --dir must be p flag "--file " overrides="--stdin" // --file and --stdin override each other; the last one wins flag "--file " conflicts="--stdin" // --file and --stdin cannot be given together flag "--out " requires="--format" // giving --out means --format must be given too +flag "--dump" exclusive=#true // --dump has to be given on its own flag "--stdin" { conflicts "--file" "--url" // several, one per argument @@ -99,6 +100,21 @@ setter with no getter, so [the clap integration](/spec/integrations/clap) cannot back out — a CLI that wants the constraint in its spec has to declare it here. ::: +## `exclusive` + +An exclusive flag has to be given on its own: everything else the command declares is +refused alongside it, **including its positional arguments**, which is what makes this +more than [`conflicts`](#conflicts-and-overrides) with every other flag — a conflict has +nowhere to name an argument. + +`--version` and `--dump-config` are the shape it is for: asking for one means the rest of +the command line has nothing to act on. + +Only what was supplied counts, the same rule `conflicts` follows. A flag with a +[`default`](/spec/reference/flag) standing beside an exclusive one is nobody saying +anything, and counting it would make the exclusive flag unusable on any command that has +a default. + ## `global` A `global` flag is recognized by the command that declares it and by everything below it, so diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 6fd2c3675..89d3424c7 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1254,6 +1254,35 @@ fn parse_partial_with_env( } } + // An exclusive flag is the whole-command form of a conflict: `--version` means the + // rest of the line has nothing to act on. Everything the invocation supplied counts, + // positionals included, which is what distinguishes it from being in a group with + // every other flag. + // + // Only what was *given*, as `conflicts` reads it: a defaulted flag standing beside an + // exclusive one is nobody saying anything, and counting it would make the exclusive + // flag unusable on any command that has a default. + for flag in unique_flags(out.available_flags.values()) { + if !flag.exclusive || !out.flags.contains_key(flag) { + continue; + } + let other_flag = out + .flags + .keys() + .find(|other| other.name != flag.name) + .map(|other| format!("--{}", other.name)); + let other = + other_flag.or_else(|| out.args.keys().next().map(|arg| format!("<{}>", arg.name))); + if let Some(other) = other { + out.errors.push(UsageErr::InvalidFlag { + token: format!("--{}", flag.name), + reason: format!("must be given on its own, and {other} was given too"), + span: (0, 0).into(), + input: format!("--{} {other}", flag.name), + }); + } + } + // Groups, checked once per group rather than per flag: both questions a group asks — // how many members were given, and whether that is enough — are about the set, which // is the whole reason a group exists rather than a pile of pairwise conflicts. @@ -2906,6 +2935,39 @@ flag "--file " required_unless="--stdin" } } + #[test] + fn an_exclusive_flag_has_to_be_alone() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--verbose\"\narg \"[target]\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])).expect("alone is the point"); + + // Any other flag. + let err = parse(&spec, &input(&["ex", "--dump", "--verbose"])).unwrap_err(); + assert!(err.to_string().contains("on its own"), "{err}"); + + // And a positional, which is what makes this more than a conflict with every + // other flag. + let err = parse(&spec, &input(&["ex", "--dump", "t"])).unwrap_err(); + assert!(err.to_string().contains("on its own"), "{err}"); + + // Not given, so it imposes nothing. + parse(&spec, &input(&["ex", "--verbose", "t"])).expect("without it, nothing changes"); + } + + #[test] + fn an_exclusive_flag_is_not_disturbed_by_a_default() { + // Only what was supplied counts, as `conflicts` reads it. A default counting as + // company would make an exclusive flag unusable on any command that has one. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--jobs \" default=\"4\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])).expect("a default is nobody saying anything"); + assert!(parse(&spec, &input(&["ex", "--dump", "--jobs", "8"])).is_err()); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 64e8db12e..479860776 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -116,6 +116,14 @@ pub struct SpecFlag { /// had. #[serde(skip_serializing_if = "Vec::is_empty")] pub requires: Vec, + /// Whether this flag must be given on its own. + /// + /// The whole-command form of [`SpecFlag::conflicts`]: `--version` and `--help` are + /// the shape — asking for one means the rest of the command line has nothing to act + /// on. Everything the command declares counts, positionals included, which is what + /// makes this different from being in a group with every other flag. + #[serde(skip_serializing_if = "is_false")] + pub exclusive: bool, /// Raises the effect of the command when this flag is supplied. /// See [`crate::spec::effect::SpecCommandEffect`]; never lowers it. #[serde(skip_serializing_if = "Option::is_none")] @@ -177,6 +185,7 @@ impl SpecFlag { "overrides" => flag.overrides = vec![v.ensure_string()?], "conflicts" => flag.conflicts = vec![v.ensure_string()?], "requires" => flag.requires = vec![v.ensure_string()?], + "exclusive" => flag.exclusive = v.ensure_bool()?, "effect" => { let raw = v.ensure_string()?; match raw.parse() { @@ -284,6 +293,7 @@ impl SpecFlag { .map(|arg| arg.ensure_string()) .collect::>>()?; } + "exclusive" => flag.exclusive = child.arg(0)?.ensure_bool()?, "requires" => { flag.requires = child .ensure_arg_len(1..)? @@ -448,6 +458,9 @@ impl From<&SpecFlag> for KdlNode { } children.nodes_mut().push(requires); } + if flag.exclusive { + node.push(KdlEntry::new_prop("exclusive", true)); + } if let Some(env) = &flag.env { node.push(string_entry(Some("env"), env)); } @@ -614,6 +627,8 @@ impl From<&clap::Arg> for SpecFlag { // than guessed at, and counted by `gen-shadow` as a thing the clap dialect // cannot carry. requires: vec![], + // This one clap does expose, unlike `requires` just above. + exclusive: c.is_exclusive_set(), help, help_long, help_md: None, @@ -771,6 +786,29 @@ mod tests { assert_eq!(reparsed.cmd.flags[1].requires.len(), 2, "{spec}"); } + #[test] + fn exclusive_round_trips_and_comes_across_from_clap() { + let spec: Spec = "flag \"--dump\" exclusive=#true\nflag \"--verbose\"\n" + .parse() + .unwrap(); + assert!(spec.cmd.flags[0].exclusive); + assert!(!spec.cmd.flags[1].exclusive); + + let reparsed: Spec = spec.to_string().parse().unwrap(); + assert!(reparsed.cmd.flags[0].exclusive, "{spec}"); + + // Unlike `requires`, clap answers for this one — `Arg::is_exclusive_set` — so a + // spec generated from a clap command carries it. + let cmd = clap::Command::new("ex") + .arg(clap::Arg::new("dump").long("dump").exclusive(true)) + .arg(clap::Arg::new("verbose").long("verbose")); + let spec = Spec::from(&cmd); + let dump = spec.cmd.flags.iter().find(|f| f.name == "dump").unwrap(); + assert!(dump.exclusive); + let verbose = spec.cmd.flags.iter().find(|f| f.name == "verbose").unwrap(); + assert!(!verbose.exclusive); + } + #[test] fn requires_cannot_come_across_from_clap() { // Not an oversight to be fixed later: clap 4 has `Arg::requires` as a setter diff --git a/xtask/src/shadow.rs b/xtask/src/shadow.rs index 4d098574f..c62232da0 100644 --- a/xtask/src/shadow.rs +++ b/xtask/src/shadow.rs @@ -842,6 +842,9 @@ fn usage_flag_opts( if !flag.requires.is_empty() { opts.push(selector_list("requires", &flag.requires)); } + if flag.exclusive { + opts.push("exclusive = true".into()); + } if !flag.required_if.is_empty() { opts.push(selector_list("required_if", &flag.required_if)); } @@ -957,6 +960,9 @@ fn clap_flag_opts( if flag.negate.is_some() { skipped.note("a negated flag"); } + if flag.exclusive { + opts.push("exclusive = true".into()); + } if flag.global { opts.push("global = true".into()); } From f113d3b287b7303c9a0d5353d4fdf23bfef5628a Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:14:44 +0000 Subject: [PATCH 02/17] fix(spec): enforce exclusivity across command boundaries --- argv/src/spec.rs | 19 +++ conformance/tests/post_binding.rs | 92 ++++++++++++++ conformance/tests/post_binding_env.rs | 24 ++++ derive/src/codegen.rs | 171 ++++++++++++++++++++++++++ lib/src/parse.rs | 60 +++++++-- 5 files changed, 357 insertions(+), 9 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 4b5d8bb91..6277b817b 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1544,6 +1544,25 @@ pub trait CommandArgs: Sized { Vec::new() } + /// One declaration in this command that ended up being given, if any. + /// + /// Used to enforce relationships across a flattened `CommandArgs` boundary, where the + /// parent can see the nested partial only through this trait. + fn any_given(partial: &Self::Partial) -> Option<&'static str> { + let _ = partial; + None + } + + /// One exclusive flag in this command that was given, if any. + /// + /// Like [`CommandArgs::any_given`], this is the composition point for flattened argument + /// groups. An exclusive flag in a selected subcommand belongs to that subcommand rather + /// than to its parent, so implementations do not propagate it across that boundary. + fn exclusive_given(partial: &Self::Partial) -> Option<&'static str> { + let _ = partial; + None + } + /// Everything this command decides after the last token: required-ness, /// choices, and how many values a variadic got. /// diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index 86ec9ccc4..25a6eb7ed 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -847,3 +847,95 @@ fn exclusive_reaches_the_spec() { let dump = spec.cmd.flags.iter().find(|f| f.name == "dump").unwrap(); assert!(dump.exclusive); } + +#[derive(Args)] +struct ExtraOutput { + /// Write somewhere + #[usage(long)] + output: Option, +} + +#[derive(Cli)] +#[usage(bin = "flat-ex")] +struct ExclusiveBesideFlatten { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: ExtraOutput, +} + +#[derive(Args)] +struct FlattenedExclusive { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, +} + +#[derive(Cli)] +#[usage(bin = "flat-ex-reverse")] +struct FlattenBesideOther { + /// Print more + #[usage(long)] + verbose: bool, + #[usage(flatten)] + extra: FlattenedExclusive, +} + +#[test] +fn flattening_does_not_hide_either_side_of_exclusivity() { + let a = argv(["--dump", "--output", "somewhere"]); + assert!(matches!( + ExclusiveBesideFlatten::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + let a = argv(["--dump", "--verbose"]); + assert!(matches!( + FlattenBesideOther::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + let a = argv(["--output", "somewhere"]); + let parsed = ExclusiveBesideFlatten::parse_from(&a).expect("without --dump"); + assert!(!parsed.dump); + assert_eq!(parsed.extra.output.as_deref(), Some("somewhere")); + + let a = argv(["--dump"]); + let parsed = FlattenBesideOther::parse_from(&a).expect("the flattened flag is alone"); + assert!(!parsed.verbose); + assert!(parsed.extra.dump); +} + +#[derive(Cli)] +#[usage(bin = "sub-ex")] +struct ExclusiveBesideSubcommand { + /// Print the version and leave + #[usage(long, global, exclusive)] + version: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +enum ExclusiveCommands { + /// Run something + Run, +} + +#[test] +fn selecting_a_subcommand_counts_as_company_for_a_parent_exclusive_flag() { + let a = argv(["--version"]); + let parsed = ExclusiveBesideSubcommand::parse_from(&a).expect("alone is allowed"); + assert!(parsed.version); + assert!(parsed.command.is_none()); + + let a = argv(["--version", "run"]); + assert!(matches!( + ExclusiveBesideSubcommand::parse_from(&a), + Err(Error::ConflictingFlags { + other: "version", + .. + }) + )); +} diff --git a/conformance/tests/post_binding_env.rs b/conformance/tests/post_binding_env.rs index 6557a5c4e..6d71d1152 100644 --- a/conformance/tests/post_binding_env.rs +++ b/conformance/tests/post_binding_env.rs @@ -96,6 +96,30 @@ struct Ovr { quiet: bool, } +/// An exclusive flag beside a value supplied by the environment. +#[derive(Cli)] +#[usage(bin = "exclusive-env")] +struct ExclusiveEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + /// Where to write + #[usage(long, env = "EXCLUSIVE_ENV_OUT")] + out: Option, +} + +#[test] +fn an_environment_value_counts_for_exclusivity() { + unsafe { std::env::set_var("EXCLUSIVE_ENV_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(ExclusiveEnv::parse_from(&a).is_err()); + unsafe { std::env::remove_var("EXCLUSIVE_ENV_OUT") }; + + let parsed = ExclusiveEnv::parse_from(&a).expect("without the environment it is alone"); + assert!(parsed.dump); + assert!(parsed.out.is_none()); +} + #[test] fn a_displaced_flag_is_not_revived_by_its_environment_variable() { // The command line says `--stdin` came last, so `--file` lost. Filling it from the diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index fe38bbbfd..ced38bf47 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1394,6 +1394,87 @@ fn option_str(value: Option<&str>) -> TokenStream { } } +/// The presence summaries a parent needs to enforce exclusivity across a flattened +/// `CommandArgs` boundary. +fn presence_methods(cli: &Cli) -> TokenStream { + let direct_given = cli.fields.iter().filter_map(|field| { + if matches!(field.kind, Kind::Flatten { .. } | Kind::Subcommand { .. }) { + return None; + } + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + Some(quote! { + if partial.#given { + return ::std::option::Option::Some(#name); + } + }) + }); + let flattened_given = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#ident) + { + return ::std::option::Option::Some(name); + } + }) + }); + let selected = cli.fields.iter().find_map(|field| { + if !matches!(field.kind, Kind::Subcommand { .. }) { + return None; + } + let name = &field.name; + Some(quote! { + if partial.__usage_selected.is_some() { + return ::std::option::Option::Some(#name); + } + }) + }); + let direct_exclusive = cli.fields.iter().filter_map(|field| { + if !field.exclusive { + return None; + } + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + Some(quote! { + if partial.#given { + return ::std::option::Option::Some(#name); + } + }) + }); + let flattened_exclusive = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) + { + return ::std::option::Option::Some(name); + } + }) + }); + + quote! { + fn any_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { + #(#direct_given)* + #(#flattened_given)* + #selected + ::std::option::Option::None + } + + fn exclusive_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { + #(#direct_exclusive)* + #(#flattened_exclusive)* + ::std::option::Option::None + } + } +} + /// The struct that collects values while parsing. /// /// One field per declared field, of the type that accumulates it: a `bool` for a @@ -2275,6 +2356,7 @@ fn subcommand_parts(cli: &Cli) -> Option { pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let presence = presence_methods(cli); // A group carries settings the same way a root does, minus the layer: `SettingGiven` is // usage-argv's own vocabulary, so a flattened group can hand its parent what it was given // without either of them naming the config crate. Emitted whenever it has anything to say — @@ -2499,6 +2581,8 @@ pub fn emit_args(cli: &Cli) -> TokenStream { check(partial) } + #presence + #settings_impl fn build<'t, 'v>( @@ -3343,6 +3427,92 @@ fn post_binding(cli: &Cli) -> TokenStream { .collect::>() }); + // A flattened partial is intentionally opaque to its parent. Ask through + // `CommandArgs` whether either side of an exclusive relationship was given, so the + // relationship does not disappear merely because the declarations live in reusable + // `Args`. A selected subcommand is itself another declaration in this command; its + // contents remain in the child command's own scope. + let direct_given = cli + .fields + .iter() + .filter(|field| !matches!(field.kind, Kind::Flatten { .. } | Kind::Subcommand { .. })) + .rev() + .fold(quote!(::std::option::Option::None), |rest, field| { + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + quote!(if partial.#given { ::std::option::Option::Some(#name) } else { #rest }) + }); + let direct_exclusive = cli + .fields + .iter() + .filter(|field| field.exclusive) + .rev() + .fold(quote!(::std::option::Option::None), |rest, field| { + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + quote!(if partial.#given { ::std::option::Option::Some(#name) } else { #rest }) + }); + let has_flatten = cli + .fields + .iter() + .any(|field| matches!(field.kind, Kind::Flatten { .. })); + let has_direct_exclusive = cli.fields.iter().any(|field| field.exclusive); + let flattened_segments = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + ( + <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#ident), + <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), + ), + }) + }); + let subcommand_segment = cli.fields.iter().find_map(|field| { + if !matches!(field.kind, Kind::Subcommand { .. }) { + return None; + } + let name = &field.name; + Some(quote! { + ( + partial.__usage_selected.map(|_| #name), + ::std::option::Option::None, + ), + }) + }); + let exclusive_cross_checks = + (has_flatten || (has_direct_exclusive && subcommand_segment.is_some())).then(|| { + quote! { + let __usage_exclusive_segments = [ + (#direct_given, #direct_exclusive), + #(#flattened_segments)* + #subcommand_segment + ]; + for __usage_i in 0..__usage_exclusive_segments.len() { + if let ::std::option::Option::Some(exclusive) = + __usage_exclusive_segments[__usage_i].1 + { + for __usage_j in 0..__usage_exclusive_segments.len() { + if __usage_i == __usage_j { + continue; + } + if let ::std::option::Option::Some(other) = + __usage_exclusive_segments[__usage_j].0 + { + return ::std::result::Result::Err( + ::usage_argv::Error::ConflictingFlags { + name: other, + other: exclusive, + }, + ); + } + } + } + } + } + }); + // Groups, checked once per group rather than per member: both questions a group asks // — how many members were given, and whether that is enough — are about the set. // @@ -3487,6 +3657,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // unfilled, and it is the one usage-lib reports. #(#conflict_checks)* #(#exclusive_checks)* + #exclusive_cross_checks #(#group_exclusivity_checks)* #(#requirement_checks)* #(#flattened_checks)* diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 89d3424c7..88c7a9942 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1261,18 +1261,40 @@ fn parse_partial_with_env( // // Only what was *given*, as `conflicts` reads it: a defaulted flag standing beside an // exclusive one is nobody saying anything, and counting it would make the exclusive - // flag unusable on any command that has a default. - for flag in unique_flags(out.available_flags.values()) { - if !flag.exclusive || !out.flags.contains_key(flag) { + // flag unusable on any command that has a default. Environment values do count, also as + // `conflicts` reads them, so the spec parser and the derive agree. + for flag in unique_flags(out.available_flags.values().chain(out.flags.keys())) { + let given = out.flags.contains_key(flag) || flag_has_env(flag, custom_env); + if !flag.exclusive || !given || overridden_flags.contains(&flag.name) { continue; } - let other_flag = out - .flags - .keys() - .find(|other| other.name != flag.name) + let other_flag = unique_flags(out.available_flags.values().chain(out.flags.keys())) + .find(|other| { + other.name != flag.name + && !overridden_flags.contains(&other.name) + && (out.flags.contains_key(*other) || flag_has_env(other, custom_env)) + }) .map(|other| format!("--{}", other.name)); - let other = - other_flag.or_else(|| out.args.keys().next().map(|arg| format!("<{}>", arg.name))); + let other_arg = out.cmd.args.iter().find(|arg| { + out.args.keys().any(|given| given.name == arg.name) + || arg + .env + .as_ref() + .is_some_and(|env| env_contains(custom_env, env)) + }); + // Selecting a child is company for an exclusive flag declared by an ancestor. An + // exclusive flag belonging to the child itself does not conflict with the command + // word needed to reach that child. + let selected_subcommand = (out.cmds.len() > 1 + && out.cmds[..out.cmds.len() - 1].iter().any(|cmd| { + cmd.flags + .iter() + .any(|declared| declared.name == flag.name && declared.exclusive) + })) + .then(|| out.cmd.name.clone()); + let other = other_flag + .or_else(|| other_arg.map(|arg| format!("<{}>", arg.name))) + .or(selected_subcommand); if let Some(other) = other { out.errors.push(UsageErr::InvalidFlag { token: format!("--{}", flag.name), @@ -2968,6 +2990,26 @@ flag "--file " required_unless="--stdin" assert!(parse(&spec, &input(&["ex", "--dump", "--jobs", "8"])).is_err()); } + #[test] + fn an_environment_value_counts_for_an_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out \" env=\"EX_OUT\"\n" + .parse() + .unwrap(); + + assert!(parse_with_env(&spec, &["ex", "--dump"], &[("EX_OUT", "somewhere")]).is_err()); + parse_with_env(&spec, &["ex", "--dump"], &[]).expect("without the value it is alone"); + } + + #[test] + fn a_selected_subcommand_counts_for_an_ancestor_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--version\" global=#true exclusive=#true\ncmd \"run\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--version"])).expect("alone is allowed"); + assert!(parse(&spec, &input(&["ex", "--version", "run"])).is_err()); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" From 68c27e3b92851c666732ac8622619dd8fcec1bc8 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:48:27 +0000 Subject: [PATCH 03/17] fix(derive): apply flattened env before exclusivity --- argv/src/spec.rs | 9 ++ conformance/tests/post_binding_env.rs | 57 ++++++++++- derive/src/codegen.rs | 133 +++++++++++++++----------- 3 files changed, 141 insertions(+), 58 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 6277b817b..bffa7fa0e 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1563,6 +1563,15 @@ pub trait CommandArgs: Sized { None } + /// Fill fields in this command from their declared environment variables. + /// + /// A parent calls this before relationships that cross a flattened `CommandArgs` + /// boundary, so those relationships see the same values as the nested command's own + /// checks. Empty by default for hand-written implementations. + fn apply_env(partial: &mut Self::Partial) { + let _ = partial; + } + /// Everything this command decides after the last token: required-ness, /// choices, and how many values a variadic got. /// diff --git a/conformance/tests/post_binding_env.rs b/conformance/tests/post_binding_env.rs index 6d71d1152..6431c0039 100644 --- a/conformance/tests/post_binding_env.rs +++ b/conformance/tests/post_binding_env.rs @@ -9,7 +9,7 @@ use std::ffi::OsStr; -use usage_derive::Cli; +use usage_derive::{Args, Cli}; fn argv(tokens: [&str; N]) -> [&OsStr; N] { tokens.map(OsStr::new) @@ -120,6 +120,61 @@ fn an_environment_value_counts_for_exclusivity() { assert!(parsed.out.is_none()); } +#[derive(Args)] +struct FlattenedEnvOutput { + /// Where to write + #[usage(long, env = "FLAT_EXCLUSIVE_ENV_OUT")] + out: Option, +} + +#[derive(Cli)] +#[usage(bin = "flat-exclusive-env")] +struct ExclusiveAcrossFlattenEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: FlattenedEnvOutput, +} + +#[derive(Args)] +struct FlattenedExclusiveEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, +} + +#[derive(Cli)] +#[usage(bin = "flat-exclusive-env-reverse")] +struct EnvAcrossFlattenExclusive { + /// Where to write + #[usage(long, env = "FLAT_EXCLUSIVE_ENV_REVERSE_OUT")] + out: Option, + #[usage(flatten)] + extra: FlattenedExclusiveEnv, +} + +#[test] +fn flattened_environment_values_are_visible_to_cross_boundary_exclusivity() { + unsafe { std::env::set_var("FLAT_EXCLUSIVE_ENV_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(ExclusiveAcrossFlattenEnv::parse_from(&a).is_err()); + unsafe { std::env::remove_var("FLAT_EXCLUSIVE_ENV_OUT") }; + + unsafe { std::env::set_var("FLAT_EXCLUSIVE_ENV_REVERSE_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(EnvAcrossFlattenExclusive::parse_from(&a).is_err()); + unsafe { std::env::remove_var("FLAT_EXCLUSIVE_ENV_REVERSE_OUT") }; + + let a = argv(["--dump"]); + let parsed = ExclusiveAcrossFlattenEnv::parse_from(&a).expect("alone after cleanup"); + assert!(parsed.dump); + assert!(parsed.extra.out.is_none()); + let parsed = EnvAcrossFlattenExclusive::parse_from(&a).expect("alone after cleanup"); + assert!(parsed.out.is_none()); + assert!(parsed.extra.dump); +} + #[test] fn a_displaced_flag_is_not_revived_by_its_environment_variable() { // The command line says `--stdin` came last, so `--file` lost. Filling it from the diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index ced38bf47..50c8145c9 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2357,6 +2357,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); let presence = presence_methods(cli); + let apply_env = env_fallbacks(cli); // A group carries settings the same way a root does, minus the layer: `SettingGiven` is // usage-argv's own vocabulary, so a flattened group can hand its parent what it was given // without either of them naming the config crate. Emitted whenever it has anything to say — @@ -2583,6 +2584,10 @@ pub fn emit_args(cli: &Cli) -> TokenStream { #presence + fn apply_env(partial: &mut Self::Partial) { + #apply_env + } + #settings_impl fn build<'t, 'v>( @@ -3082,6 +3087,75 @@ fn group_meta_table(cli: &Cli) -> (TokenStream, TokenStream) { ) } +/// Environment fallbacks for this command and every argument group flattened into it. +/// +/// Kept separate from the rest of post-binding validation because a parent must apply these +/// before it can enforce a relationship whose other side lives across a flatten boundary. +fn env_fallbacks(cli: &Cli) -> TokenStream { + let own = cli.fields.iter().filter_map(|f| { + let ident = &f.ident; + let given = format_ident!("__given_{}", ident); + let var = f.env.as_deref()?; + let assign = match f.shape { + // `env::var` gives text, which is right for an environment variable: the + // partial holds bytes because *argv* may not be UTF-8, and this is not argv. + Shape::Optional => quote! { + partial.#ident = ::std::option::Option::Some(value.into_bytes()); + }, + Shape::Required => quote!(partial.#ident = value.into_bytes();), + // Cleared first, so the environment *replaces* a declared default instead of + // adding to it — which is what every other shape does by assigning. + Shape::Many => quote! { + partial.#ident.clear(); + partial.#ident.push(value.into_bytes()); + }, + Shape::Bool => quote! { + partial.#ident = !matches!( + value.as_str(), + "" | "0" | "false" | "no" | "off" + ); + }, + // An unparseable count leaves the field alone rather than counting as given. + Shape::Count => { + let ty = &f.ty; + quote! { + match value.parse::<#ty>() { + ::std::result::Result::Ok(count) => partial.#ident = count, + ::std::result::Result::Err(_) => continue_unset = true, + } + } + } + }; + // A flag that lost an override is not merely unset: filling it from the + // environment would undo the last-one-wins the command line asked for. + let standing = displaced_guard(cli, f); + Some(quote! { + if !partial.#given #standing { + if let ::std::result::Result::Ok(value) = ::std::env::var(#var) { + let mut continue_unset = false; + #assign + if !continue_unset { + partial.#given = true; + } + } + } + }) + }); + let flattened = cli.fields.iter().filter_map(|f| { + let Kind::Flatten { ty } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + <#ty as ::usage_argv::spec::CommandArgs>::apply_env(&mut partial.#ident); + }) + }); + quote! { + #(#own)* + #(#flattened)* + } +} + /// Everything decided once the last token has been read. /// /// Ordered deliberately. The environment fills what argv left out, so it runs @@ -3141,62 +3215,7 @@ fn post_binding(cli: &Cli) -> TokenStream { }) }); - let env_fallbacks = cli.fields.iter().filter_map(|f| { - let ident = &f.ident; - let given = format_ident!("__given_{}", ident); - let var = f.env.as_deref()?; - let assign = match f.shape { - // `env::var` gives text, which is right for an environment variable: the - // partial holds bytes because *argv* may not be UTF-8, and this is not argv. - Shape::Optional => quote! { - partial.#ident = ::std::option::Option::Some(value.into_bytes()); - }, - Shape::Required => quote!(partial.#ident = value.into_bytes();), - // Cleared first, so the environment *replaces* a declared default instead of - // adding to it — which is what every other shape does by assigning, and what the - // order here means: a default says what the value is when nobody said anything, - // and the environment is somebody saying something. Nothing else can be in the - // collection at this point: argv sets `__given_*`, which this is guarded on. - Shape::Many => quote! { - partial.#ident.clear(); - partial.#ident.push(value.into_bytes()); - }, - // A switch reads as on for anything but the spellings of "off", which is - // what every tool that takes a boolean from the environment settles on. - Shape::Bool => quote! { - partial.#ident = !matches!( - value.as_str(), - "" | "0" | "false" | "no" | "off" - ); - }, - // A number, since the environment cannot repeat a flag: `EX_VERBOSE=3` - // is how you say `-vvv`. An unparseable value leaves the field alone - // rather than being counted as given. - Shape::Count => { - let ty = &f.ty; - quote! { - match value.parse::<#ty>() { - ::std::result::Result::Ok(count) => partial.#ident = count, - ::std::result::Result::Err(_) => continue_unset = true, - } - } - } - }; - // A flag that lost an override is not merely unset: filling it from the - // environment would undo the last-one-wins the command line asked for. - let standing = displaced_guard(cli, f); - Some(quote! { - if !partial.#given #standing { - if let ::std::result::Result::Ok(value) = ::std::env::var(#var) { - let mut continue_unset = false; - #assign - if !continue_unset { - partial.#given = true; - } - } - } - }) - }); + let env_fallbacks = env_fallbacks(cli); let required_checks = cli.fields.iter().filter_map(|f| { // A `String` has nowhere to put "absent", so the type is the declaration; a collection @@ -3650,7 +3669,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // Before the environment, which overrides a default when the flag was not given — // the order `start` used to give them. #(#declared_defaults)* - #(#env_fallbacks)* + #env_fallbacks #(#duplicate_checks)* // Before required-ness: "you gave two flags that cannot go together" is the // more useful of the two answers when a conflict has also left something From 0ff03e2680be62ca4e43c26c9615cfd549263903 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:38:26 +0000 Subject: [PATCH 04/17] fix(parse): distinguish same-named child flags --- lib/src/parse.rs | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 88c7a9942..e24dd6d4c 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1285,13 +1285,23 @@ fn parse_partial_with_env( // Selecting a child is company for an exclusive flag declared by an ancestor. An // exclusive flag belonging to the child itself does not conflict with the command // word needed to reach that child. - let selected_subcommand = (out.cmds.len() > 1 - && out.cmds[..out.cmds.len() - 1].iter().any(|cmd| { - cmd.flags - .iter() - .any(|declared| declared.name == flag.name && declared.exclusive) - })) - .then(|| out.cmd.name.clone()); + // + // Identity matters here, not only the canonical name: a parent and child may each + // declare their own non-global `--clean`. The selected command's flag is still in + // `available_flags` under the same Arc the parse recorded; a non-global ancestor was + // dropped on descent. A child re-declaring an inherited global is the same logical + // ancestor flag only when the merged flag kept `global`, hence the declaration check. + let belongs_to_selected_command = out + .available_flags + .values() + .any(|available| Arc::ptr_eq(available, flag)) + && out + .cmd + .flags + .iter() + .any(|declared| declared.name == flag.name && declared.global == flag.global); + let selected_subcommand = + (out.cmds.len() > 1 && !belongs_to_selected_command).then(|| out.cmd.name.clone()); let other = other_flag .or_else(|| other_arg.map(|arg| format!("<{}>", arg.name))) .or(selected_subcommand); @@ -3010,6 +3020,20 @@ flag "--file " required_unless="--stdin" assert!(parse(&spec, &input(&["ex", "--version", "run"])).is_err()); } + #[test] + fn a_child_exclusive_flag_is_not_mistaken_for_a_same_named_parent_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child flag is alone within the child command"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run"])).is_err(), + "the parent flag still conflicts with selecting the child" + ); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" From 6f23d0ced8d868cf54bea93dc8b3ddb91374c04d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:48:39 +0000 Subject: [PATCH 05/17] fix(parse): preserve inherited alias exclusivity --- lib/src/parse.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index e24dd6d4c..23ee1798f 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1289,17 +1289,19 @@ fn parse_partial_with_env( // Identity matters here, not only the canonical name: a parent and child may each // declare their own non-global `--clean`. The selected command's flag is still in // `available_flags` under the same Arc the parse recorded; a non-global ancestor was - // dropped on descent. A child re-declaring an inherited global is the same logical - // ancestor flag only when the merged flag kept `global`, hence the declaration check. + // dropped on descent. Compare the spellings as well as the canonical name when a + // child re-declares an inherited global: the child can replace only one alias while + // an invocation through another alias still points at the ancestor declaration. let belongs_to_selected_command = out .available_flags .values() .any(|available| Arc::ptr_eq(available, flag)) - && out - .cmd - .flags - .iter() - .any(|declared| declared.name == flag.name && declared.global == flag.global); + && out.cmd.flags.iter().any(|declared| { + declared.name == flag.name + && declared.global == flag.global + && declared.short == flag.short + && declared.long == flag.long + }); let selected_subcommand = (out.cmds.len() > 1 && !belongs_to_selected_command).then(|| out.cmd.name.clone()); let other = other_flag @@ -3034,6 +3036,18 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn an_inherited_alias_keeps_its_ancestor_exclusivity() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" global=#true\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the inherited short alias still belongs to the ancestor exclusive flag" + ); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" From 418257891c2c4fa3542f3d4404c1ad066994d0da Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:55:16 +0000 Subject: [PATCH 06/17] fix(parse): track exclusive flag identity --- lib/src/parse.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 23ee1798f..50991018a 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1264,7 +1264,11 @@ fn parse_partial_with_env( // flag unusable on any command that has a default. Environment values do count, also as // `conflicts` reads them, so the spec parser and the derive agree. for flag in unique_flags(out.available_flags.values().chain(out.flags.keys())) { - let given = out.flags.contains_key(flag) || flag_has_env(flag, custom_env); + // `SpecFlag` equality is intentionally name-only for the public parsed-value map, + // but re-declared aliases can leave distinct declarations with that same name in + // scope. Exclusivity is about the declaration the typed spelling resolved to, so + // compare the parser's `Arc`s by identity here. + let given = flag_was_parsed(&out, flag) || flag_has_env(flag, custom_env); if !flag.exclusive || !given || overridden_flags.contains(&flag.name) { continue; } @@ -1272,7 +1276,7 @@ fn parse_partial_with_env( .find(|other| { other.name != flag.name && !overridden_flags.contains(&other.name) - && (out.flags.contains_key(*other) || flag_has_env(other, custom_env)) + && (flag_was_parsed(&out, other) || flag_has_env(other, custom_env)) }) .map(|other| format!("--{}", other.name)); let other_arg = out.cmd.args.iter().find(|arg| { @@ -1301,6 +1305,7 @@ fn parse_partial_with_env( && declared.global == flag.global && declared.short == flag.short && declared.long == flag.long + && declared.negate == flag.negate }); let selected_subcommand = (out.cmds.len() > 1 && !belongs_to_selected_command).then(|| out.cmd.name.clone()); @@ -1490,6 +1495,10 @@ fn flag_has_env(flag: &SpecFlag, custom_env: Option<&HashMap>) - .is_some_and(|env_var| env_contains(custom_env, env_var)) } +fn flag_was_parsed(out: &ParseOutput, flag: &Arc) -> bool { + out.flags.keys().any(|parsed| Arc::ptr_eq(parsed, flag)) +} + fn selector_is_explicit( selector: &str, out: &ParseOutput, @@ -3042,12 +3051,26 @@ flag "--file " required_unless="--stdin" .parse() .unwrap(); + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child's spelling does not activate the orphan ancestor alias"); assert!( parse(&spec, &input(&["ex", "run", "-c"])).is_err(), "the inherited short alias still belongs to the ancestor exclusive flag" ); } + #[test] + fn an_inherited_negated_alias_keeps_its_ancestor_exclusivity() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" negate=\"--no-clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"-c --clean\" global=#true\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "--no-clean"])).is_err(), + "the inherited negated alias still belongs to the ancestor exclusive flag" + ); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" From 262b5373aa404109c7c90fcb60f1f4d549b49802 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:29:38 +0000 Subject: [PATCH 07/17] fix(parse): attribute local exclusive flags --- lib/src/parse.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 50991018a..0d973e436 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1302,7 +1302,6 @@ fn parse_partial_with_env( .any(|available| Arc::ptr_eq(available, flag)) && out.cmd.flags.iter().any(|declared| { declared.name == flag.name - && declared.global == flag.global && declared.short == flag.short && declared.long == flag.long && declared.negate == flag.negate @@ -3045,6 +3044,20 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn a_child_local_exclusive_redeclaration_belongs_to_the_child() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child-local exclusive flag is alone inside the child command"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run"])).is_err(), + "the ancestor spelling still conflicts with selecting the child" + ); + } + #[test] fn an_inherited_alias_keeps_its_ancestor_exclusivity() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" global=#true\n}\n" From 3f540b13ada60119a27643e5b749ac569e773504 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:23:34 +0000 Subject: [PATCH 08/17] fix(parse): attribute merged aliases by spelling --- lib/src/parse.rs | 59 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 0d973e436..f2505eb6b 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -613,6 +613,11 @@ fn parse_partial_with_env( // Keep this internal so adding relationship support remains semver-compatible. The full // parser uses it to prevent defaults and environment values from restoring overridden flags. let mut overridden_flags = HashSet::new(); + // Which spelling supplied each parsed flag. A child may re-declare one long form of an + // inherited global while the merge keeps the ancestor's other aliases on the same `Arc`. + // The declaration object alone then cannot answer whether `--clean` belonged to the child + // or an inherited `-c` belonged to the ancestor. + let mut parsed_flag_spellings: HashMap> = HashMap::new(); // Phase 1: Scan for subcommands and collect global flags // @@ -939,6 +944,10 @@ fn parse_partial_with_env( let split = w.split_once('='); let word = split.map(|(word, _)| word).unwrap_or(&w); if let Some(f) = binding.as_ref().or_else(|| out.available_flags.get(word)) { + parsed_flag_spellings + .entry(Arc::as_ptr(f) as usize) + .or_default() + .insert(word.to_string()); apply_flag_overrides( f, &out.available_flags, @@ -1032,6 +1041,10 @@ fn parse_partial_with_env( .as_ref() .or_else(|| out.available_flags.get(&format!("-{short}"))) { + parsed_flag_spellings + .entry(Arc::as_ptr(f) as usize) + .or_default() + .insert(format!("-{short}")); apply_flag_overrides( f, &out.available_flags, @@ -1296,16 +1309,36 @@ fn parse_partial_with_env( // dropped on descent. Compare the spellings as well as the canonical name when a // child re-declares an inherited global: the child can replace only one alias while // an invocation through another alias still points at the ancestor declaration. + let was_parsed = flag_was_parsed(&out, flag); let belongs_to_selected_command = out .available_flags .values() .any(|available| Arc::ptr_eq(available, flag)) - && out.cmd.flags.iter().any(|declared| { - declared.name == flag.name - && declared.short == flag.short - && declared.long == flag.long - && declared.negate == flag.negate - }); + && if was_parsed { + // Ask about the form that was actually typed. The merged flag may be a + // superset of the child's declaration because it also carries an orphan + // ancestor alias, so comparing the complete alias sets misattributes both + // `--clean` and `-c`. + parsed_flag_spellings + .get(&(Arc::as_ptr(flag) as usize)) + .is_some_and(|spellings| { + out.cmd.flags.iter().any(|declared| { + declared.name == flag.name + && flag_keys(declared) + .iter() + .any(|spelling| spellings.contains(spelling)) + }) + }) + } else { + // An environment value has no typed spelling. Preserve the declaration + // identity check for that path. + out.cmd.flags.iter().any(|declared| { + declared.name == flag.name + && declared.short == flag.short + && declared.long == flag.long + && declared.negate == flag.negate + }) + }; let selected_subcommand = (out.cmds.len() > 1 && !belongs_to_selected_command).then(|| out.cmd.name.clone()); let other = other_flag @@ -3058,6 +3091,20 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn an_orphan_parent_alias_does_not_disown_a_child_local_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the typed long form belongs to the child declaration"); + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the inherited short form still belongs to the ancestor" + ); + } + #[test] fn an_inherited_alias_keeps_its_ancestor_exclusivity() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" global=#true\n}\n" From 8f7c14a1c6df2024c9ecf8799f8f16eb3e56574c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:53:34 +0000 Subject: [PATCH 09/17] fix(parse): complete exclusive flag semantics --- argv/src/spec.rs | 15 +++ conformance/tests/post_binding.rs | 55 ++++++++++ derive/src/codegen.rs | 171 +++++++++++++++++++++++------- lib/src/parse.rs | 141 ++++++++++++++++-------- 4 files changed, 301 insertions(+), 81 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index bffa7fa0e..8e4228eef 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1658,6 +1658,21 @@ pub trait Subcommands: Sized { Vec::new() } + /// One declaration in the selected command that was given, if any. + fn any_given(partial: &Self::Partial, selected: Option) -> Option<&'static str> { + let _ = (partial, selected); + None + } + + /// One exclusive flag in the selected command that was given, if any. + /// + /// This lets the parent compare its own fields with the selected child's without + /// exposing the child's generated partial type. + fn exclusive_given(partial: &Self::Partial, selected: Option) -> Option<&'static str> { + let _ = (partial, selected); + None + } + /// Check the selected command's requirements, and nothing else's. /// /// A flag that `install` requires says nothing about an invocation that ran diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index 25a6eb7ed..fbed931d8 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -839,6 +839,26 @@ fn an_exclusive_flag_has_to_be_alone() { assert_eq!(ex.target.as_deref(), Some("t")); } +#[derive(Cli)] +#[usage(bin = "required-ex")] +struct ExclusiveWithRequiredSiblings { + #[usage(long, exclusive)] + dump: bool, + #[usage(long)] + output: String, + target: String, +} + +#[test] +fn an_exclusive_flag_bypasses_required_siblings() { + let a = argv(["--dump"]); + let parsed = ExclusiveWithRequiredSiblings::parse_from(&a) + .expect("exclusive is the command's requiredness escape"); + assert!(parsed.dump); + assert!(parsed.output.is_empty()); + assert!(parsed.target.is_empty()); +} + #[test] fn exclusive_reaches_the_spec() { let kdl = Exclusively::to_kdl(); @@ -939,3 +959,38 @@ fn selecting_a_subcommand_counts_as_company_for_a_parent_exclusive_flag() { }) )); } +#[allow(dead_code)] +#[derive(Args)] +struct ChildExclusive { + #[usage(long, exclusive)] + dump: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum ChildExclusiveCommands { + Run(ChildExclusive), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "child-ex")] +struct ParentBesideChildExclusive { + #[usage(long)] + verbose: bool, + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_child_exclusive_flag_counts_parent_flags_as_company() { + let a = argv(["run", "--dump"]); + ParentBesideChildExclusive::parse_from(&a).expect("the child flag is alone"); + + let a = argv(["--verbose", "run", "--dump"]); + assert!(matches!( + ParentBesideChildExclusive::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + | Err(Error::ConflictingFlags { name: "dump", .. }) + )); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 50c8145c9..ded14dfb9 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1458,6 +1458,21 @@ fn presence_methods(cli: &Cli) -> TokenStream { } }) }); + let selected_exclusive = cli.fields.iter().find_map(|field| { + let Kind::Subcommand { ty, .. } = &field.kind else { + return None; + }; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ) + { + return ::std::option::Option::Some(name); + } + }) + }); quote! { fn any_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { @@ -1470,6 +1485,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { fn exclusive_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { #(#direct_exclusive)* #(#flattened_exclusive)* + #selected_exclusive ::std::option::Option::None } } @@ -2800,6 +2816,24 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }); + let any_givens = subs.variants.iter().enumerate().map(|(i, v)| { + let field = format_ident!("v{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#field) + } + } + }); + let exclusive_givens = subs.variants.iter().enumerate().map(|(i, v)| { + let field = format_ident!("v{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#field) + } + } + }); let selects = subs.variants.iter().enumerate().map(|(i, v)| { let held = format_ident!("V{i}"); let variant = &v.ident; @@ -2917,6 +2951,26 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } + fn any_given( + partial: &Self::Partial, + selected: ::std::option::Option, + ) -> ::std::option::Option<&'static str> { + match selected { + #(#any_givens)* + _ => ::std::option::Option::None, + } + } + + fn exclusive_given( + partial: &Self::Partial, + selected: ::std::option::Option, + ) -> ::std::option::Option<&'static str> { + match selected { + #(#exclusive_givens)* + _ => ::std::option::Option::None, + } + } + fn check<'t, 'v>( partial: &mut Self::Partial, selected: usize, @@ -3164,6 +3218,39 @@ fn env_fallbacks(cli: &Cli) -> TokenStream { /// from the environment or a default. fn post_binding(cli: &Cli) -> TokenStream { let sub_check = subcommand_parts(cli).map(|p| p.check).unwrap_or_default(); + let direct_exclusive_present = cli.fields.iter().filter_map(|field| { + if !field.exclusive { + return None; + } + let given = format_ident!("__given_{}", field.ident); + Some(quote!(partial.#given)) + }); + let flattened_exclusive_present = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident).is_some() + }) + }); + let selected_exclusive_present = cli.fields.iter().filter_map(|field| { + let Kind::Subcommand { ty, .. } = &field.kind else { + return None; + }; + Some(quote! { + <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ).is_some() + }) + }); + let exclusive_present = quote! { + false + #(|| #direct_exclusive_present)* + #(|| #flattened_exclusive_present)* + #(|| #selected_exclusive_present)* + }; // A flattened struct declares its own required-ness and choices, and only it knows them. // // Run before this command's own required-ness, on the same principle that puts conflicts @@ -3180,7 +3267,12 @@ fn post_binding(cli: &Cli) -> TokenStream { }; let ident = &f.ident; Some(quote! { - <#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?; + if !__usage_exclusive_present + || <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) + .is_some() + { + <#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?; + } }) }); let duplicate_checks = cli.fields.iter().filter(|f| rejects_duplicate(f)).map(|f| { @@ -3475,7 +3567,6 @@ fn post_binding(cli: &Cli) -> TokenStream { .fields .iter() .any(|field| matches!(field.kind, Kind::Flatten { .. })); - let has_direct_exclusive = cli.fields.iter().any(|field| field.exclusive); let flattened_segments = cli.fields.iter().filter_map(|field| { let Kind::Flatten { ty } = &field.kind else { return None; @@ -3489,48 +3580,50 @@ fn post_binding(cli: &Cli) -> TokenStream { }) }); let subcommand_segment = cli.fields.iter().find_map(|field| { - if !matches!(field.kind, Kind::Subcommand { .. }) { + let Kind::Subcommand { ty, .. } = &field.kind else { return None; - } + }; let name = &field.name; Some(quote! { ( partial.__usage_selected.map(|_| #name), - ::std::option::Option::None, + <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ), ), }) }); - let exclusive_cross_checks = - (has_flatten || (has_direct_exclusive && subcommand_segment.is_some())).then(|| { - quote! { - let __usage_exclusive_segments = [ - (#direct_given, #direct_exclusive), - #(#flattened_segments)* - #subcommand_segment - ]; - for __usage_i in 0..__usage_exclusive_segments.len() { - if let ::std::option::Option::Some(exclusive) = - __usage_exclusive_segments[__usage_i].1 - { - for __usage_j in 0..__usage_exclusive_segments.len() { - if __usage_i == __usage_j { - continue; - } - if let ::std::option::Option::Some(other) = - __usage_exclusive_segments[__usage_j].0 - { - return ::std::result::Result::Err( - ::usage_argv::Error::ConflictingFlags { - name: other, - other: exclusive, - }, - ); - } + let exclusive_cross_checks = (has_flatten || subcommand_segment.is_some()).then(|| { + quote! { + let __usage_exclusive_segments = [ + (#direct_given, #direct_exclusive), + #(#flattened_segments)* + #subcommand_segment + ]; + for __usage_i in 0..__usage_exclusive_segments.len() { + if let ::std::option::Option::Some(exclusive) = + __usage_exclusive_segments[__usage_i].1 + { + for __usage_j in 0..__usage_exclusive_segments.len() { + if __usage_i == __usage_j { + continue; + } + if let ::std::option::Option::Some(other) = + __usage_exclusive_segments[__usage_j].0 + { + return ::std::result::Result::Err( + ::usage_argv::Error::ConflictingFlags { + name: other, + other: exclusive, + }, + ); } } } } - }); + } + }); // Groups, checked once per group rather than per member: both questions a group asks // — how many members were given, and whether that is enough — are about the set. @@ -3670,6 +3763,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // the order `start` used to give them. #(#declared_defaults)* #env_fallbacks + let __usage_exclusive_present = #exclusive_present; #(#duplicate_checks)* // Before required-ness: "you gave two flags that cannot go together" is the // more useful of the two answers when a conflict has also left something @@ -3678,11 +3772,16 @@ fn post_binding(cli: &Cli) -> TokenStream { #(#exclusive_checks)* #exclusive_cross_checks #(#group_exclusivity_checks)* - #(#requirement_checks)* #(#flattened_checks)* - #(#required_checks)* - #(#group_required_checks)* - #(#relationship_required_checks)* + // An exclusive occurrence is the command's escape from requiredness, just as in clap: + // `--version` remains usable on a command that otherwise requires an input. Conflicts + // and other validation still ran above; only errors about absent siblings are skipped. + if !__usage_exclusive_present { + #(#requirement_checks)* + #(#required_checks)* + #(#group_required_checks)* + #(#relationship_required_checks)* + } #(#choice_checks)* #(#bound_checks)* #sub_check diff --git a/lib/src/parse.rs b/lib/src/parse.rs index f2505eb6b..0771c849b 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1179,12 +1179,34 @@ fn parse_partial_with_env( record_cursor(&mut out, next_arg_idx, seen_double_dash); + // `out.flags` is keyed by `SpecFlag`, whose equality is intentionally name-only. Two + // declarations with the same canonical name therefore share one public value entry even + // when both were typed. The spelling ledger is keyed by declaration identity and retains + // both, which is what exclusivity needs. + let flag_was_parsed = + |flag: &Arc| parsed_flag_spellings.contains_key(&(Arc::as_ptr(flag) as usize)); + + // clap's `exclusive` is also an escape from requiredness: `--version` has to work on a + // command that otherwise needs an input. Companions are still diagnosed below, but an + // exclusive occurrence suppresses the missing-value checks that would make it unusable + // whether it was alone or not. + let exclusive_present = + unique_flags(out.available_flags.values().chain(out.flags.keys())).any(|flag| { + flag.exclusive + && !overridden_flags.contains(&flag.name) + && (flag_was_parsed(flag) || flag_has_env(flag, custom_env)) + }); + // A command that says it needs a subcommand, given none. Checked on `out.cmd` and nowhere // else, because `out.cmd` *is* the command the words reached: had a subcommand been taken, // the child would be here instead. The spec has carried `subcommand_required` since it was // added for the derive, and this parser never read it — so `mise generate` parsed as a // complete invocation while usage-argv and clap both refused it. - if out.cmd.subcommand_required && !out.cmd.subcommands.is_empty() { + // + // An exclusive flag escapes it, as it escapes every other requirement: `--version` on a + // command that needs a subcommand is the shape the property exists for, and answering it + // with "which subcommand?" would make the flag unusable exactly where it is most wanted. + if out.cmd.subcommand_required && !out.cmd.subcommands.is_empty() && !exclusive_present { let mut names: Vec<&str> = out .cmd .subcommands @@ -1203,22 +1225,24 @@ fn parse_partial_with_env( // Not `skip(out.args.len())`: a `--` may have jumped the cursor past an arg that stayed // empty, so position and fill count can disagree. Ask `out.args` which args it holds. - for arg in out.cmd.args.iter() { - if out.args.contains_key(arg) { - continue; - } - // Already reported as needing a `--`; one mistake should not yield two messages. - if double_dash_violations.contains(&arg.name) { - continue; - } - if arg.required && arg.default.is_empty() { - // Check if there's an env var available (custom env map takes precedence) - let has_env = arg - .env - .as_ref() - .is_some_and(|env_var| env_contains(custom_env, env_var)); - if !has_env { - out.errors.push(UsageErr::MissingArg(arg.name.clone())); + if !exclusive_present { + for arg in out.cmd.args.iter() { + if out.args.contains_key(arg) { + continue; + } + // Already reported as needing a `--`; one mistake should not yield two messages. + if double_dash_violations.contains(&arg.name) { + continue; + } + if arg.required && arg.default.is_empty() { + // Check if there's an env var available (custom env map takes precedence) + let has_env = arg + .env + .as_ref() + .is_some_and(|env_var| env_contains(custom_env, env_var)); + if !has_env { + out.errors.push(UsageErr::MissingArg(arg.name.clone())); + } } } } @@ -1259,10 +1283,12 @@ fn parse_partial_with_env( // named it, which is what clap says too: an unmet `requires` is a required // argument that was not provided. Named by its own name, resolved through the // same matcher, so a `requires="-f"` reports `--force` rather than the selector. - for other in &flag.requires { - if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) { - let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone()); - out.errors.push(UsageErr::MissingFlag(name)); + if !exclusive_present { + for other in &flag.requires { + if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) { + let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone()); + out.errors.push(UsageErr::MissingFlag(name)); + } } } } @@ -1281,15 +1307,15 @@ fn parse_partial_with_env( // but re-declared aliases can leave distinct declarations with that same name in // scope. Exclusivity is about the declaration the typed spelling resolved to, so // compare the parser's `Arc`s by identity here. - let given = flag_was_parsed(&out, flag) || flag_has_env(flag, custom_env); + let given = flag_was_parsed(flag) || flag_has_env(flag, custom_env); if !flag.exclusive || !given || overridden_flags.contains(&flag.name) { continue; } let other_flag = unique_flags(out.available_flags.values().chain(out.flags.keys())) .find(|other| { - other.name != flag.name + !Arc::ptr_eq(other, flag) && !overridden_flags.contains(&other.name) - && (flag_was_parsed(&out, other) || flag_has_env(other, custom_env)) + && (flag_was_parsed(other) || flag_has_env(other, custom_env)) }) .map(|other| format!("--{}", other.name)); let other_arg = out.cmd.args.iter().find(|arg| { @@ -1309,7 +1335,7 @@ fn parse_partial_with_env( // dropped on descent. Compare the spellings as well as the canonical name when a // child re-declares an inherited global: the child can replace only one alias while // an invocation through another alias still points at the ancestor declaration. - let was_parsed = flag_was_parsed(&out, flag); + let was_parsed = flag_was_parsed(flag); let belongs_to_selected_command = out .available_flags .values() @@ -1398,7 +1424,7 @@ fn parse_partial_with_env( .members .iter() .any(|selector| selector_is_satisfied(selector, &out, &overridden_flags, custom_env)); - if group.required && !satisfied { + if group.required && !satisfied && !exclusive_present { // The members are what a user has to type, so they are in the message; the // group's name is there too, since a command with several groups would // otherwise report the same sentence twice with nothing to tell them apart. @@ -1410,23 +1436,24 @@ fn parse_partial_with_env( } out.errors.extend(group_errors); - for flag in unique_flags(out.available_flags.values()) { - if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { - continue; - } - let has_default = - !flag.default.is_empty() || flag.arg.iter().any(|a| !a.default.is_empty()); - let has_env = flag_has_env(flag, custom_env); - let required_if = flag - .required_if - .iter() - .any(|selector| selector_is_explicit(selector, &out, &overridden_flags, custom_env)); - let required_unless = !flag.required_unless.is_empty() - && !flag.required_unless.iter().any(|selector| { + if !exclusive_present { + for flag in unique_flags(out.available_flags.values()) { + if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { + continue; + } + let has_default = + !flag.default.is_empty() || flag.arg.iter().any(|a| !a.default.is_empty()); + let has_env = flag_has_env(flag, custom_env); + let required_if = flag.required_if.iter().any(|selector| { selector_is_explicit(selector, &out, &overridden_flags, custom_env) }); - if (flag.required || required_if || required_unless) && !has_default && !has_env { - out.errors.push(UsageErr::MissingFlag(flag.name.clone())); + let required_unless = !flag.required_unless.is_empty() + && !flag.required_unless.iter().any(|selector| { + selector_is_explicit(selector, &out, &overridden_flags, custom_env) + }); + if (flag.required || required_if || required_unless) && !has_default && !has_env { + out.errors.push(UsageErr::MissingFlag(flag.name.clone())); + } } } @@ -1527,10 +1554,6 @@ fn flag_has_env(flag: &SpecFlag, custom_env: Option<&HashMap>) - .is_some_and(|env_var| env_contains(custom_env, env_var)) } -fn flag_was_parsed(out: &ParseOutput, flag: &Arc) -> bool { - out.flags.keys().any(|parsed| Arc::ptr_eq(parsed, flag)) -} - fn selector_is_explicit( selector: &str, out: &ParseOutput, @@ -3043,6 +3066,21 @@ flag "--file " required_unless="--stdin" assert!(parse(&spec, &input(&["ex", "--dump", "--jobs", "8"])).is_err()); } + #[test] + fn an_exclusive_flag_bypasses_required_siblings() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out \" required=#true\narg \"\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])) + .expect("exclusive is the command's requiredness escape"); + assert!(parse( + &spec, + &input(&["ex", "--dump", "--out", "somewhere", "target"]) + ) + .is_err()); + } + #[test] fn an_environment_value_counts_for_an_exclusive_flag() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out \" env=\"EX_OUT\"\n" @@ -3091,6 +3129,19 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn a_same_named_parent_flag_is_company_for_a_child_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" global=#true exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])).expect("the child exclusive flag is alone"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run", "--clean"])).is_err(), + "the distinct parent declaration is still company despite sharing a name" + ); + } + #[test] fn an_orphan_parent_alias_does_not_disown_a_child_local_exclusive_flag() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" From 0a5182983ec5643682205b7bee8e7e408dad62ed Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:04:08 +0000 Subject: [PATCH 10/17] fix(derive): enforce exclusive flag boundaries --- argv/src/spec.rs | 12 +++++- conformance/tests/post_binding_env.rs | 55 ++++++++++++++++++++++++++- derive/src/codegen.rs | 31 +++++++++++++++ derive/src/model.rs | 35 +++++++++++++++++ 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 8e4228eef..ee654e8a1 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1556,8 +1556,8 @@ pub trait CommandArgs: Sized { /// One exclusive flag in this command that was given, if any. /// /// Like [`CommandArgs::any_given`], this is the composition point for flattened argument - /// groups. An exclusive flag in a selected subcommand belongs to that subcommand rather - /// than to its parent, so implementations do not propagate it across that boundary. + /// groups and selected subcommands. Parents need the latter to apply whole-invocation + /// exclusivity and its requiredness escape across a command boundary. fn exclusive_given(partial: &Self::Partial) -> Option<&'static str> { let _ = partial; None @@ -1673,6 +1673,14 @@ pub trait Subcommands: Sized { None } + /// Fill fields in the selected command from their declared environment variables. + /// + /// A parent calls this before relationships that cross the subcommand boundary, just as + /// [`CommandArgs::apply_env`] prepares a flattened argument group. + fn apply_env(partial: &mut Self::Partial, selected: Option) { + let _ = (partial, selected); + } + /// Check the selected command's requirements, and nothing else's. /// /// A flag that `install` requires says nothing about an invocation that ran diff --git a/conformance/tests/post_binding_env.rs b/conformance/tests/post_binding_env.rs index 6431c0039..e9b52b6dc 100644 --- a/conformance/tests/post_binding_env.rs +++ b/conformance/tests/post_binding_env.rs @@ -9,7 +9,8 @@ use std::ffi::OsStr; -use usage_derive::{Args, Cli}; +use usage_argv::Error; +use usage_derive::{Args, Cli, Subcommands}; fn argv(tokens: [&str; N]) -> [&OsStr; N] { tokens.map(OsStr::new) @@ -175,6 +176,58 @@ fn flattened_environment_values_are_visible_to_cross_boundary_exclusivity() { assert!(parsed.extra.dump); } +#[derive(Args)] +struct ChildEnvExclusive { + /// Dump and leave + #[usage(long, exclusive, env = "CHILD_EXCLUSIVE_ENV_DUMP")] + dump: bool, +} + +#[derive(Subcommands)] +enum ChildEnvExclusiveCommands { + Run(ChildEnvExclusive), +} + +#[derive(Cli)] +#[usage(bin = "child-exclusive-env")] +struct ParentOfChildEnvExclusive { + /// Print more + #[usage(long)] + verbose: bool, + /// Required unless an exclusive flag ends the invocation + #[usage(long)] + out: String, + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_selected_child_environment_value_is_visible_to_parent_exclusivity() { + unsafe { std::env::set_var("CHILD_EXCLUSIVE_ENV_DUMP", "1") }; + + let a = argv(["run"]); + let parsed = ParentOfChildEnvExclusive::parse_from(&a) + .expect("the child exclusive environment value bypasses parent requiredness"); + assert!(!parsed.verbose); + assert!(parsed.out.is_empty()); + let Some(ChildEnvExclusiveCommands::Run(child)) = parsed.command else { + panic!("the selected child should be built"); + }; + assert!( + child.dump, + "the environment value should reach the child field" + ); + + let a = argv(["--verbose", "run"]); + assert!(matches!( + ParentOfChildEnvExclusive::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + | Err(Error::ConflictingFlags { name: "dump", .. }) + )); + + unsafe { std::env::remove_var("CHILD_EXCLUSIVE_ENV_DUMP") }; +} + #[test] fn a_displaced_flag_is_not_revived_by_its_environment_variable() { // The command line says `--stdin` came last, so `--file` lost. Filling it from the diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index ded14dfb9..914836023 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2834,6 +2834,15 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }); + let apply_envs = subs.variants.iter().enumerate().map(|(i, v)| { + let field = format_ident!("v{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + <#ty as ::usage_argv::spec::CommandArgs>::apply_env(&mut partial.#field); + } + } + }); let selects = subs.variants.iter().enumerate().map(|(i, v)| { let held = format_ident!("V{i}"); let variant = &v.ident; @@ -2971,6 +2980,16 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } + fn apply_env( + partial: &mut Self::Partial, + selected: ::std::option::Option, + ) { + match selected { + #(#apply_envs)* + _ => {} + } + } + fn check<'t, 'v>( partial: &mut Self::Partial, selected: usize, @@ -3204,9 +3223,21 @@ fn env_fallbacks(cli: &Cli) -> TokenStream { <#ty as ::usage_argv::spec::CommandArgs>::apply_env(&mut partial.#ident); }) }); + let selected = cli.fields.iter().find_map(|f| { + let Kind::Subcommand { ty, .. } = &f.kind else { + return None; + }; + Some(quote! { + <#ty as ::usage_argv::spec::Subcommands>::apply_env( + &mut partial.__usage_sub, + partial.__usage_selected, + ); + }) + }); quote! { #(#own)* #(#flattened)* + #selected } } diff --git a/derive/src/model.rs b/derive/src/model.rs index 431edff3a..013727c80 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -1824,6 +1824,17 @@ impl Field { )); } + // `exclusive` is represented by flag metadata and enforced for a flag occurrence. + // Accepting it on a positional would make the derive enforce a rule that its emitted + // spec and documentation silently omit. + if exclusive && !matches!(kind, Kind::Flag { .. }) { + return Err(syn::Error::new( + span, + "`exclusive` describes a flag that has to be given on its own; a positional \ + argument cannot carry it — add `long` or `short` to make this field a flag", + )); + } + // `value_name` names the placeholder a *flag's value* gets in help — `--out `. // A positional argument is named by `name`, and a `bool` or `count` flag has no value // to put a placeholder in, so `arg_meta` never emits it and a valueless flag has nowhere @@ -2905,6 +2916,30 @@ mod tests { ); } + #[test] + fn exclusive_is_refused_on_a_positional() { + let err = rejection( + r#" + struct Ex { + #[usage(exclusive)] + target: String, + } + "#, + ); + assert!( + err.contains("`exclusive` describes a flag"), + "unhelpful message: {err}" + ); + + cli(r#" + struct Ex { + #[usage(long, exclusive)] + dump: bool, + } + "#) + .expect("exclusive remains valid on a flag"); + } + #[test] fn a_selector_naming_nothing_is_a_compile_error() { let err = rejection( From b2eab3d7f7cce8bd40e17b1fab4e71860e6e8575 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:21:19 +0000 Subject: [PATCH 11/17] fix(parse): preserve ancestor exclusivity across aliases --- lib/src/parse.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 0771c849b..6b9fe30d2 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1348,12 +1348,20 @@ fn parse_partial_with_env( parsed_flag_spellings .get(&(Arc::as_ptr(flag) as usize)) .is_some_and(|spellings| { - out.cmd.flags.iter().any(|declared| { - declared.name == flag.name - && flag_keys(declared) - .iter() - .any(|spelling| spellings.contains(spelling)) - }) + let child_spellings: HashSet = out + .cmd + .flags + .iter() + .filter(|declared| declared.name == flag.name) + .flat_map(flag_keys) + .collect(); + // Every occurrence must belong to the child. A merged declaration can + // collect both an ancestor-only `-c` and the child's `--clean`; treating + // one child spelling as ownership of the whole set hid the ancestor's + // exclusivity from the selected subcommand. + spellings + .iter() + .all(|spelling| child_spellings.contains(spelling)) }) } else { // An environment value has no typed spelling. Preserve the declaration @@ -3154,6 +3162,10 @@ flag "--file " required_unless="--stdin" parse(&spec, &input(&["ex", "run", "-c"])).is_err(), "the inherited short form still belongs to the ancestor" ); + assert!( + parse(&spec, &input(&["ex", "run", "-c", "--clean"])).is_err(), + "a child spelling cannot mask the ancestor-exclusive occurrence on the same merged flag" + ); } #[test] From 73ae53618e04cf174481b41267830e319fe8fedc Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:52:32 +0000 Subject: [PATCH 12/17] fix(derive): route exclusivity through facade --- derive/src/codegen.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 914836023..2d7065cc1 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1416,7 +1416,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { let ident = &field.ident; Some(quote! { if let ::std::option::Option::Some(name) = - <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#ident) + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident) { return ::std::option::Option::Some(name); } @@ -1452,7 +1452,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { let ident = &field.ident; Some(quote! { if let ::std::option::Option::Some(name) = - <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) { return ::std::option::Option::Some(name); } @@ -1464,7 +1464,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { }; Some(quote! { if let ::std::option::Option::Some(name) = - <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + <#ty as usage_argv::spec::Subcommands>::exclusive_given( &partial.__usage_sub, partial.__usage_selected, ) @@ -2821,7 +2821,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#field) + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#field) } } }); @@ -2830,7 +2830,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#field) + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#field) } } }); @@ -2839,7 +2839,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as ::usage_argv::spec::CommandArgs>::apply_env(&mut partial.#field); + <#ty as usage_argv::spec::CommandArgs>::apply_env(&mut partial.#field); } } }); @@ -3220,7 +3220,7 @@ fn env_fallbacks(cli: &Cli) -> TokenStream { }; let ident = &f.ident; Some(quote! { - <#ty as ::usage_argv::spec::CommandArgs>::apply_env(&mut partial.#ident); + <#ty as usage_argv::spec::CommandArgs>::apply_env(&mut partial.#ident); }) }); let selected = cli.fields.iter().find_map(|f| { @@ -3228,7 +3228,7 @@ fn env_fallbacks(cli: &Cli) -> TokenStream { return None; }; Some(quote! { - <#ty as ::usage_argv::spec::Subcommands>::apply_env( + <#ty as usage_argv::spec::Subcommands>::apply_env( &mut partial.__usage_sub, partial.__usage_selected, ); @@ -3262,7 +3262,7 @@ fn post_binding(cli: &Cli) -> TokenStream { }; let ident = &field.ident; Some(quote! { - <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident).is_some() + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident).is_some() }) }); let selected_exclusive_present = cli.fields.iter().filter_map(|field| { @@ -3270,7 +3270,7 @@ fn post_binding(cli: &Cli) -> TokenStream { return None; }; Some(quote! { - <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + <#ty as usage_argv::spec::Subcommands>::exclusive_given( &partial.__usage_sub, partial.__usage_selected, ).is_some() @@ -3558,7 +3558,7 @@ fn post_binding(cli: &Cli) -> TokenStream { quote! { if partial.#given && partial.#other_given { return ::std::result::Result::Err( - ::usage_argv::Error::ConflictingFlags { + usage_argv::Error::ConflictingFlags { name: #other_name, other: #name, }, @@ -3605,8 +3605,8 @@ fn post_binding(cli: &Cli) -> TokenStream { let ident = &field.ident; Some(quote! { ( - <#ty as ::usage_argv::spec::CommandArgs>::any_given(&partial.#ident), - <#ty as ::usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident), + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), ), }) }); @@ -3618,7 +3618,7 @@ fn post_binding(cli: &Cli) -> TokenStream { Some(quote! { ( partial.__usage_selected.map(|_| #name), - <#ty as ::usage_argv::spec::Subcommands>::exclusive_given( + <#ty as usage_argv::spec::Subcommands>::exclusive_given( &partial.__usage_sub, partial.__usage_selected, ), @@ -3644,7 +3644,7 @@ fn post_binding(cli: &Cli) -> TokenStream { __usage_exclusive_segments[__usage_j].0 { return ::std::result::Result::Err( - ::usage_argv::Error::ConflictingFlags { + usage_argv::Error::ConflictingFlags { name: other, other: exclusive, }, From 777c232e89e145b2d6cf3113d1b75247eff09dc3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:18:03 +0000 Subject: [PATCH 13/17] fix(parse): preserve exclusive merge semantics --- argv/src/spec.rs | 9 +++++ conformance/tests/post_binding.rs | 26 ++++++++++++++ derive/src/codegen.rs | 59 ++++++++++++++++++++++--------- lib/src/parse.rs | 19 ++++++++++ 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index ee654e8a1..770c8ccd6 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1563,6 +1563,15 @@ pub trait CommandArgs: Sized { None } + /// Fill fields in this command from their declared defaults. + /// + /// Kept separate from [`CommandArgs::check`] so a parent can preserve defaults in a + /// flattened argument group while an unrelated exclusive flag suppresses only that + /// group's missing-value checks. + fn apply_defaults(partial: &mut Self::Partial) { + let _ = partial; + } + /// Fill fields in this command from their declared environment variables. /// /// A parent calls this before relationships that cross a flattened `CommandArgs` diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index fbed931d8..9f15c16fb 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -885,6 +885,23 @@ struct ExclusiveBesideFlatten { extra: ExtraOutput, } +#[derive(Args)] +struct FlattenedDefault { + /// How many jobs to run + #[usage(long, default = "4")] + jobs: u8, +} + +#[derive(Cli)] +#[usage(bin = "flat-default-ex")] +struct ExclusiveBesideFlattenedDefault { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: FlattenedDefault, +} + #[derive(Args)] struct FlattenedExclusive { /// Dump and leave @@ -927,6 +944,15 @@ fn flattening_does_not_hide_either_side_of_exclusivity() { assert!(parsed.extra.dump); } +#[test] +fn an_exclusive_flag_does_not_skip_flattened_defaults() { + let a = argv(["--dump"]); + let parsed = ExclusiveBesideFlattenedDefault::parse_from(&a) + .expect("exclusive suppresses requiredness, not declared defaults"); + assert!(parsed.dump); + assert_eq!(parsed.extra.jobs, 4); +} + #[derive(Cli)] #[usage(bin = "sub-ex")] struct ExclusiveBesideSubcommand { diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 2d7065cc1..03c944652 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2373,6 +2373,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); let presence = presence_methods(cli); + let apply_defaults = declared_defaults(cli); let apply_env = env_fallbacks(cli); // A group carries settings the same way a root does, minus the layer: `SettingGiven` is // usage-argv's own vocabulary, so a flattened group can hand its parent what it was given @@ -2600,6 +2601,10 @@ pub fn emit_args(cli: &Cli) -> TokenStream { #presence + fn apply_defaults(partial: &mut Self::Partial) { + #apply_defaults + } + fn apply_env(partial: &mut Self::Partial) { #apply_env } @@ -3160,6 +3165,39 @@ fn group_meta_table(cli: &Cli) -> (TokenStream, TokenStream) { ) } +/// Apply declared defaults to this command and every argument group flattened into it. +/// +/// Separate from the rest of `check` because exclusivity suppresses requiredness, not values a +/// CLI promised to provide by default. A parent can therefore prepare an opaque flattened partial +/// without also asking it to report a missing sibling. +fn declared_defaults(cli: &Cli) -> TokenStream { + let own = cli.fields.iter().filter_map(|f| { + if f.default.is_empty() || matches!(f.kind, Kind::Subcommand { .. }) { + return None; + } + let given = format_ident!("__given_{}", f.ident); + let assign = reset_to_default(f); + Some(quote! { + if !partial.#given { + #assign + } + }) + }); + let flattened = cli.fields.iter().filter_map(|f| { + let Kind::Flatten { ty } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + <#ty as usage_argv::spec::CommandArgs>::apply_defaults(&mut partial.#ident); + }) + }); + quote! { + #(#own)* + #(#flattened)* + } +} + /// Environment fallbacks for this command and every argument group flattened into it. /// /// Kept separate from the rest of post-binding validation because a parent must apply these @@ -3321,22 +3359,9 @@ fn post_binding(cli: &Cli) -> TokenStream { // partial for *every* command in the CLI, selected or not, so a declared default was // costing a `String` per default per command — 60 allocations to parse a bare `mise`, // which is the CLI's size leaking into the invocation. `check` runs for the selected - // command only. - // - // Guarded on `__given_*`, which is what makes this safe to move: a negation that set a - // defaulted `bool` to false during the parse must not be undone here. - let declared_defaults = cli.fields.iter().filter_map(|f| { - if f.default.is_empty() || matches!(f.kind, Kind::Subcommand { .. }) { - return None; - } - let given = format_ident!("__given_{}", f.ident); - let assign = reset_to_default(f); - Some(quote! { - if !partial.#given { - #assign - } - }) - }); + // command only. The helper also prepares flattened defaults before an exclusive flag can + // suppress those groups' requiredness checks. + let declared_defaults = declared_defaults(cli); let env_fallbacks = env_fallbacks(cli); @@ -3792,7 +3817,7 @@ fn post_binding(cli: &Cli) -> TokenStream { quote! { // Before the environment, which overrides a default when the flag was not given — // the order `start` used to give them. - #(#declared_defaults)* + #declared_defaults #env_fallbacks let __usage_exclusive_present = #exclusive_present; #(#duplicate_checks)* diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 6b9fe30d2..dad42693c 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -109,6 +109,11 @@ fn merge_subcommand_flags( Some(merged) => merged.clone(), None => { let mut merged = (*global_flag).clone(); + // The child declaration owns behavior at the command it belongs to. The + // inherited object is retained only so the merged aliases remain global; + // keeping its exclusivity would silently discard a child's `exclusive` + // redeclaration (or retain one the child explicitly removed). + merged.exclusive = flag.exclusive; for s in &flag.short { if !merged.short.contains(s) { merged.short.push(*s); @@ -3150,6 +3155,20 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn a_local_child_redeclaration_keeps_its_exclusivity_when_merged() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child exclusive flag is valid alone"); + assert!( + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "merging with the inherited global must not discard child exclusivity" + ); + } + #[test] fn an_orphan_parent_alias_does_not_disown_a_child_local_exclusive_flag() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" From 1439f52e9ff63ec6a77303e59e9d48f29ee5c7f3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:37:07 +0000 Subject: [PATCH 14/17] fix(parse): resolve exclusivity by the spelling that was typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A child re-declaring an inherited global merges into one flag object carrying both alias sets. Assigning the child's `exclusive` onto that object answered for the ancestor's aliases too, so a local child that omitted `exclusive` let an orphan ancestor alias through beside a selected subcommand — the opposite asymmetry from the one the previous commit fixed, and inconsistent with a global child re-declaration, which leaves those spellings exclusive. A single bool cannot hold two declarations' answers. The merged flag keeps the ancestor's, and exclusivity is resolved per occurrence through the same spelling ledger that already decides whether selecting the child is company — so the two questions cannot drift apart. The derive never had this to reconcile, keeping the declarations as separate fields; a conformance test now holds both to it. Co-Authored-By: Claude Opus 5 --- conformance/tests/post_binding.rs | 48 +++++++++ lib/src/parse.rs | 159 ++++++++++++++++++++---------- 2 files changed, 153 insertions(+), 54 deletions(-) diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index 9f15c16fb..ba743c505 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -1020,3 +1020,51 @@ fn a_child_exclusive_flag_counts_parent_flags_as_company() { | Err(Error::ConflictingFlags { name: "dump", .. }) )); } + +#[allow(dead_code)] +#[derive(Args)] +struct RedeclaredClean { + /// Clean, as this command means it + #[usage(long = "clean")] + clean: bool, + /// Say more + #[usage(long)] + verbose: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum RedeclaredCleanCommands { + Run(RedeclaredClean), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "orphan-alias-ex")] +struct OrphanAliasExclusive { + /// Clean everything and leave + #[usage(short = 'c', long, global, exclusive)] + clean: bool, + #[usage(subcommand)] + command: Option, +} + +/// A child that re-declares only the long form of an inherited global leaves the short alias +/// with the ancestor — and the ancestor's `exclusive` goes with it. The derive keeps the two +/// declarations as separate fields, so it never had to reconcile them; this holds usage-lib, +/// which merges them into one flag, to the same answer. +#[test] +fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_child_redeclaration() { + let a = argv(["run", "-c"]); + assert!( + matches!( + OrphanAliasExclusive::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + ), + "the ancestor's own spelling is still its exclusive flag" + ); + + let a = argv(["run", "--clean", "--verbose"]); + OrphanAliasExclusive::parse_from(&a) + .expect("the child's spelling drops the exclusivity the child did not restate"); +} diff --git a/lib/src/parse.rs b/lib/src/parse.rs index dad42693c..47a777480 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -109,11 +109,13 @@ fn merge_subcommand_flags( Some(merged) => merged.clone(), None => { let mut merged = (*global_flag).clone(); - // The child declaration owns behavior at the command it belongs to. The - // inherited object is retained only so the merged aliases remain global; - // keeping its exclusivity would silently discard a child's `exclusive` - // redeclaration (or retain one the child explicitly removed). - merged.exclusive = flag.exclusive; + // `exclusive` is deliberately *not* reconciled here, in either direction. + // One object now answers to two alias sets that may disagree: the child + // owns the spellings it declared, the ancestor keeps the ones only it + // declared. A single bool cannot hold both, so the merged flag carries the + // ancestor's and validation resolves the occurrence by the spelling that + // was typed — the ledger it already consults to decide whether selecting + // the child is company. for s in &flag.short { if !merged.short.contains(s) { merged.short.push(*s); @@ -1191,13 +1193,77 @@ fn parse_partial_with_env( let flag_was_parsed = |flag: &Arc| parsed_flag_spellings.contains_key(&(Arc::as_ptr(flag) as usize)); + // Which declaration an occurrence belongs to: the selected command's own, or an ancestor's. + // + // Identity matters here, not only the canonical name: a parent and child may each declare + // their own non-global `--clean`. The selected command's flag is still in `available_flags` + // under the same Arc the parse recorded; a non-global ancestor was dropped on descent. + // Compare the spellings as well as the canonical name when a child re-declares an inherited + // global: the child can replace only one alias while an invocation through another alias + // still points at the ancestor declaration. + let owned_by_selected_command = |flag: &Arc| { + out.available_flags + .values() + .any(|available| Arc::ptr_eq(available, flag)) + && if flag_was_parsed(flag) { + // Ask about the form that was actually typed. The merged flag may be a + // superset of the child's declaration because it also carries an orphan + // ancestor alias, so comparing the complete alias sets misattributes both + // `--clean` and `-c`. + parsed_flag_spellings + .get(&(Arc::as_ptr(flag) as usize)) + .is_some_and(|spellings| { + let child_spellings: HashSet = out + .cmd + .flags + .iter() + .filter(|declared| declared.name == flag.name) + .flat_map(flag_keys) + .collect(); + // Every occurrence must belong to the child. A merged declaration can + // collect both an ancestor-only `-c` and the child's `--clean`; treating + // one child spelling as ownership of the whole set hid the ancestor's + // exclusivity from the selected subcommand. + spellings + .iter() + .all(|spelling| child_spellings.contains(spelling)) + }) + } else { + // An environment value has no typed spelling. Preserve the declaration + // identity check for that path. + out.cmd.flags.iter().any(|declared| { + declared.name == flag.name + && declared.short == flag.short + && declared.long == flag.long + && declared.negate == flag.negate + }) + } + }; + + // Exclusivity belongs to the declaration a spelling resolved to, not to the object that + // carries it. A merged flag holds the ancestor's `exclusive`, since it is a clone of the + // inherited global; when the occurrence is the child's, the child's own declaration is the + // one that answers — which is how a child both adds exclusivity the ancestor never had and + // drops exclusivity it did not restate, without either answer leaking onto the other's + // aliases. + let exclusive_occurrence = |flag: &Arc| { + if owned_by_selected_command(flag) { + out.cmd + .flags + .iter() + .any(|declared| declared.name == flag.name && declared.exclusive) + } else { + flag.exclusive + } + }; + // clap's `exclusive` is also an escape from requiredness: `--version` has to work on a // command that otherwise needs an input. Companions are still diagnosed below, but an // exclusive occurrence suppresses the missing-value checks that would make it unusable // whether it was alone or not. let exclusive_present = unique_flags(out.available_flags.values().chain(out.flags.keys())).any(|flag| { - flag.exclusive + exclusive_occurrence(flag) && !overridden_flags.contains(&flag.name) && (flag_was_parsed(flag) || flag_has_env(flag, custom_env)) }); @@ -1313,7 +1379,7 @@ fn parse_partial_with_env( // scope. Exclusivity is about the declaration the typed spelling resolved to, so // compare the parser's `Arc`s by identity here. let given = flag_was_parsed(flag) || flag_has_env(flag, custom_env); - if !flag.exclusive || !given || overridden_flags.contains(&flag.name) { + if !exclusive_occurrence(flag) || !given || overridden_flags.contains(&flag.name) { continue; } let other_flag = unique_flags(out.available_flags.values().chain(out.flags.keys())) @@ -1332,54 +1398,10 @@ fn parse_partial_with_env( }); // Selecting a child is company for an exclusive flag declared by an ancestor. An // exclusive flag belonging to the child itself does not conflict with the command - // word needed to reach that child. - // - // Identity matters here, not only the canonical name: a parent and child may each - // declare their own non-global `--clean`. The selected command's flag is still in - // `available_flags` under the same Arc the parse recorded; a non-global ancestor was - // dropped on descent. Compare the spellings as well as the canonical name when a - // child re-declares an inherited global: the child can replace only one alias while - // an invocation through another alias still points at the ancestor declaration. - let was_parsed = flag_was_parsed(flag); - let belongs_to_selected_command = out - .available_flags - .values() - .any(|available| Arc::ptr_eq(available, flag)) - && if was_parsed { - // Ask about the form that was actually typed. The merged flag may be a - // superset of the child's declaration because it also carries an orphan - // ancestor alias, so comparing the complete alias sets misattributes both - // `--clean` and `-c`. - parsed_flag_spellings - .get(&(Arc::as_ptr(flag) as usize)) - .is_some_and(|spellings| { - let child_spellings: HashSet = out - .cmd - .flags - .iter() - .filter(|declared| declared.name == flag.name) - .flat_map(flag_keys) - .collect(); - // Every occurrence must belong to the child. A merged declaration can - // collect both an ancestor-only `-c` and the child's `--clean`; treating - // one child spelling as ownership of the whole set hid the ancestor's - // exclusivity from the selected subcommand. - spellings - .iter() - .all(|spelling| child_spellings.contains(spelling)) - }) - } else { - // An environment value has no typed spelling. Preserve the declaration - // identity check for that path. - out.cmd.flags.iter().any(|declared| { - declared.name == flag.name - && declared.short == flag.short - && declared.long == flag.long - && declared.negate == flag.negate - }) - }; + // word needed to reach that child. Same ownership question `exclusive_occurrence` + // asked to decide whose `exclusive` applies, so the two cannot drift apart. let selected_subcommand = - (out.cmds.len() > 1 && !belongs_to_selected_command).then(|| out.cmd.name.clone()); + (out.cmds.len() > 1 && !owned_by_selected_command(flag)).then(|| out.cmd.name.clone()); let other = other_flag .or_else(|| other_arg.map(|arg| format!("<{}>", arg.name))) .or(selected_subcommand); @@ -3213,6 +3235,35 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_plain_child_redeclaration() { + // The mirror of `a_local_child_redeclaration_keeps_its_exclusivity_when_merged`: the + // child owns `--clean` and says nothing about exclusivity, but `-c` is a spelling only + // the ancestor ever declared, so the ancestor's answer still governs it. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the orphan ancestor alias is still the ancestor's exclusive flag" + ); + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])) + .expect("the child's own spelling drops the exclusivity the child did not restate"); + } + + #[test] + fn a_merged_child_exclusive_flag_still_escapes_requiredness() { + // Exclusivity suppresses missing-value checks, and that has to survive the merge for + // the same reason the companion check does. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--out \" required=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("a merged child exclusive flag is still the command's requiredness escape"); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" From 62844500ab924917f5677e631058f08eb0b8fbc6 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:50:34 +0000 Subject: [PATCH 15/17] fix(parse): attribute exclusivity per spelling, not per occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ownership was still one boolean for the whole occurrence, and two cases need finer grain than that. An ancestor-only alias typed beside a child-exclusive spelling attributed everything to the ancestor, so the child's exclusivity never fired — `run -c --clean --verbose` was accepted. And an environment value, which has no spelling to attribute it by, was matched against the merged flag's whole alias set, which a child re-declaring one alias can never equal; the ancestor answered for it in both directions. `exclusivity_in_play` now returns the two sides separately: the child's, for the spellings the child declared, and the ancestor's, for the ones only it declared. Both can be in play at once, which is exactly the mixed-alias case. An environment value takes the declaration the selected command has in scope. The subcommand-as-company question asks which exclusivity is being enforced rather than who owns the flag, which is the thing it actually meant. Co-Authored-By: Claude Opus 5 --- conformance/tests/post_binding.rs | 45 +++++++++ lib/src/parse.rs | 157 ++++++++++++++++++------------ 2 files changed, 142 insertions(+), 60 deletions(-) diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index ba743c505..880a386ea 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -1068,3 +1068,48 @@ fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_child_redeclaration() { OrphanAliasExclusive::parse_from(&a) .expect("the child's spelling drops the exclusivity the child did not restate"); } + +#[allow(dead_code)] +#[derive(Args)] +struct ExclusiveRedeclaredClean { + /// Clean, and nothing else + #[usage(long = "clean", exclusive)] + clean: bool, + /// Say more + #[usage(long)] + verbose: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum ExclusiveRedeclaredCleanCommands { + Run(ExclusiveRedeclaredClean), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "mixed-alias-ex")] +struct MixedAliasExclusive { + /// Clean everything + #[usage(short = 'c', long, global)] + clean: bool, + #[usage(subcommand)] + command: Option, +} + +/// The other direction, and both aliases at once: the child's spelling is exclusive whatever it +/// was typed beside, so an ancestor-only alias in the same invocation cannot excuse a companion. +#[test] +fn a_child_spelling_stays_exclusive_beside_an_ancestor_spelling() { + let a = argv(["run", "-c", "--clean", "--verbose"]); + assert!( + matches!( + MixedAliasExclusive::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + ), + "the child's exclusive spelling was given, so --verbose is company" + ); + + let a = argv(["run", "-c", "--verbose"]); + MixedAliasExclusive::parse_from(&a).expect("the ancestor's own spelling was never exclusive"); +} diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 47a777480..b7f601957 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1193,70 +1193,66 @@ fn parse_partial_with_env( let flag_was_parsed = |flag: &Arc| parsed_flag_spellings.contains_key(&(Arc::as_ptr(flag) as usize)); - // Which declaration an occurrence belongs to: the selected command's own, or an ancestor's. + // The spellings the selected command's own declaration speaks for, on this object. // - // Identity matters here, not only the canonical name: a parent and child may each declare - // their own non-global `--clean`. The selected command's flag is still in `available_flags` - // under the same Arc the parse recorded; a non-global ancestor was dropped on descent. - // Compare the spellings as well as the canonical name when a child re-declares an inherited - // global: the child can replace only one alias while an invocation through another alias - // still points at the ancestor declaration. - let owned_by_selected_command = |flag: &Arc| { - out.available_flags - .values() - .any(|available| Arc::ptr_eq(available, flag)) - && if flag_was_parsed(flag) { - // Ask about the form that was actually typed. The merged flag may be a - // superset of the child's declaration because it also carries an orphan - // ancestor alias, so comparing the complete alias sets misattributes both - // `--clean` and `-c`. - parsed_flag_spellings - .get(&(Arc::as_ptr(flag) as usize)) - .is_some_and(|spellings| { - let child_spellings: HashSet = out - .cmd - .flags - .iter() - .filter(|declared| declared.name == flag.name) - .flat_map(flag_keys) - .collect(); - // Every occurrence must belong to the child. A merged declaration can - // collect both an ancestor-only `-c` and the child's `--clean`; treating - // one child spelling as ownership of the whole set hid the ancestor's - // exclusivity from the selected subcommand. - spellings - .iter() - .all(|spelling| child_spellings.contains(spelling)) - }) - } else { - // An environment value has no typed spelling. Preserve the declaration - // identity check for that path. - out.cmd.flags.iter().any(|declared| { - declared.name == flag.name - && declared.short == flag.short - && declared.long == flag.long - && declared.negate == flag.negate - }) - } + // Empty unless that declaration really is this object's: a parent and child may each + // declare `--clean` without merging, leaving two flags that share a name, and the + // ancestor's must not be read as the child's. The test is whether every spelling the child + // declared resolves back here — true of a merged flag, and of a plain local one, but not of + // an ancestor whose long form the child took over. + let child_spellings = |flag: &Arc| -> HashSet { + let declared: HashSet = out + .cmd + .flags + .iter() + .filter(|declared| declared.name == flag.name) + .flat_map(flag_keys) + .collect(); + let speaks_for_this_flag = !declared.is_empty() + && declared.iter().all(|spelling| { + out.available_flags + .get(spelling) + .is_some_and(|available| Arc::ptr_eq(available, flag)) + }); + if speaks_for_this_flag { + declared + } else { + HashSet::new() + } }; - // Exclusivity belongs to the declaration a spelling resolved to, not to the object that - // carries it. A merged flag holds the ancestor's `exclusive`, since it is a clone of the - // inherited global; when the occurrence is the child's, the child's own declaration is the - // one that answers — which is how a child both adds exclusivity the ancestor never had and - // drops exclusivity it did not restate, without either answer leaking onto the other's - // aliases. - let exclusive_occurrence = |flag: &Arc| { - if owned_by_selected_command(flag) { - out.cmd + // Whose `exclusive` an occurrence activates, as `(the child's, an ancestor's)`. + // + // A child that re-declares an inherited global merges into one object answering to two + // alias sets whose declarations may disagree, so there is no single owner to name: the + // child owns the spellings it declared and the ancestor keeps the ones only it declared. + // Both sides can be in play at once — `run -c --clean` is the ancestor's alias and the + // child's in one invocation — and each carries its own declaration's answer. + let exclusivity_in_play = |flag: &Arc| -> (bool, bool) { + let child = child_spellings(flag); + let child_exclusive = !child.is_empty() + && out + .cmd .flags .iter() - .any(|declared| declared.name == flag.name && declared.exclusive) - } else { - flag.exclusive + .any(|declared| declared.name == flag.name && declared.exclusive); + match parsed_flag_spellings.get(&(Arc::as_ptr(flag) as usize)) { + Some(spellings) => ( + child_exclusive && spellings.iter().any(|s| child.contains(s)), + flag.exclusive && spellings.iter().any(|s| !child.contains(s)), + ), + // An environment value has no spelling to attribute it by. The declaration the + // selected command has in scope is the one that answers — which is the child's + // when it re-declared the flag, and the ancestor's when it did not. + None => (child_exclusive, flag.exclusive && child.is_empty()), } }; + let exclusive_occurrence = |flag: &Arc| { + let (child, ancestor) = exclusivity_in_play(flag); + child || ancestor + }; + // clap's `exclusive` is also an escape from requiredness: `--version` has to work on a // command that otherwise needs an input. Companions are still diagnosed below, but an // exclusive occurrence suppresses the missing-value checks that would make it unusable @@ -1397,11 +1393,13 @@ fn parse_partial_with_env( .is_some_and(|env| env_contains(custom_env, env)) }); // Selecting a child is company for an exclusive flag declared by an ancestor. An - // exclusive flag belonging to the child itself does not conflict with the command - // word needed to reach that child. Same ownership question `exclusive_occurrence` - // asked to decide whose `exclusive` applies, so the two cannot drift apart. + // exclusive flag belonging to the child itself does not conflict with the command word + // needed to reach that child — so the question is not who owns the flag but whose + // exclusivity is the one being enforced, which is what `exclusivity_in_play` already + // separated. + let (_, ancestor_exclusivity) = exclusivity_in_play(flag); let selected_subcommand = - (out.cmds.len() > 1 && !owned_by_selected_command(flag)).then(|| out.cmd.name.clone()); + (out.cmds.len() > 1 && ancestor_exclusivity).then(|| out.cmd.name.clone()); let other = other_flag .or_else(|| other_arg.map(|arg| format!("<{}>", arg.name))) .or(selected_subcommand); @@ -3252,6 +3250,45 @@ flag "--file " required_unless="--stdin" .expect("the child's own spelling drops the exclusivity the child did not restate"); } + #[test] + fn a_child_spelling_carries_its_exclusivity_even_beside_an_ancestor_spelling() { + // Both spellings of one merged flag, typed together. The child's `--clean` is exclusive + // whatever else was typed alongside it, so `--verbose` is company; attributing the whole + // occurrence to the ancestor because `-c` appeared in it lost that. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "-c", "--clean", "--verbose"])).is_err(), + "the child spelling is exclusive whatever it was typed beside" + ); + parse(&spec, &input(&["ex", "run", "-c", "--verbose"])) + .expect("the ancestor's own spelling was never exclusive"); + } + + #[test] + fn an_environment_value_takes_the_exclusivity_of_the_declaration_in_scope() { + // An environment value has no spelling to attribute, so the declaration the selected + // command has in scope answers — in both directions. Comparing whole alias sets asked + // the ancestor instead, because the merged flag also carries its orphan `-c`. + let added: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse_with_env(&added, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")]).is_err(), + "the child added exclusivity the environment value has to honor" + ); + + let dropped: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse_with_env(&dropped, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")]) + .expect("the child dropped the exclusivity, and the environment value follows it"); + } + #[test] fn a_merged_child_exclusive_flag_still_escapes_requiredness() { // Exclusivity suppresses missing-value checks, and that has to survive the merge for From 4e2b571530e646603feebc256b76bf1cfdb826ba Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:26:25 +0000 Subject: [PATCH 16/17] fix(derive): reach the selected subcommand's partial through its variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things arrived on main while this branch was open, and both meet it in code this branch wrote. `conformance/src/tables.rs` builds a `FlagMeta` field by field, so the `exclusive` added here left it one short. And a subcommand partial is now an enum holding only the selected variant rather than a struct with one field per variant, so `any_given`, `exclusive_given` and `apply_env` — the three this branch added to cross the subcommand boundary — were still reaching for `partial.v{i}`. Matched to the `settings_given` beside them, which already asks the variant. The behavior is unchanged: every one of these was already gated on `selected`, so only the selected arm was ever consulted, and an arm that is not selected has nothing to have been given. Co-Authored-By: Claude Opus 5 --- conformance/src/tables.rs | 1 + derive/src/codegen.rs | 24 ++++++++++++++++++------ lib/src/parse.rs | 6 +----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 6714d19cf..ad532c40b 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -307,6 +307,7 @@ fn flag_meta( overrides: strs(&f.overrides), conflicts: strs(&f.conflicts), requires: strs(&f.requires), + exclusive: f.exclusive, required_if: strs(&f.required_if), required_unless: strs(&f.required_unless), help_heading: opt(&f.help_heading), diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 03c944652..99fb57e54 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2821,30 +2821,42 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }); + // The partial holds only the selected subcommand's own, so every one of these asks the + // variant rather than a field: an unselected arm has nothing to have been given. let any_givens = subs.variants.iter().enumerate().map(|(i, v)| { - let field = format_ident!("v{i}"); + let variant = format_ident!("V{i}"); let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#field) + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::any_given(__usage_p) + } else { + ::std::option::Option::None + } } } }); let exclusive_givens = subs.variants.iter().enumerate().map(|(i, v)| { - let field = format_ident!("v{i}"); + let variant = format_ident!("V{i}"); let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#field) + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(__usage_p) + } else { + ::std::option::Option::None + } } } }); let apply_envs = subs.variants.iter().enumerate().map(|(i, v)| { - let field = format_ident!("v{i}"); + let variant = format_ident!("V{i}"); let ty = &v.ty; quote! { ::std::option::Option::Some(#i) => { - <#ty as usage_argv::spec::CommandArgs>::apply_env(&mut partial.#field); + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::apply_env(__usage_p); + } } } }); diff --git a/lib/src/parse.rs b/lib/src/parse.rs index b7f601957..6d4ce9381 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1269,11 +1269,7 @@ fn parse_partial_with_env( // the child would be here instead. The spec has carried `subcommand_required` since it was // added for the derive, and this parser never read it — so `mise generate` parsed as a // complete invocation while usage-argv and clap both refused it. - // - // An exclusive flag escapes it, as it escapes every other requirement: `--version` on a - // command that needs a subcommand is the shape the property exists for, and answering it - // with "which subcommand?" would make the flag unusable exactly where it is most wanted. - if out.cmd.subcommand_required && !out.cmd.subcommands.is_empty() && !exclusive_present { + if out.cmd.subcommand_required && !out.cmd.subcommands.is_empty() { let mut names: Vec<&str> = out .cmd .subcommands From f119674a943ac6d78fa11102233ee403891aa7ae Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:06:25 +0000 Subject: [PATCH 17/17] fix(parse): one colliding alias does not disown a child from its own flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ownership asked whether *every* spelling the child declared resolves to this flag. A child may declare one that another inherited global already owns — `-c --clean` beside an inherited `-c --config` — and that collision is settled in the other global's favor, so the child's `-c` resolves elsewhere. Requiring all of them then disowned the child from the `--clean` it plainly does own, and its `exclusive` stopped applying: `run --clean --verbose` was accepted. Any of them is enough, and still tells apart the case the check exists for: when a child re-declares a global as global there are two flags, and the child's own spellings resolve to the child's, so none of them lands on the ancestor's. Also pins the rule a local re-declaration follows before the subcommand word, which was read as a bug and is the same rule working: such a declaration describes the flag at the child, so ahead of the subcommand the flag can only be the ancestor's, and the ancestor's exclusivity answers whichever way it is set. The two halves of the new test differ in nothing but that setting. Co-Authored-By: Claude Opus 5 --- lib/src/parse.rs | 63 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 6d4ce9381..17e2f12fc 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -1208,12 +1208,20 @@ fn parse_partial_with_env( .filter(|declared| declared.name == flag.name) .flat_map(flag_keys) .collect(); - let speaks_for_this_flag = !declared.is_empty() - && declared.iter().all(|spelling| { - out.available_flags - .get(spelling) - .is_some_and(|available| Arc::ptr_eq(available, flag)) - }); + // *Any* of them, not all. All was too strong: a child may declare a spelling that + // some other inherited global already owns — `-c --clean` beside an inherited + // `-c --config` — and that collision is resolved in the other global's favor, so the + // child's `-c` resolves elsewhere. Requiring every spelling to land here let one + // unrelated collision disown the child from the `--clean` it plainly does own. + // + // Still enough to tell the two-object case apart, which is what this guards: when a + // child re-declares a global as global, the child's own spellings resolve to the + // child's separate flag, so none of them lands on the ancestor's. + let speaks_for_this_flag = declared.iter().any(|spelling| { + out.available_flags + .get(spelling) + .is_some_and(|available| Arc::ptr_eq(available, flag)) + }); if speaks_for_this_flag { declared } else { @@ -3229,6 +3237,49 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn a_colliding_alias_does_not_disown_the_child_from_the_rest() { + // The child re-declares the inherited `--clean` as exclusive and gives it a `-c` that + // an unrelated inherited global already owns. That collision is resolved in the other + // global's favor, so the child's `-c` resolves elsewhere — but the child plainly owns + // the `--clean` it declared, and its exclusivity holds. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\nflag \"-c --config \" global=#true\ncmd \"run\" {\n flag \"-c --clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])).expect("alone is allowed"); + assert!( + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "one unrelated alias collision cannot disown the child from its own flag" + ); + } + + #[test] + fn a_local_child_declaration_is_not_in_scope_before_the_subcommand() { + // A child's *local* re-declaration describes the flag at the child. Typed ahead of the + // subcommand word the flag can only be the ancestor's, because that is the only one in + // scope there — so the ancestor's exclusivity is the one that answers, whichever way it + // is set. The pair below differ in nothing else, which is what makes this one rule + // rather than two behaviors. + let quiet: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + parse(&quiet, &input(&["ex", "--clean", "run", "--verbose"])) + .expect("the ancestor owns this occurrence, and it is not exclusive"); + assert!( + parse(&quiet, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "after the subcommand word the child's declaration is in scope, and it is exclusive" + ); + + let loud: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + assert!( + parse(&loud, &input(&["ex", "--clean", "run"])).is_err(), + "the same rule, with an exclusive ancestor: selecting the child is company for it" + ); + } + #[test] fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_plain_child_redeclaration() { // The mirror of `a_local_child_redeclaration_keeps_its_exclusivity_when_merged`: the