From 63739df5eaf05f6bf1b176e090cbff919ad2b92c Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:49:06 +0200 Subject: [PATCH 1/2] revert https://github.com/selemis-com/clap_schema/pull/12/commits/aa12ee5c756522a61c6ee8861b0c1e164ac2f609 --- Cargo.lock | 12 +- Cargo.toml | 3 +- README.md | 7 +- SPECIFICATION.md | 104 +++- THIRD_PARTY_NOTICES.md | 8 +- .../examples/relationship_contract.rs | 76 +++ crates/clap_schema/src/contract.rs | 208 ++++++- crates/clap_schema/src/lib.rs | 7 +- crates/clap_schema/src/model.rs | 114 +++- .../tests/conformance/arguments.rs | 109 +++- .../tests/conformance/boundaries.rs | 82 ++- .../tests/conformance/fixtures/arguments.rs | 96 +++- .../tests/conformance/fixtures/mod.rs | 7 +- .../tests/conformance/relationships.rs | 507 +++++++++++++++++- crates/clap_schema/tests/support/ui.rs | 2 +- deny.toml | 4 +- 16 files changed, 1261 insertions(+), 85 deletions(-) create mode 100644 crates/clap_schema/examples/relationship_contract.rs diff --git a/Cargo.lock b/Cargo.lock index cc34bf9..5d3096b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,8 +65,7 @@ dependencies = [ [[package]] name = "clap" version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +source = "git+https://github.com/zerosnacks/clap?rev=2883a7f13718bb4c3cf0456cf831176d20a2ae14#2883a7f13718bb4c3cf0456cf831176d20a2ae14" dependencies = [ "clap_builder", "clap_derive", @@ -75,8 +74,7 @@ dependencies = [ [[package]] name = "clap_builder" version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +source = "git+https://github.com/zerosnacks/clap?rev=2883a7f13718bb4c3cf0456cf831176d20a2ae14#2883a7f13718bb4c3cf0456cf831176d20a2ae14" dependencies = [ "anstyle", "clap_lex", @@ -85,8 +83,7 @@ dependencies = [ [[package]] name = "clap_derive" version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +source = "git+https://github.com/zerosnacks/clap?rev=2883a7f13718bb4c3cf0456cf831176d20a2ae14#2883a7f13718bb4c3cf0456cf831176d20a2ae14" dependencies = [ "heck", "proc-macro2", @@ -97,8 +94,7 @@ dependencies = [ [[package]] name = "clap_lex" version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +source = "git+https://github.com/zerosnacks/clap?rev=2883a7f13718bb4c3cf0456cf831176d20a2ae14#2883a7f13718bb4c3cf0456cf831176d20a2ae14" [[package]] name = "clap_schema" diff --git a/Cargo.toml b/Cargo.toml index f3ce611..c6c7935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,7 +162,8 @@ zero_sized_map_values = "warn" [workspace.dependencies] # Vendor -clap = { version = "4.6.6", default-features = false, features = ["std"] } +# Temporary until clap-rs/clap#6494 is merged and released. +clap = { git = "https://github.com/zerosnacks/clap", rev = "2883a7f13718bb4c3cf0456cf831176d20a2ae14", version = "4.6.6", default-features = false, features = ["std"] } proc-macro-crate = "3.5.0" proc-macro2 = "1.0.107" quote = "1.0.47" diff --git a/README.md b/README.md index ed685cb..947d82f 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ deployctl deploy --environment production api The command path and canonical invocation contract come from Clap. Global argument scope, positional order, canonical option spellings, value arity, lexical defaults and possible values, conflicts, -argument-group cardinality, repeatability, delimiters, value terminators, required `=` syntax, -required `--` syntax, and exclusivity are reflected from the built command model. The `output` field is the JSON Schema of the successful Rust result. +overrides, conditional requirements, argument groups, repeatability, delimiters, value terminators, +required `=` syntax, required `--` syntax, and exclusivity are reflected from the built command +model. The `output` field is the JSON Schema of the successful Rust result. This gives agents a canonical process-style invocation contract without making rendered Clap help part of the wire format. Clap remains authoritative for parser-specific validation that cannot be @@ -290,7 +291,7 @@ When Rust code already knows which command it wants to inspect, lower-level look Paths accept Clap aliases, while returned paths are always canonical. -Generated contracts include canonical invocation metadata such as names, global argument scope and canonical ownership, positional order, value arity, lexical defaults and possible values, repeatability, conflicts, argument-group cardinality, and token-level syntax requirements. They do not replace Clap's argument parser: Clap remains authoritative for parser-specific validation that cannot be reflected structurally. +Generated contracts include canonical invocation metadata such as names, global argument scope and canonical ownership, positional order, value arity, lexical defaults and possible values, repeatability, conflicts, overrides, conditional requirements, argument-group constraints, and token-level syntax requirements. They do not replace Clap's argument parser: Clap remains authoritative for parser-specific validation that cannot be reflected structurally. ## Application-defined extensions diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 5e586b0..bf5fa13 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -68,7 +68,7 @@ A complete command document can contain: `ancestors`, when present, contains the invocation-relevant command levels above the selected command, ordered from the root command to the immediate parent. Root command documents omit this field. -Each ancestor uses the same `arguments`, `options`, `groups`, command-syntax, and subcommand-routing properties defined for the selected command. Its `path` identifies the command boundary that owns those semantics. Global arguments propagated by Clap are represented once at the highest command level where they appear and omitted from descendant levels. This preserves the command boundary of local groups that constrain a global argument while avoiding duplicate copies. Ancestor contexts do not carry `invocable`, `output`, or child topology because they describe how to reach the selected command rather than a separately selected operation. +Each ancestor uses the same `arguments`, `options`, `groups`, command-syntax, and subcommand-routing properties defined for the selected command. Its `path` identifies the command boundary that owns those semantics. Global arguments propagated by Clap are represented once at the highest command level where they appear and omitted from descendant levels. This preserves the command boundary of local groups and relationships that reference a global argument while avoiding duplicate copies. Ancestor contexts do not carry `invocable`, `output`, or child topology because they describe how to reach the selected command rather than a separately selected operation. Consumers constructing a nested invocation **MUST** apply ancestor requirements and routing rules at the command level where they are declared. In particular, selecting a child does not implicitly discard a parent's required arguments unless that ancestor has `subcommandNegatesRequirements: true`. @@ -98,7 +98,7 @@ Alternative short names and aliases are intentionally not part of the core contr ### `groups` -`groups`, when present, describes Clap argument groups that contain reflected arguments and materially affect invocation validity through requiredness or mutual-exclusion cardinality. Unconstraining groups are omitted. +`groups`, when present, describes Clap argument groups that contain reflected arguments and materially affect invocation validity. Group references used by argument relationships resolve against this array. Unconstraining groups that are neither required, mutually exclusive, related to another target, nor referenced by a relationship are omitted. See [Argument groups](#argument-groups). @@ -167,16 +167,18 @@ The `arguments` array order and `position` carry the same ordering intentionally ### `required` `required: true` means Clap's base required setting is enabled for the argument. This base -requiredness is evaluated together with reflected conflicts; for example, a conflict can make an -otherwise-required argument inapplicable for a particular invocation. +requiredness is evaluated together with reflected conflicts and other relationship rules; for +example, a conflict can make an otherwise-required argument inapplicable for a particular +invocation. -Absence is equivalent to `false`. +Absence is equivalent to `false`. Conditional requiredness is represented separately by +`requiredIfAny`, `requiredIfAll`, `requiredUnlessAny`, and `requiredUnlessAll`. ### `global` `global: true` means Clap propagates this argument to child commands. A global argument may be supplied at the command level where it is declared or at any descendant level accepted by Clap. -In a complete nested command document, a propagated global argument is represented once at the highest command level where it appears and omitted from descendant levels. Consumers SHOULD emit it at that represented command level. This is the canonical placement because ancestor-local argument groups can constrain the global argument at that boundary even when Clap also recognizes the option after a descendant subcommand. +In a complete nested command document, a propagated global argument is represented once at the highest command level where it appears and omitted from descendant levels. Consumers SHOULD emit it at that represented command level. This is the canonical placement because ancestor-local groups and relationships can depend on the global argument at that boundary even when Clap also recognizes the option after a descendant subcommand. Absence is equivalent to `false`. @@ -194,7 +196,64 @@ A canonical consumer SHOULD repeat an argument only when `repeatable` is true. `conflictsWith` contains canonical argument names in argument-level conflict relationships with this argument. These relationships are normalized symmetrically, matching Clap's two-way conflict semantics. -A consumer MUST NOT construct an invocation containing an argument together with a listed conflict. +A consumer MUST NOT construct an invocation containing an argument together with a listed conflict. Conflicts owned by an argument group remain represented by that group's `conflictsWith` rule and are not duplicated onto every member argument. + +### `overrides` + +`overrides` contains argument or group targets configured as mutually overridable with this argument: + +```json +{ + "overrides": [ + {"kind": "argument", "name": "--legacy"}, + {"kind": "group", "name": "selector"} + ] +} +``` + +Concrete argument-to-argument relationships are normalized symmetrically, matching Clap's last-one-wins behavior. Group targets are preserved as group references rather than expanded into inferred member-level overrides. + +Consumers SHOULD avoid supplying mutually overridable targets together unless the overriding behavior is intentional. + +### `requires` + +`requires` contains requirements introduced by this argument. Each entry has a `when` predicate and a `target`. `when` is either `present` or an equality predicate on this argument's lexical value. `target` identifies either a canonical argument or a named argument group. + +These predicates retain Clap's value-source semantics: values originating only from a +default do not satisfy `present` or equality predicates. + +When a predicate matches, the referenced target MUST be satisfied. An argument target is satisfied by supplying that argument. A group target is satisfied by supplying a member in accordance with that group's cardinality rules. + +### `requiredIfAny` and `requiredIfAll` + +Each entry is an equality condition on another argument or argument group. Its `target` uses the same tagged `argument` / `group` representation as other relationship targets: + +```json +{ + "requiredIfAny": [ + {"target": {"kind": "argument", "name": "--format"}, "equals": "json"}, + {"target": {"kind": "group", "name": "selector"}, "equals": "mode"} + ] +} +``` + +For an argument target, `equals` is the lexical argument value. For a group target, `equals` is the stable ID of a selected group member. Equality conditions retain Clap's predicate semantics, including that values originating only from a default do not satisfy the predicate. The array is one aggregate rule; consumers MUST preserve whether that rule uses `any` or `all`. + +For `requiredIfAny`, the argument is required when **at least one** listed condition matches. The listed conditions are therefore combined with logical OR. + +For `requiredIfAll`, the argument is required only when **every** listed condition matches. The listed conditions are therefore combined with logical AND. + +These rules are independent of unconditional `required`. + +### `requiredUnlessAny` and `requiredUnlessAll` + +Each entry identifies an argument or group whose presence can satisfy this required-unless rule. The array is one aggregate rule; consumers MUST preserve whether that rule uses `any` or `all`. + +For `requiredUnlessAny`, this rule requires the argument unless **at least one** listed target is present. + +For `requiredUnlessAll`, this rule requires the argument unless **every** listed target is present. + +Satisfying one of these rules does not by itself prove the argument is optional: another conditional-requiredness rule may still require it. ### `requireEquals` @@ -275,6 +334,14 @@ A single default is a JSON string. Multiple defaults are an array of JSON string Clap's `hide_default_value` setting affects human help only and does not suppress the default from this machine-readable contract. +### `defaultMissing` + +`defaultMissing`, when present, is the lexical value Clap supplies when the argument itself is present but no explicit value is supplied. This is distinct from `default`, which applies when the argument is omitted. + +### `defaultIf` + +`defaultIf` is an ordered array of conditional-default rules. Each rule identifies an argument or argument-group `target`, a presence or equality predicate, and a lexical default. Predicate evaluation retains Clap's value-source semantics: values originating only from a default do not satisfy the predicate. For a group target, an equality predicate compares against the stable ID of the selected group member. Rules are evaluated in declaration order; the first matching rule wins. A JSON `null` value represents a matching rule that suppresses the unconditional default. + ### `delimiter` `delimiter`, when present, is the character used to split multiple values inside one command-line token. @@ -293,7 +360,7 @@ Clap's `hide_default_value` setting affects human help only and does not suppres ### `ignoreCase` -`ignoreCase: true` reflects Clap's case-insensitive matching for advertised possible values. +`ignoreCase: true` reflects Clap's case-insensitive matching for advertised possible values. Clap also applies this setting when the argument is the compared target of `required_if_eq`, `required_if_eq_any`, or `required_if_eq_all`, represented here by `requiredIfAny` / `requiredIfAll` on another argument. Consumers MUST NOT generalize this setting to other relationship predicates unless Clap defines that behavior. ## Argument groups @@ -304,16 +371,20 @@ A group document has the following semantic shape: "name": "input", "members": ["--stdin", "--file"], "required": true, - "multiple": false + "multiple": false, + "requires": [{"kind": "argument", "name": "--format"}], + "conflictsWith": [{"kind": "argument", "name": "--legacy"}] } ``` -`name` is the stable group identifier. `members` contains canonical argument names. +`name` is the stable group identifier used by relationship references. `members` contains canonical argument names. -`required: true` means Clap's base required setting is enabled for the group. Absence is equivalent to `false`. +`required: true` means Clap's base required setting is enabled for the group. As with argument requiredness, a reflected conflict can make that requirement inapplicable for a particular invocation. Absence is equivalent to `false`. `multiple: true` means more than one member may be present. Absence is equivalent to `false`, so a group with multiple members is mutually exclusive by default. When the group requirement applies, combining `required: true` with `multiple: false` means exactly one member must be present. +`requires` and `conflictsWith` apply when the group is present and may refer to either arguments or other groups. A group that would otherwise impose no constraint is still emitted when another reflected relationship targets it. + ## Shallow subcommand summaries In shallow discovery, a direct child can be represented as: @@ -354,11 +425,11 @@ Given the executable name separately and one complete command document, a consum 3. After the final path token, construct the selected command's own represented options and positional values from the top-level command properties. Globals already represented by an ancestor are omitted here. 4. For a selected option with `value`, supply a number of values within `minValues..=maxValues`. When `maxValues` is `null`, there is no finite upper bound. 5. If `requireEquals` is true, attach the first option value with `=`. -6. Respect `delimiter`, `terminator`, `repeatable`, `conflictsWith`, `ignoreCase`, and `exclusive` when they are present. -7. Satisfy every applicable argument-group cardinality rule at each command level. +6. Respect `delimiter`, `terminator`, `repeatable`, `conflictsWith`, `overrides`, `requires`, conditional requiredness, `ignoreCase`, and `exclusive` when they are present. +7. Satisfy every applicable argument-group cardinality, requirement, and conflict rule at each command level. 8. Append positional values at each command level according to their explicit `position`, respecting `allowMissingPositionals`. If any positional has `requiresDoubleDash: true`, insert `--` before that positional as required by that command level. Once a `trailingVarArg` positional begins consuming values, treat the remaining tokens as its values; when `dontDelimitTrailingValues` is true, do not split those trailing values by configured delimiters. 9. When `values` is present, prefer one of the advertised values, but do not treat that list as exhaustive validation. Every supplied lexical value must still satisfy Clap's configured parser. -10. When relying on defaults, use the reflected unconditional lexical `default` when it is present. +10. When relying on defaults, distinguish omission (`default`) from selecting an option without an explicit value (`defaultMissing`) and apply ordered `defaultIf` rules before the unconditional default. The schema does not prescribe shell quoting or escaping. The caller is responsible for passing the resulting argument vector safely to the process. Agents and tools SHOULD prefer direct argv/process APIs over constructing a shell command string. @@ -366,13 +437,13 @@ The schema does not prescribe shell quoting or escaping. The caller is responsib `clap_schema` 0.2 describes semantics that can be obtained reliably from Clap's public built-command reflection plus the registered Rust output type. -The core contract includes canonical paths and option spellings, global argument scope, positional order, base requiredness, argument-group cardinality, value arity, unconditional defaults, advertised possible values, delimiters, value terminators, repeatability, conflicts, exclusive arguments, required `=` syntax, required `--` syntax, trailing variadic capture, case-insensitive value matching, missing-positional behavior, trailing-value delimiting, parent/subcommand routing semantics, and typed successful-output JSON Schema. +The core contract includes canonical paths and option spellings, global argument scope, positional order, base and conditional requiredness, requirement and override relationships, argument-group rules, value arity, unconditional/missing/conditional defaults, advertised possible values, delimiters, value terminators, repeatability, conflicts, exclusive arguments, required `=` syntax, required `--` syntax, trailing variadic capture, case-insensitive value matching, missing-positional behavior, trailing-value delimiting, parent/subcommand routing semantics, and typed successful-output JSON Schema. Input values remain lexical command-line values. `clap_schema` does not infer Rust result types from Clap's erased value parser, and Clap remains authoritative for parser-specific validation. Parser configuration that is not exposed through Clap's built-command reflection is not inferred. This includes `args_override_self`; consumers should use the canonical argument occurrence semantics represented by the contract rather than treating every argv form accepted by Clap as discoverable. -Only UTF-8 lexical defaults can be represented directly by this JSON contract. A non-UTF-8 default is omitted rather than converted lossily. Consumers MUST interpret omission as “not stated by this contract”, not as proof that no application-specific default exists. +Only UTF-8 lexical relationship and default values can be represented directly by this JSON contract. A rule whose lexical predicate cannot be represented as UTF-8 is omitted rather than converted lossily. Consumers MUST interpret omission as “not stated by this contract”, not as proof that no application-specific constraint exists. The contract uses normal process-style argv framing at the root parser entrypoint, where the executable name is separate from the command path. `ContractBuilder::build` rejects root `no_binary_name` and `multicall` modes because they change the meaning of the beginning of argv. Equivalent settings on nested commands remain valid because they do not change root process framing. Runtime external-subcommand capture and parser control flow such as `arg_required_else_help` remain Clap-authoritative outside the structured contract. @@ -415,6 +486,7 @@ Notable 0.2 changes include: - input value arity is explicit while values remain lexical; - global argument scope is explicit and propagated globals are represented once at their highest command level; - syntax-affecting details such as required `=`, required `--`, delimiters, terminators, repeatability, conflicts, and exclusivity are exposed directly; +- conditional requirements, required-unless rules, overrides, missing/conditional defaults, and argument-group constraints are reflected as structured semantics. Within the 0.2 line, adding an optional field is compatible. Consumers MUST ignore unknown fields. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 86ac5e7..63a621d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -437,15 +437,15 @@ Apache License 2.0 Apache License 2.0 #### Used by +- [clap]( https://github.com/clap-rs/clap ) 4.6.6 +- [clap_builder]( https://github.com/clap-rs/clap ) 4.6.6 +- [clap_derive]( https://github.com/clap-rs/clap ) 4.6.4 +- [clap_lex]( https://github.com/clap-rs/clap ) 1.1.0 - [anstream]( https://github.com/rust-cli/anstyle.git ) 1.0.0 - [anstyle-parse]( https://github.com/rust-cli/anstyle.git ) 1.0.0 - [anstyle-query]( https://github.com/rust-cli/anstyle.git ) 1.1.5 - [anstyle-wincon]( https://github.com/rust-cli/anstyle.git ) 3.0.11 - [anstyle]( https://github.com/rust-cli/anstyle.git ) 1.0.14 -- [clap]( https://github.com/clap-rs/clap ) 4.6.6 -- [clap_builder]( https://github.com/clap-rs/clap ) 4.6.6 -- [clap_derive]( https://github.com/clap-rs/clap ) 4.6.4 -- [clap_lex]( https://github.com/clap-rs/clap ) 1.1.0 - [colorchoice]( https://github.com/rust-cli/anstyle.git ) 1.0.5 - [escargot]( https://github.com/crate-ci/escargot.git ) 0.5.15 - [is_terminal_polyfill]( https://github.com/polyfill-rs/is_terminal_polyfill ) 1.70.2 diff --git a/crates/clap_schema/examples/relationship_contract.rs b/crates/clap_schema/examples/relationship_contract.rs new file mode 100644 index 0000000..19f8250 --- /dev/null +++ b/crates/clap_schema/examples/relationship_contract.rs @@ -0,0 +1,76 @@ +//! Reflection of advanced invocation relationships and group constraints. + +use std::convert::Infallible; + +use clap::{Arg, ArgAction, ArgGroup, Command}; +use clap_schema::{ContractBuilder, schema_handler}; + +/// Typed command used to register the example handler contract. +struct CreateCommand; + +/// Example handler whose successful output is intentionally empty. +#[schema_handler(CreateCommand)] +const fn create(_command: CreateCommand) -> Result<(), Infallible> { + Ok(()) +} + +/// Build a command that exercises reflected argument relationships and groups. +fn cli() -> Command { + Command::new("fixture").subcommand( + Command::new("create") + .about("Create a resource") + .arg(Arg::new("mode").long("mode")) + .arg(Arg::new("format").long("format")) + .arg(Arg::new("source").long("source")) + .arg(Arg::new("auth").long("auth")) + .arg(Arg::new("input").long("input")) + .arg(Arg::new("stdin").long("stdin").action(ArgAction::SetTrue)) + .arg(Arg::new("file").long("file")) + .arg(Arg::new("host").long("host")) + .arg(Arg::new("port").long("port")) + .arg(Arg::new("legacy").long("legacy")) + .arg( + Arg::new("count") + .long("count") + .value_parser(clap::value_parser!(i64)) + .allow_negative_numbers(true), + ) + .arg( + Arg::new("config") + .long("config") + .num_args(0..=1) + .default_value("fallback") + .default_missing_value("default-missing") + .default_value_if("mode", "auto", Some("generated")) + .overrides_with("legacy") + .requires("selector") + .requires_if("special", "input") + .required_if_eq_any([("format", "json"), ("mode", "strict")]) + .required_if_eq_all([("source", "remote"), ("auth", "token")]) + .required_unless_present_any(["stdin", "file"]) + .required_unless_present_all(["host", "port"]), + ) + .group(ArgGroup::new("selector").args(["mode", "format"]).multiple(true)) + .group(ArgGroup::new("irrelevant").args(["source", "host"]).multiple(true)) + .group( + ArgGroup::new("transport") + .args(["stdin", "file"]) + .required(true) + .multiple(true) + .requires("auth") + .conflicts_with("legacy"), + ), + ) +} + +fn main() -> Result<(), Box> { + let contract = ContractBuilder::new(cli()).command::(["create"]).build()?; + + let command = contract.command(&["create"])?; + println!("{}", serde_json::to_string_pretty(&command)?); + + // Keep the example handler an ordinary callable Rust function as well. + create(CreateCommand)?; + + Ok(()) +} diff --git a/crates/clap_schema/src/contract.rs b/crates/clap_schema/src/contract.rs index 1e365c1..6fef1e1 100644 --- a/crates/clap_schema/src/contract.rs +++ b/crates/clap_schema/src/contract.rs @@ -2,14 +2,15 @@ use std::{any::TypeId, collections::HashSet}; -use clap::{Arg, ArgAction, Command, Id}; +use clap::{Arg, ArgAction, Command, Id, builder::ArgPredicate as ClapArgPredicate}; use schemars::JsonSchema; use serde_json::Value; use crate::{ model::{ - ArgumentGroupInfo, ArgumentInfo, ArgumentSyntax, ArgumentValue, CliContract, CommandSyntax, - DiscoveryNode, ExecutableData, SubcommandRouting, + ArgumentGroupInfo, ArgumentInfo, ArgumentPredicate, ArgumentRequirement, ArgumentSyntax, + ArgumentTarget, ArgumentValue, ArgumentValueCondition, CliContract, CommandSyntax, + ConditionalDefault, DiscoveryNode, ExecutableData, SubcommandRouting, }, schema::{ ExtendedSchemaFactory, SchemaFactory, compose_extended_schemas, extended_schema_factory, @@ -357,7 +358,7 @@ fn build_discovery_node( let arguments = reflected_positionals(command); let options = reflected_options(command); - let groups = reflected_groups(command); + let groups = reflected_groups(command, &arguments, &options); Ok(Some(DiscoveryNode { name: command.get_name().to_owned(), @@ -419,8 +420,49 @@ fn reflected_argument(argument: &Arg) -> bool { fn argument_info(command: &Command, argument: &Arg) -> ArgumentInfo { let action = argument.get_action(); let takes_values = action.takes_values(); - let value = takes_values.then(|| argument_value(argument)); + let value = takes_values.then(|| argument_value(command, argument)); let conflicts_with = reflected_argument_conflicts(command, argument); + let overrides = reflected_argument_overrides(command, argument); + let requires = argument + .get_requires() + .iter() + .filter_map(|(predicate, target)| { + Some(ArgumentRequirement { + when: argument_predicate(predicate)?, + target: argument_target(command, target)?, + }) + }) + .collect(); + let required_if_any = argument + .get_required_if_eq_any() + .iter() + .filter_map(|(id, value)| { + Some(ArgumentValueCondition { + target: argument_target(command, id)?, + equals: value.to_str()?.to_owned(), + }) + }) + .collect(); + let required_if_all = argument + .get_required_if_eq_all() + .iter() + .filter_map(|(id, value)| { + Some(ArgumentValueCondition { + target: argument_target(command, id)?, + equals: value.to_str()?.to_owned(), + }) + }) + .collect(); + let required_unless_any = argument + .get_required_unless_present_any() + .iter() + .filter_map(|id| argument_target(command, id)) + .collect(); + let required_unless_all = argument + .get_required_unless_present_all() + .iter() + .filter_map(|id| argument_target(command, id)) + .collect(); ArgumentInfo { name: canonical_argument_name(argument), @@ -434,6 +476,12 @@ fn argument_info(command: &Command, argument: &Arg) -> ArgumentInfo { value, repeatable: matches!(action, ArgAction::Append | ArgAction::Count), conflicts_with, + overrides, + requires, + required_if_any, + required_if_all, + required_unless_any, + required_unless_all, syntax: ArgumentSyntax { require_equals: takes_values && !argument.is_positional() @@ -446,7 +494,7 @@ fn argument_info(command: &Command, argument: &Arg) -> ArgumentInfo { } /// Builds the value contract for one value-taking Clap argument. -fn argument_value(argument: &Arg) -> ArgumentValue { +fn argument_value(command: &Command, argument: &Arg) -> ArgumentValue { let (min_values, max_values) = argument.get_num_args().map_or((1, Some(1)), |range| { let max = range.max_values(); (range.min_values(), (max != usize::MAX).then_some(max)) @@ -463,6 +511,8 @@ fn argument_value(argument: &Arg) -> ArgumentValue { max_values, values, default: argument_default(argument), + default_missing: lexical_values(argument.get_default_missing_values()), + default_if: conditional_defaults(command, argument), delimiter: argument.get_value_delimiter(), terminator: argument.get_value_terminator().map(ToString::to_string), allow_hyphen_values: argument.is_allow_hyphen_values_set(), @@ -509,6 +559,33 @@ fn lexical_value_set(values: &[clap::builder::OsStr]) -> Option { } } +/// Reflects ordered conditional defaults for one argument. +fn conditional_defaults(command: &Command, argument: &Arg) -> Vec { + argument + .get_default_values_ifs() + .iter() + .filter_map(|(id, predicate, values)| { + let target = argument_target(command, id)?; + let when = argument_predicate(predicate)?; + let value = match values { + Some(values) => Some(lexical_value_set(values)?), + None => None, + }; + Some(ConditionalDefault { target, when, value }) + }) + .collect() +} + +/// Converts Clap's normalized relationship predicate to the wire representation. +fn argument_predicate(predicate: &ClapArgPredicate) -> Option { + match predicate { + ClapArgPredicate::IsPresent => Some(ArgumentPredicate::Present), + ClapArgPredicate::Equals(value) => { + Some(ArgumentPredicate::Equals { value: value.to_str()?.to_owned() }) + } + } +} + /// Resolves a reflected ID to its canonical argument name. fn reflected_argument_name(command: &Command, id: &Id) -> Option { command @@ -518,6 +595,9 @@ fn reflected_argument_name(command: &Command, id: &Id) -> Option { } /// Reflects argument-level conflicts as the mutual relationship Clap enforces at runtime. +/// +/// Group-owned conflicts remain represented by [`ArgumentGroupInfo`] rather than being duplicated +/// onto every member argument. fn reflected_argument_conflicts(command: &Command, argument: &Arg) -> Vec { let mut conflicts = Vec::new(); @@ -548,9 +628,50 @@ fn reflected_argument_conflicts(command: &Command, argument: &Arg) -> Vec Vec { +/// Reflects configured override targets and the reverse side of concrete argument overrides. +/// +/// Clap treats argument-to-argument overrides as mutual. Group targets are preserved structurally +/// without inferring member-level override relationships. +fn reflected_argument_overrides(command: &Command, argument: &Arg) -> Vec { + let mut overrides = argument + .get_overrides() + .iter() + .filter_map(|id| argument_target(command, id)) + .collect::>(); + + for candidate in command.get_arguments().filter(|candidate| reflected_argument(candidate)) { + if candidate.get_id() == argument.get_id() { + continue; + } + if candidate.get_overrides().iter().any(|id| id == argument.get_id()) { + let target = ArgumentTarget::Argument { name: canonical_argument_name(candidate) }; + if !overrides.contains(&target) { + overrides.push(target); + } + } + } + + overrides +} + +/// Resolves an ID used by a Clap relationship to an argument or group reference. +fn argument_target(command: &Command, id: &Id) -> Option { + if let Some(name) = reflected_argument_name(command, id) { + return Some(ArgumentTarget::Argument { name }); + } command + .get_groups() + .find(|group| group.get_id() == id) + .map(|group| ArgumentTarget::Group { name: group.get_id().to_string() }) +} + +/// Reflects argument groups that materially affect the machine-readable invocation contract. +fn reflected_groups( + command: &Command, + arguments: &[ArgumentInfo], + options: &[ArgumentInfo], +) -> Vec { + let mut groups = command .get_groups() .filter_map(|group| { let members = group @@ -564,18 +685,75 @@ fn reflected_groups(command: &Command) -> Vec { // `ArgGroup::is_multiple` currently takes `&mut self`; clone only to reflect this // read-only property until clap-rs/clap#6411 lands. let mut owned_group = group.clone(); - let group = ArgumentGroupInfo { + Some(ArgumentGroupInfo { name: group.get_id().to_string(), members, required: group.is_required_set(), multiple: owned_group.is_multiple(), - }; - - // Clap derive implicitly creates `multiple = true` groups for `Args` structs. Keep a - // group only when it changes invocation validity through cardinality. - (group.required || (!group.multiple && group.members.len() > 1)).then_some(group) + requires: group + .get_requires() + .filter_map(|id| argument_target(command, id)) + .collect(), + conflicts_with: group + .get_conflicts() + .filter_map(|id| argument_target(command, id)) + .collect(), + }) }) - .collect() + .collect::>(); + let referenced_groups = reflected_group_references(arguments, options, &groups); + + groups.retain(|group| { + // Clap derive implicitly creates `multiple = true` groups for `Args` structs. Keep a group + // only when it changes invocation validity or another reflected relationship targets it. + let constrains_cardinality = group.required || (!group.multiple && group.members.len() > 1); + constrains_cardinality + || !group.requires.is_empty() + || !group.conflicts_with.is_empty() + || referenced_groups.contains(&group.name) + }); + groups +} + +/// Finds groups targeted by relationships already present in the reflected contract. +fn reflected_group_references( + arguments: &[ArgumentInfo], + options: &[ArgumentInfo], + groups: &[ArgumentGroupInfo], +) -> HashSet { + let mut referenced = HashSet::new(); + let mut record = |target: &ArgumentTarget| { + if let ArgumentTarget::Group { name } = target { + referenced.insert(name.clone()); + } + }; + + for argument in arguments.iter().chain(options) { + for target in &argument.overrides { + record(target); + } + for requirement in &argument.requires { + record(&requirement.target); + } + for condition in argument.required_if_any.iter().chain(&argument.required_if_all) { + record(&condition.target); + } + for target in argument.required_unless_any.iter().chain(&argument.required_unless_all) { + record(target); + } + if let Some(value) = &argument.value { + for conditional in &value.default_if { + record(&conditional.target); + } + } + } + for group in groups { + for target in group.requires.iter().chain(&group.conflicts_with) { + record(target); + } + } + + referenced } /// Formats a canonical command path for diagnostics. diff --git a/crates/clap_schema/src/lib.rs b/crates/clap_schema/src/lib.rs index 0ecbe65..868757f 100644 --- a/crates/clap_schema/src/lib.rs +++ b/crates/clap_schema/src/lib.rs @@ -297,9 +297,10 @@ pub mod __private; pub use clap_schema_derive::{CliSchema, CommandSchema, schema_handler}; pub use contract::{ContractBuilder, Error, Result}; pub use model::{ - ArgumentGroupInfo, ArgumentInfo, ArgumentSyntax, ArgumentValue, CliContract, CommandContext, - CommandInfo, CommandSyntax, SchemaCommandSummary, SchemaDocument, SchemaRequest, - SchemaSubcommand, SubcommandRouting, + ArgumentGroupInfo, ArgumentInfo, ArgumentPredicate, ArgumentRequirement, ArgumentSyntax, + ArgumentTarget, ArgumentValue, ArgumentValueCondition, CliContract, CommandContext, + CommandInfo, CommandSyntax, ConditionalDefault, SchemaCommandSummary, SchemaDocument, + SchemaRequest, SchemaSubcommand, SubcommandRouting, }; /// Trait implemented by a machine-contract-aware root Clap parser. diff --git a/crates/clap_schema/src/model.rs b/crates/clap_schema/src/model.rs index ca0b08c..8f2c02c 100644 --- a/crates/clap_schema/src/model.rs +++ b/crates/clap_schema/src/model.rs @@ -179,8 +179,8 @@ impl CliContract { /// Returns invocation-relevant command levels above `node`, from root to immediate parent. /// /// Clap propagates global arguments into descendant command models during build. The contract - /// keeps each global at the highest command level where it appears so ancestor-local groups - /// retain their original command boundary without duplicating the argument. + /// keeps each global at the highest command level where it appears so ancestor-local groups and + /// relationships retain their original command boundary without duplicating the argument. fn ancestor_contexts(&self, node: &DiscoveryNode) -> (Vec, HashSet) { let mut current = &self.discovery; let mut ancestors = Vec::with_capacity(node.path.len()); @@ -522,9 +522,30 @@ pub struct ArgumentInfo { pub repeatable: bool, /// Canonical invocation names in argument-level conflict relationships with this argument. /// - /// Argument-level conflicts are normalized symmetrically. + /// Argument-level conflicts are normalized symmetrically. Conflicts owned by an argument group + /// remain represented by that group. #[serde(skip_serializing_if = "Vec::is_empty")] pub conflicts_with: Vec, + /// Arguments or groups mutually overridable with this argument. + /// + /// Concrete argument relationships are normalized symmetrically. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub overrides: Vec, + /// Arguments or groups required when this argument matches the stated predicate. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, + /// Conditions on arguments or groups where any match makes this argument required. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub required_if_any: Vec, + /// Conditions on arguments or groups that must all match to make this argument required. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub required_if_all: Vec, + /// Arguments or groups where any presence satisfies this required-unless rule. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub required_unless_any: Vec, + /// Arguments or groups that must all be present to satisfy this required-unless rule. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub required_unless_all: Vec, /// Token-placement syntax required to invoke this argument correctly. #[serde(flatten)] pub syntax: ArgumentSyntax, @@ -572,6 +593,12 @@ pub struct ArgumentValue { /// of strings because command-line defaults are lexical values before parsing. #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, + /// Lexical value used when the argument is present without an explicit value. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_missing: Option, + /// Ordered conditional defaults evaluated before the unconditional default. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub default_if: Vec, /// Delimiter Clap uses to split multiple values inside one token. #[serde(skip_serializing_if = "Option::is_none")] pub delimiter: Option, @@ -584,17 +611,88 @@ pub struct ArgumentValue { /// Whether negative-number tokens are accepted without being treated as options. #[serde(default, skip_serializing_if = "is_false")] pub allow_negative_numbers: bool, - /// Whether Clap enables case-insensitive value matching. + /// Whether Clap enables case-insensitive possible-value and required-if-equality matching. #[serde(default, skip_serializing_if = "is_false")] pub ignore_case: bool, } +/// Reference to an argument or argument group in one command contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[non_exhaustive] +pub enum ArgumentTarget { + /// One concrete argument, named by its canonical invocation name. + Argument { + /// Canonical argument name. + name: String, + }, + /// One named Clap argument group. + Group { + /// Stable group identifier. + name: String, + }, +} + +/// Predicate reflected from Clap for relationship and conditional-default rules. +/// +/// Values originating only from defaults do not satisfy these predicates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "camelCase")] +#[non_exhaustive] +pub enum ArgumentPredicate { + /// The target is present for the predicate evaluation. + Present, + /// The target has the stated lexical value for the predicate evaluation. + Equals { + /// Lexical value compared by Clap. + value: String, + }, +} + +/// Requirement introduced by selecting an argument. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ArgumentRequirement { + /// Predicate applied to the argument that owns this requirement. + pub when: ArgumentPredicate, + /// Argument or group that becomes required when `when` matches. + pub target: ArgumentTarget, +} + +/// Equality condition on another argument or argument group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ArgumentValueCondition { + /// Argument or group to inspect. + pub target: ArgumentTarget, + /// Lexical value that must match. + /// + /// For a group target, this is the stable ID of a selected group member. Values originating + /// only from a default do not satisfy this condition. + pub equals: String, +} + +/// Conditional default evaluated in declaration order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ConditionalDefault { + /// Argument or group whose state controls this default. + pub target: ArgumentTarget, + /// Predicate applied to `target` using Clap's conditional-default semantics. + pub when: ArgumentPredicate, + /// Lexical default to apply, or `null` to suppress the unconditional default. + pub value: Option, +} + /// Invocation-validity contract for one Clap argument group. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ArgumentGroupInfo { - /// Stable group identifier. + /// Stable group identifier used by relationship references. pub name: String, /// Canonical names of arguments in this group. pub members: Vec, @@ -604,6 +702,12 @@ pub struct ArgumentGroupInfo { /// Whether more than one member of this group may be used together. #[serde(default, skip_serializing_if = "is_false")] pub multiple: bool, + /// Arguments or groups required when this group is present. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, + /// Arguments or groups that conflict with this group. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub conflicts_with: Vec, } /// Internal discoverable command topology reflected from Clap. diff --git a/crates/clap_schema/tests/conformance/arguments.rs b/crates/clap_schema/tests/conformance/arguments.rs index 818f5a6..2c188ee 100644 --- a/crates/clap_schema/tests/conformance/arguments.rs +++ b/crates/clap_schema/tests/conformance/arguments.rs @@ -1,5 +1,7 @@ //! Conformance tests for argument values, token syntax, and groups. +use clap_schema::{ArgumentPredicate, ArgumentTarget}; + use super::fixtures::{ argument_shape, assert_accepts, assert_rejects, build_contract, group, groups, option, positional, token_syntax, value_semantics, @@ -139,6 +141,7 @@ fn value_contract_matches_clap_arity_defaults_and_lexical_metadata() { assert!(color_argument.syntax.require_equals); let color = color_argument.value.as_ref().expect("color value"); assert_eq!((color.min_values, color.max_values), (0, Some(2))); + assert_eq!(color.default_missing, Some(serde_json::json!(["auto", "always"]))); let hyphen = option(&command, "--hyphen").value.as_ref().expect("hyphen value"); assert!(hyphen.allow_hyphen_values); @@ -181,12 +184,37 @@ fn argument_token_syntax_matches_clap() { } #[test] -fn group_contract_matches_clap_cardinality_semantics() { +fn group_contract_matches_clap_cardinality_relationship_and_target_semantics() { assert_rejects(&groups(), &["fixture"]); + assert_rejects(&groups(), &["fixture", "--bypass"]); + assert_rejects(&groups(), &["fixture", "--bypass", "--format"]); + assert_rejects(&groups(), &["fixture", "--mode"]); + assert_accepts(&groups(), &["fixture", "--mode", "--policy", "strict"]); assert_accepts(&groups(), &["fixture", "--format"]); assert_rejects(&groups(), &["fixture", "--mode", "--format", "--policy", "strict"]); - assert_accepts(&groups(), &["fixture", "--format", "--user"]); - assert_rejects(&groups(), &["fixture", "--format", "--user", "--token"]); + assert_rejects(&groups(), &["fixture", "--format", "--stdin"]); + assert_rejects(&groups(), &["fixture", "--format", "--stdin", "--auth"]); + assert_accepts(&groups(), &["fixture", "--format", "--stdin", "--auth", "--user"]); + assert_accepts(&groups(), &["fixture", "--format", "--stdin", "--file", "--auth", "--user"]); + assert_rejects(&groups(), &["fixture", "--format", "--stdin", "--auth", "--user", "--token"]); + assert_rejects(&groups(), &["fixture", "--format", "--stdin", "--auth", "--user", "--legacy"]); + assert_rejects(&groups(), &["fixture", "--format", "--stdin", "--auth", "--user", "--compat"]); + + let plain = + groups().try_get_matches_from(["fixture", "--format"]).expect("no output mode selected"); + assert_eq!(plain.get_one::("output").map(String::as_str), Some("plain")); + assert_eq!(plain.get_one::("group-default").map(String::as_str), Some("plain")); + let mode_selected = groups() + .try_get_matches_from(["fixture", "--mode", "--policy", "strict"]) + .expect("group equality conditional default"); + assert_eq!( + mode_selected.get_one::("group-default").map(String::as_str), + Some("mode-selected") + ); + let selected = groups() + .try_get_matches_from(["fixture", "--format", "--json", "--yaml"]) + .expect("multiple output group members selected"); + assert_eq!(selected.get_one::("output").map(String::as_str), Some("selected")); let contract = build_contract(groups(), &[]); let command = contract.command(&[]).expect("root command"); @@ -195,16 +223,77 @@ fn group_contract_matches_clap_cardinality_semantics() { assert_eq!(selector.members, ["--mode", "--format"]); assert!(selector.required); assert!(!selector.multiple); + assert!(selector.conflicts_with.iter().any(|target| matches!( + target, + ArgumentTarget::Argument { name } if name == "--bypass" + ))); + + let policy = option(&command, "--policy"); + assert!(matches!( + policy.required_if_any.as_slice(), + [condition] + if matches!( + &condition.target, + ArgumentTarget::Group { name } if name == "selector" + ) && condition.equals == "mode" + )); + + let transport = group(&command, "transport"); + assert!(transport.multiple); + let stdin = option(&command, "--stdin"); + assert!(stdin.requires.is_empty()); + assert!(stdin.conflicts_with.is_empty()); + assert!(option(&command, "--legacy").conflicts_with.is_empty()); + assert!(option(&command, "--compat").conflicts_with.is_empty()); + assert!(transport.requires.iter().any(|target| matches!( + target, + ArgumentTarget::Argument { name } if name == "--auth" + ))); + assert!(transport.requires.iter().any(|target| matches!( + target, + ArgumentTarget::Group { name } if name == "credentials" + ))); + assert!(transport.conflicts_with.iter().any(|target| matches!( + target, + ArgumentTarget::Argument { name } if name == "--legacy" + ))); + assert!(transport.conflicts_with.iter().any(|target| matches!( + target, + ArgumentTarget::Group { name } if name == "legacy-mode" + ))); let credentials = group(&command, "credentials"); assert_eq!(credentials.members, ["--user", "--token"]); - assert!(!credentials.required); assert!(!credentials.multiple); + assert!(command.groups.iter().all(|group| group.name != "metadata")); + assert!(command.groups.iter().all(|group| group.name != "single-label")); + assert_eq!(group(&command, "legacy-mode").members, ["--compat"]); + + let output_mode = group(&command, "output-mode"); + assert_eq!(output_mode.members, ["--json", "--yaml"]); + assert!(output_mode.multiple); + + let output = option(&command, "--output").value.as_ref().expect("output value"); + assert!(matches!( + output.default_if.as_slice(), + [conditional] + if matches!( + &conditional.target, + ArgumentTarget::Group { name } if name == "output-mode" + ) && matches!(&conditional.when, ArgumentPredicate::Present) + )); - for omitted in ["transport", "legacy-mode", "metadata", "single-label", "output-mode"] { - assert!( - command.groups.iter().all(|group| group.name != omitted), - "unconstraining group must be omitted: {omitted}" - ); - } + let group_default = + option(&command, "--group-default").value.as_ref().expect("group default value"); + assert!(matches!( + group_default.default_if.as_slice(), + [conditional] + if matches!( + &conditional.target, + ArgumentTarget::Group { name } if name == "selector" + ) && matches!( + &conditional.when, + ArgumentPredicate::Equals { value } if value == "mode" + ) + )); } diff --git a/crates/clap_schema/tests/conformance/boundaries.rs b/crates/clap_schema/tests/conformance/boundaries.rs index e3b6c2b..5bb5aad 100644 --- a/crates/clap_schema/tests/conformance/boundaries.rs +++ b/crates/clap_schema/tests/conformance/boundaries.rs @@ -4,9 +4,10 @@ use clap_schema::ContractBuilder; use serde_json::Value; use super::fixtures::{ - Operation, argument_shape, assert_accepts, assert_rejects, build_contract, groups, hierarchy, - multicall, no_binary_name, option, parser_control_flow, parser_specific_validation, - presentation_visibility, relationships, token_syntax, value_semantics, wire_shape, + Operation, argument_shape, assert_accepts, assert_rejects, build_contract, + conditional_defaults, conditional_requiredness, groups, hierarchy, multicall, no_binary_name, + option, parser_control_flow, parser_specific_validation, presentation_visibility, + relationships, token_syntax, value_semantics, wire_shape, }; #[test] @@ -127,6 +128,18 @@ fn wire_shape_uses_only_the_canonical_contract_vocabulary() { build_contract(relationships(), &[]).command(&[]).expect("relationship contract"), ) .expect("serialize relationship contract"), + serde_json::to_value( + build_contract(conditional_requiredness(), &[]) + .command(&[]) + .expect("conditional requiredness contract"), + ) + .expect("serialize conditional requiredness contract"), + serde_json::to_value( + build_contract(conditional_defaults(), &[]) + .command(&[]) + .expect("conditional default contract"), + ) + .expect("serialize conditional default contract"), serde_json::to_value( build_contract(hierarchy(), &["objects", "get"]) .command(&["objects", "get"]) @@ -175,6 +188,12 @@ fn wire_shape_uses_only_the_canonical_contract_vocabulary() { "global", "repeatable", "conflictsWith", + "overrides", + "requires", + "requiredIfAny", + "requiredIfAll", + "requiredUnlessAny", + "requiredUnlessAll", "requireEquals", "requiresDoubleDash", "trailingVarArg", @@ -191,6 +210,8 @@ fn wire_shape_uses_only_the_canonical_contract_vocabulary() { for omitted in [ "values", "default", + "defaultMissing", + "defaultIf", "delimiter", "terminator", "allowHyphenValues", @@ -209,12 +230,63 @@ fn wire_shape_uses_only_the_canonical_contract_vocabulary() { .expect("serialized global option"); assert_eq!(global["global"], true); + let config = serialized["options"] + .as_array() + .and_then(|options| options.iter().find(|argument| argument["name"] == "--config")) + .expect("serialized config option"); + assert_eq!( + config["requiredIfAny"], + serde_json::json!([{ + "target": {"kind": "argument", "name": "--mode"}, + "equals": "strict" + }]) + ); + assert_eq!(config["value"]["defaultMissing"], "auto"); + assert!(config["value"].get("defaultIf").is_some()); + let conditional = &config["value"]["defaultIf"][0]; + assert_eq!(conditional["target"], serde_json::json!({"kind": "argument", "name": "--mode"})); + assert_eq!(conditional["when"], serde_json::json!({"kind": "equals", "value": "auto"})); + assert_eq!(conditional["value"], "generated"); + assert!(conditional.get("argument").is_none()); + + let publish = serialized["options"] + .as_array() + .and_then(|options| options.iter().find(|argument| argument["name"] == "--publish")) + .expect("serialized publish option"); + assert_eq!( + publish["requires"], + serde_json::json!([{ + "when": {"kind": "present"}, + "target": {"kind": "group", "name": "choice"} + }]) + ); + + let legacy = serialized["options"] + .as_array() + .and_then(|options| options.iter().find(|argument| argument["name"] == "--legacy")) + .expect("serialized legacy option"); + let replacement = serialized["options"] + .as_array() + .and_then(|options| options.iter().find(|argument| argument["name"] == "--replacement")) + .expect("serialized replacement option"); + assert_eq!( + legacy["overrides"], + serde_json::json!([{"kind": "argument", "name": "--replacement"}]) + ); + assert_eq!( + replacement["overrides"], + serde_json::json!([{"kind": "argument", "name": "--legacy"}]) + ); + let choice = serialized["groups"] .as_array() .and_then(|groups| groups.iter().find(|group| group["name"] == "choice")) .expect("serialized choice group"); - assert_eq!(choice["members"], serde_json::json!(["--left", "--right"])); - for omitted in ["required", "multiple"] { + assert_eq!( + choice["conflictsWith"], + serde_json::json!([{"kind": "argument", "name": "--config"}]) + ); + for omitted in ["required", "multiple", "requires"] { assert!(choice.get(omitted).is_none(), "default group field must be omitted: {omitted}"); } } diff --git a/crates/clap_schema/tests/conformance/fixtures/arguments.rs b/crates/clap_schema/tests/conformance/fixtures/arguments.rs index 39d2e3b..535794b 100644 --- a/crates/clap_schema/tests/conformance/fixtures/arguments.rs +++ b/crates/clap_schema/tests/conformance/fixtures/arguments.rs @@ -133,16 +133,40 @@ pub(in crate::tests) fn groups() -> Command { pub(in crate::tests) fn relationships() -> Command { Command::new("fixture") + .arg( + Arg::new("config") + .long("config") + .required_unless_present_any(["stdin", "file"]) + .requires_if("special", "input"), + ) + .arg(Arg::new("stdin").long("stdin").action(ArgAction::SetTrue)) + .arg(Arg::new("file").long("file").action(ArgAction::SetTrue)) + .arg(Arg::new("input").long("input")) + .arg(Arg::new("manifest").long("manifest").default_value("remote").requires("source")) + .arg(Arg::new("source").long("source")) + .arg( + Arg::new("credentials") + .long("credentials") + .required_unless_present_all(["host", "port"]), + ) + .arg(Arg::new("host").long("host")) + .arg(Arg::new("port").long("port")) + .arg(Arg::new("publish").long("publish").action(ArgAction::SetTrue).requires("destination")) + .arg(Arg::new("local").long("local").action(ArgAction::SetTrue)) + .arg(Arg::new("remote").long("remote").action(ArgAction::SetTrue)) .arg(Arg::new("auth").long("auth").conflicts_with("legacy")) .arg(Arg::new("legacy").long("legacy")) + .arg(Arg::new("replacement").long("replacement").overrides_with_all(["config", "legacy"])) + .arg(Arg::new("group-replacement").long("group-replacement").overrides_with("automation")) + .arg(Arg::new("automatic").long("automatic").action(ArgAction::SetTrue)) + .arg(Arg::new("assisted").long("assisted").action(ArgAction::SetTrue)) .arg( Arg::new("manual") .long("manual") .action(ArgAction::SetTrue) .conflicts_with("automation"), ) - .arg(Arg::new("automatic").long("automatic").action(ArgAction::SetTrue)) - .arg(Arg::new("assisted").long("assisted").action(ArgAction::SetTrue)) + .group(ArgGroup::new("destination").args(["local", "remote"]).multiple(true)) .group(ArgGroup::new("automation").args(["automatic", "assisted"]).multiple(true)) } @@ -152,6 +176,74 @@ pub(in crate::tests) fn required_conflict_precedence() -> Command { .arg(Arg::new("skip").long("skip").action(ArgAction::SetTrue)) } +pub(in crate::tests) fn required_unless_group_targets() -> Command { + Command::new("fixture") + .arg(Arg::new("local").long("local").action(ArgAction::SetTrue)) + .arg(Arg::new("remote").long("remote").action(ArgAction::SetTrue)) + .arg(Arg::new("token").long("token").action(ArgAction::SetTrue)) + .arg(Arg::new("certificate").long("certificate").action(ArgAction::SetTrue)) + .arg( + Arg::new("any").long("any").required_unless_present_any(["destination", "credentials"]), + ) + .arg( + Arg::new("all").long("all").required_unless_present_all(["destination", "credentials"]), + ) + .group(ArgGroup::new("destination").args(["local", "remote"]).multiple(true)) + .group(ArgGroup::new("credentials").args(["token", "certificate"]).multiple(true)) +} + +pub(in crate::tests) fn conditional_requiredness() -> Command { + Command::new("fixture") + .arg(Arg::new("mode").long("mode").default_value("strict").ignore_case(true)) + .arg(Arg::new("format").long("format")) + .arg( + Arg::new("any") + .long("any") + .required_if_eq_any([("mode", "strict"), ("format", "json")]), + ) + .arg( + Arg::new("all") + .long("all") + .required_if_eq_all([("mode", "strict"), ("format", "json")]), + ) + .arg(Arg::new("policy-mode").long("policy-mode").action(ArgAction::SetTrue)) + .arg(Arg::new("policy-format").long("policy-format").action(ArgAction::SetTrue)) + .arg(Arg::new("policy").long("policy").required_if_eq("selector", "policy-mode")) + .arg( + Arg::new("combined") + .long("combined") + .required_if_eq_all([("selector", "policy-mode"), ("format", "json")]), + ) + .group(ArgGroup::new("selector").args(["policy-mode", "policy-format"]).multiple(true)) +} + +pub(in crate::tests) fn conditional_defaults() -> Command { + Command::new("fixture") + .arg(Arg::new("profile").long("profile").default_value("auto")) + .arg(Arg::new("trigger").long("trigger").action(ArgAction::SetTrue)) + .arg(Arg::new("disable").long("disable").action(ArgAction::SetTrue)) + .arg(Arg::new("output").long("output").default_value("fallback").default_value_ifs([ + ("trigger", ClapArgPredicate::IsPresent, Some("triggered")), + ("profile", ClapArgPredicate::Equals("auto".into()), Some("generated")), + ])) + .arg(Arg::new("reset").long("reset").default_value("base").default_value_if( + "disable", + ClapArgPredicate::IsPresent, + None, + )) + .arg( + Arg::new("multi") + .long("multi") + .num_args(2) + .default_values(["base-a", "base-b"]) + .default_values_if( + "trigger", + ClapArgPredicate::IsPresent, + ["trigger-a", "trigger-b"], + ), + ) +} + pub(in crate::tests) fn presentation_visibility() -> Command { Command::new("fixture") .version("1.0.0") diff --git a/crates/clap_schema/tests/conformance/fixtures/mod.rs b/crates/clap_schema/tests/conformance/fixtures/mod.rs index ddd2da3..c37a61c 100644 --- a/crates/clap_schema/tests/conformance/fixtures/mod.rs +++ b/crates/clap_schema/tests/conformance/fixtures/mod.rs @@ -11,9 +11,10 @@ mod arguments; mod commands; pub(super) use arguments::{ - argument_shape, groups, multicall, no_binary_name, parser_control_flow, - parser_specific_validation, presentation_visibility, relationships, - required_conflict_precedence, token_syntax, value_semantics, wire_shape, + argument_shape, conditional_defaults, conditional_requiredness, groups, multicall, + no_binary_name, parser_control_flow, parser_specific_validation, presentation_visibility, + relationships, required_conflict_precedence, required_unless_group_targets, token_syntax, + value_semantics, wire_shape, }; pub(super) use commands::{ conflicts_with_subcommands, hierarchy, missing_positionals, negates_requirements, precedence, diff --git a/crates/clap_schema/tests/conformance/relationships.rs b/crates/clap_schema/tests/conformance/relationships.rs index 390fa3d..23d17c8 100644 --- a/crates/clap_schema/tests/conformance/relationships.rs +++ b/crates/clap_schema/tests/conformance/relationships.rs @@ -1,10 +1,99 @@ -//! Conformance tests for argument conflicts and requiredness precedence. +//! Conformance tests for argument relationships, defaults, and precedence. + +use clap::{Arg, ArgAction, Command, builder::ArgPredicate, parser::ValueSource}; +use clap_schema::{ArgumentPredicate, ArgumentTarget}; use super::fixtures::{ - assert_accepts, assert_rejects, build_contract, option, relationships, - required_conflict_precedence, + assert_accepts, assert_rejects, build_contract, conditional_defaults, conditional_requiredness, + option, relationships, required_conflict_precedence, required_unless_group_targets, }; +#[test] +fn requirement_and_required_unless_contracts_match_clap() { + assert_rejects(&relationships(), &["fixture", "--credentials", "secret"]); + assert_accepts(&relationships(), &["fixture", "--stdin", "--credentials", "secret"]); + assert_accepts(&relationships(), &["fixture", "--file", "--credentials", "secret"]); + + assert_accepts(&relationships(), &["fixture", "--config", "normal", "--credentials", "secret"]); + assert_rejects( + &relationships(), + &["fixture", "--config", "special", "--credentials", "secret"], + ); + assert_accepts( + &relationships(), + &["fixture", "--config", "special", "--input", "payload", "--credentials", "secret"], + ); + + assert_accepts(&relationships(), &["fixture", "--stdin", "--credentials", "secret"]); + assert_rejects( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--manifest", "remote"], + ); + assert_accepts( + &relationships(), + &[ + "fixture", + "--stdin", + "--credentials", + "secret", + "--manifest", + "remote", + "--source", + "origin", + ], + ); + + assert_rejects(&relationships(), &["fixture", "--stdin", "--host", "host"]); + assert_accepts(&relationships(), &["fixture", "--stdin", "--host", "host", "--port", "443"]); + + assert_rejects( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--publish"], + ); + assert_accepts( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--publish", "--local"], + ); + + let contract = build_contract(relationships(), &[]); + let command = contract.command(&[]).expect("root command"); + + let config = option(&command, "--config"); + assert!(!config.required); + assert!(matches!( + config.required_unless_any.as_slice(), + [ArgumentTarget::Argument { name: stdin }, ArgumentTarget::Argument { name: file }] + if stdin == "--stdin" && file == "--file" + )); + assert!(config.requires.iter().any(|requirement| matches!( + (&requirement.when, &requirement.target), + ( + ArgumentPredicate::Equals { value }, + ArgumentTarget::Argument { name } + ) if value == "special" && name == "--input" + ))); + + let manifest = option(&command, "--manifest"); + assert!(manifest.requires.iter().any(|requirement| matches!( + (&requirement.when, &requirement.target), + (ArgumentPredicate::Present, ArgumentTarget::Argument { name }) if name == "--source" + ))); + + let credentials = option(&command, "--credentials"); + assert!(matches!( + credentials.required_unless_all.as_slice(), + [ArgumentTarget::Argument { name: host }, ArgumentTarget::Argument { name: port }] + if host == "--host" && port == "--port" + )); + + let publish = option(&command, "--publish"); + assert!(publish.requires.iter().any(|requirement| matches!( + (&requirement.when, &requirement.target), + (ArgumentPredicate::Present, ArgumentTarget::Group { name }) if name == "destination" + ))); + assert!(command.groups.iter().any(|group| group.name == "destination")); +} + #[test] fn base_requiredness_yields_to_conflicts_like_clap() { assert_rejects(&required_conflict_precedence(), &["fixture"]); @@ -21,11 +110,251 @@ fn base_requiredness_yields_to_conflicts_like_clap() { } #[test] -fn conflicts_match_clap_normalization() { - assert_rejects(&relationships(), &["fixture", "--auth", "new", "--legacy", "old"]); - assert_rejects(&relationships(), &["fixture", "--manual", "--automatic"]); - assert_rejects(&relationships(), &["fixture", "--manual", "--assisted"]); - assert_accepts(&relationships(), &["fixture"]); +fn required_unless_rules_accept_group_targets() { + assert_accepts( + &required_unless_group_targets(), + &["fixture", "--any", "value", "--all", "value"], + ); + assert_accepts(&required_unless_group_targets(), &["fixture", "--local", "--all", "value"]); + assert_accepts(&required_unless_group_targets(), &["fixture", "--local", "--token"]); + assert_rejects(&required_unless_group_targets(), &["fixture", "--local"]); + + let contract = build_contract(required_unless_group_targets(), &[]); + let command = contract.command(&[]).expect("root command"); + + let any = option(&command, "--any"); + assert!(matches!( + any.required_unless_any.as_slice(), + [ArgumentTarget::Group { name: destination }, ArgumentTarget::Group { name: credentials }] + if destination == "destination" && credentials == "credentials" + )); + + let all = option(&command, "--all"); + assert!(matches!( + all.required_unless_all.as_slice(), + [ArgumentTarget::Group { name: destination }, ArgumentTarget::Group { name: credentials }] + if destination == "destination" && credentials == "credentials" + )); +} + +#[test] +fn conditional_requiredness_matches_clap_any_all_group_and_case_semantics() { + assert_accepts(&conditional_requiredness(), &["fixture"]); + + assert_rejects(&conditional_requiredness(), &["fixture", "--mode", "STRICT"]); + assert_accepts( + &conditional_requiredness(), + &["fixture", "--mode", "STRICT", "--any", "present"], + ); + + assert_rejects(&conditional_requiredness(), &["fixture", "--format", "json"]); + assert_accepts( + &conditional_requiredness(), + &["fixture", "--format", "json", "--any", "present"], + ); + + assert_rejects( + &conditional_requiredness(), + &["fixture", "--mode", "strict", "--format", "json", "--any", "present"], + ); + assert_accepts( + &conditional_requiredness(), + &[ + "fixture", "--mode", "strict", "--format", "json", "--any", "present", "--all", + "present", + ], + ); + + assert_rejects(&conditional_requiredness(), &["fixture", "--policy-mode"]); + assert_accepts( + &conditional_requiredness(), + &["fixture", "--policy-mode", "--policy", "present"], + ); + assert_rejects( + &conditional_requiredness(), + &[ + "fixture", + "--policy-mode", + "--format", + "json", + "--any", + "present", + "--policy", + "present", + ], + ); + assert_accepts( + &conditional_requiredness(), + &[ + "fixture", + "--policy-mode", + "--format", + "json", + "--any", + "present", + "--policy", + "present", + "--combined", + "present", + ], + ); + + let contract = build_contract(conditional_requiredness(), &[]); + let command = contract.command(&[]).expect("root command"); + + let any = option(&command, "--any"); + assert_eq!(any.required_if_any.len(), 2); + assert!(any.required_if_any.iter().any(|condition| matches!( + &condition.target, + ArgumentTarget::Argument { name } if name == "--mode" && condition.equals == "strict" + ))); + assert!(any.required_if_any.iter().any(|condition| matches!( + &condition.target, + ArgumentTarget::Argument { name } if name == "--format" && condition.equals == "json" + ))); + + let all = option(&command, "--all"); + assert_eq!(all.required_if_all.len(), 2); + assert!(all.required_if_any.is_empty()); + + let policy = option(&command, "--policy"); + assert!(matches!( + policy.required_if_any.as_slice(), + [condition] + if matches!(&condition.target, ArgumentTarget::Group { name } if name == "selector") + && condition.equals == "policy-mode" + )); + + let combined = option(&command, "--combined"); + assert_eq!(combined.required_if_all.len(), 2); + assert!(combined.required_if_all.iter().any(|condition| matches!( + &condition.target, + ArgumentTarget::Group { name } + if name == "selector" && condition.equals == "policy-mode" + ))); + assert!(combined.required_if_all.iter().any(|condition| matches!( + &condition.target, + ArgumentTarget::Argument { name } + if name == "--format" && condition.equals == "json" + ))); + + let mode = option(&command, "--mode").value.as_ref().expect("mode value"); + assert!(mode.ignore_case); +} + +#[test] +fn conflicts_and_overrides_match_clap_normalization_and_precedence() { + let base = ["fixture", "--stdin", "--credentials", "secret"]; + + assert_rejects( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--auth", "new", "--legacy", "old"], + ); + assert_rejects( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--manual", "--automatic"], + ); + assert_rejects( + &relationships(), + &["fixture", "--stdin", "--credentials", "secret", "--manual", "--assisted"], + ); + assert_accepts(&relationships(), &base); + + let config_then_replacement = relationships() + .try_get_matches_from([ + "fixture", + "--stdin", + "--credentials", + "secret", + "--config", + "old", + "--legacy", + "old", + "--replacement", + "new", + ]) + .expect("replacement wins"); + assert!(config_then_replacement.get_one::("config").is_none()); + assert!(config_then_replacement.get_one::("legacy").is_none()); + assert_eq!( + config_then_replacement.get_one::("replacement").map(String::as_str), + Some("new") + ); + + let replacement_then_config = relationships() + .try_get_matches_from([ + "fixture", + "--stdin", + "--credentials", + "secret", + "--replacement", + "old", + "--config", + "new", + ]) + .expect("config wins"); + assert!(replacement_then_config.get_one::("replacement").is_none()); + assert_eq!( + replacement_then_config.get_one::("config").map(String::as_str), + Some("new") + ); + + assert_accepts( + &relationships(), + &[ + "fixture", + "--stdin", + "--credentials", + "secret", + "--config", + "special", + "--replacement", + "new", + ], + ); + assert_rejects( + &relationships(), + &[ + "fixture", + "--stdin", + "--credentials", + "secret", + "--replacement", + "new", + "--config", + "special", + ], + ); + assert_accepts( + &relationships(), + &[ + "fixture", + "--stdin", + "--credentials", + "secret", + "--auth", + "new", + "--legacy", + "old", + "--replacement", + "new", + ], + ); + assert_rejects( + &relationships(), + &[ + "fixture", + "--stdin", + "--credentials", + "secret", + "--replacement", + "new", + "--legacy", + "old", + "--auth", + "new", + ], + ); let contract = build_contract(relationships(), &[]); let command = contract.command(&[]).expect("root command"); @@ -40,4 +369,166 @@ fn conflicts_match_clap_normalization() { assert!(manual.conflicts_with.contains(&"--assisted".to_owned())); assert!(option(&command, "--automatic").conflicts_with.contains(&"--manual".to_owned())); assert!(option(&command, "--assisted").conflicts_with.contains(&"--manual".to_owned())); + + let config = option(&command, "--config"); + let replacement = option(&command, "--replacement"); + assert!( + config.overrides.contains(&ArgumentTarget::Argument { name: "--replacement".to_owned() }) + ); + assert!( + legacy.overrides.contains(&ArgumentTarget::Argument { name: "--replacement".to_owned() }) + ); + assert!( + replacement.overrides.contains(&ArgumentTarget::Argument { name: "--config".to_owned() }) + ); + assert!( + replacement.overrides.contains(&ArgumentTarget::Argument { name: "--legacy".to_owned() }) + ); + assert!( + option(&command, "--group-replacement") + .overrides + .contains(&ArgumentTarget::Group { name: "automation".to_owned() }) + ); +} + +#[test] +fn conditional_defaults_match_clap_ordering_default_sources_and_reset_semantics() { + let default_sourced = conditional_defaults() + .try_get_matches_from(["fixture"]) + .expect("default-sourced predicate input"); + assert_eq!(default_sourced.get_one::("profile").map(String::as_str), Some("auto")); + assert_eq!(default_sourced.value_source("profile"), Some(ValueSource::DefaultValue)); + + let triggered = conditional_defaults() + .try_get_matches_from(["fixture", "--trigger"]) + .expect("conditional default"); + assert_eq!(triggered.get_one::("output").map(String::as_str), Some("triggered")); + assert_eq!( + triggered + .get_many::("multi") + .expect("conditional multi default") + .map(String::as_str) + .collect::>(), + ["trigger-a", "trigger-b"] + ); + + let ordered = conditional_defaults() + .try_get_matches_from(["fixture", "--trigger", "--profile", "auto"]) + .expect("ordered conditional defaults"); + assert_eq!(ordered.get_one::("output").map(String::as_str), Some("triggered")); + + let explicitly_reset = conditional_defaults() + .try_get_matches_from(["fixture", "--disable"]) + .expect("explicit reset condition"); + assert!(explicitly_reset.get_one::("reset").is_none()); + + let explicit = conditional_defaults() + .try_get_matches_from([ + "fixture", + "--trigger", + "--output", + "explicit", + "--reset", + "explicit", + ]) + .expect("explicit values"); + assert_eq!(explicit.get_one::("output").map(String::as_str), Some("explicit")); + assert_eq!(explicit.get_one::("reset").map(String::as_str), Some("explicit")); + + let contract = build_contract(conditional_defaults(), &[]); + let command = contract.command(&[]).expect("root command"); + + let output = option(&command, "--output").value.as_ref().expect("output value"); + assert_eq!(output.default, Some(serde_json::Value::String("fallback".to_owned()))); + assert_eq!(output.default_if.len(), 2); + assert!(matches!( + &output.default_if[0], + conditional + if matches!( + &conditional.target, + ArgumentTarget::Argument { name } if name == "--trigger" + ) + && matches!(&conditional.when, ArgumentPredicate::Present) + && conditional.value + == Some(serde_json::Value::String("triggered".to_owned())) + )); + assert!(matches!( + &output.default_if[1], + conditional + if matches!( + &conditional.target, + ArgumentTarget::Argument { name } if name == "--profile" + ) + && matches!( + &conditional.when, + ArgumentPredicate::Equals { value } if value == "auto" + ) + && conditional.value + == Some(serde_json::Value::String("generated".to_owned())) + )); + + let reset = option(&command, "--reset").value.as_ref().expect("reset value"); + assert_eq!(reset.default, Some(serde_json::Value::String("base".to_owned()))); + assert!(matches!( + reset.default_if.as_slice(), + [conditional] + if matches!( + &conditional.target, + ArgumentTarget::Argument { name } if name == "--disable" + ) + && matches!(&conditional.when, ArgumentPredicate::Present) + && conditional.value.is_none() + )); + + let multi = option(&command, "--multi").value.as_ref().expect("multi value"); + assert_eq!(multi.default, Some(serde_json::json!(["base-a", "base-b"]))); + assert!(matches!( + multi.default_if.as_slice(), + [conditional] + if matches!( + &conditional.target, + ArgumentTarget::Argument { name } if name == "--trigger" + ) + && matches!(&conditional.when, ArgumentPredicate::Present) + && conditional.value + == Some(serde_json::json!(["trigger-a", "trigger-b"])) + )); +} + +#[test] +#[ignore = "blocked by https://github.com/clap-rs/clap/issues/4918"] +fn default_value_if_is_present_ignores_defaulted_set_true() { + let matches = Command::new("fixture") + .arg(Arg::new("trigger").long("trigger").action(ArgAction::SetTrue)) + .arg(Arg::new("output").long("output").default_value("fallback").default_value_if( + "trigger", + ArgPredicate::IsPresent, + Some("triggered"), + )) + .try_get_matches_from(["fixture"]) + .expect("command should parse"); + + assert_eq!(matches.value_source("trigger"), Some(ValueSource::DefaultValue),); + assert!(!matches.get_flag("trigger")); + + assert_eq!(matches.get_one::("output").map(String::as_str), Some("fallback"),); +} + +#[test] +#[ignore = "blocked by https://github.com/clap-rs/clap/issues/4918"] +fn default_value_if_none_is_present_preserves_base_default_for_defaulted_set_true() { + let matches = Command::new("fixture") + .arg(Arg::new("disable").long("disable").action(ArgAction::SetTrue)) + .arg(Arg::new("reset").long("reset").default_value("base").default_value_if( + "disable", + ArgPredicate::IsPresent, + None, + )) + .try_get_matches_from(["fixture"]) + .expect("command should parse"); + + assert_eq!(matches.value_source("disable"), Some(ValueSource::DefaultValue)); + assert!(!matches.get_flag("disable")); + + assert_eq!(matches.get_one::("reset").map(String::as_str), Some("base"),); } diff --git a/crates/clap_schema/tests/support/ui.rs b/crates/clap_schema/tests/support/ui.rs index c1a4de1..caf4d99 100644 --- a/crates/clap_schema/tests/support/ui.rs +++ b/crates/clap_schema/tests/support/ui.rs @@ -38,7 +38,7 @@ impl UiProject { let facade = repository_root().to_string_lossy().replace('\\', "/"); let manifest = format!( - "[package]\nname = \"clap_schema-ui-{fixture}\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nclap = {{ version = \"4.6.6\", features = [\"derive\"] }}\nclap_schema = {{ path = \"{facade}\" }}\n" + "[package]\nname = \"clap_schema-ui-{fixture}\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nclap = {{ git = \"https://github.com/zerosnacks/clap\", rev = \"2883a7f13718bb4c3cf0456cf831176d20a2ae14\", version = \"4.6.6\", features = [\"derive\"] }}\nclap_schema = {{ path = \"{facade}\" }}\n" ); fs::write(root.join("Cargo.toml"), manifest).unwrap_or_else(|error| { panic!("failed to write temporary UI manifest `{}`: {error}", root.display()) diff --git a/deny.toml b/deny.toml index 62cbe64..18ea700 100644 --- a/deny.toml +++ b/deny.toml @@ -51,7 +51,9 @@ unknown-registry = "deny" # Lint level for what to happen when a crate from a git repository that is not # in the allow list is encountered unknown-git = "deny" -allow-git = [] +allow-git = [ + "https://github.com/zerosnacks/clap", # Temporary for clap-rs/clap#6494 +] allow-registry = [ "https://github.com/rust-lang/crates.io-index", ] From c5461509168bd520357c876c97f3fa0da3741cd6 Mon Sep 17 00:00:00 2001 From: zerosnacks <95942363+zerosnacks@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:47:18 +0200 Subject: [PATCH 2/2] Update title of SPECIFICATION.md --- SPECIFICATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPECIFICATION.md b/SPECIFICATION.md index bf5fa13..05c292d 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -1,4 +1,4 @@ -# clap_schema Discovery Contract 0.2 +# Specification This document specifies the machine-readable discovery contract emitted by `clap_schema` 0.2.x.