Skip to content

feat(spec): add conflicts to flags - #819

Merged
jdx merged 4 commits into
agent/derive-nestingfrom
agent/spec-conflicts
Aug 12, 2026
Merged

feat(spec): add conflicts to flags#819
jdx merged 4 commits into
agent/derive-nestingfrom
agent/spec-conflicts

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

The spec can say overrides, required_if and required_unless, but not that two flags must not be given together. mise declares forty conflicts_with relationships in clap, and the From<&clap::Command> bridge was dropping every one — so a spec generated from mise's CLI silently lost them.

Per the canonicality law (code authors, the spec defines), the spec gains the property before the derive can carry it.

What's here

  • SpecFlag.conflicts, parsed as a property for one target (conflicts="--stdin") or a child node for several, and serialized back the same way — mirroring overrides.
  • The clap bridge now reads Command::get_arg_conflicts_with. clap keeps conflicts on the command rather than on the argument, so the conversion in cmd.rs is the only place both are in view.
  • Enforcement in usage-lib's parser, beside the requirement checks: a conflict is a question about the invocation as a whole, not about any one token, since the flag it conflicts with may still be ahead of it. It gets its own loop because the requirement loop skips exactly the flags a conflict check needs — the ones that were given.
  • Four corpus vectors and a conflicting_flags error code.

overrides vs conflicts is the distinction worth keeping straight, and it's now documented: overrides resolves a collision by letting the last flag win (--color/--no-color, where a later flag is a correction); conflicts reports it, for combinations with no sensible meaning, where silently honouring one hides the mistake.

Notes for review

  • clap exposes only the direction the conflict was declared in, so the spec records it once. That's sufficient because the check looks at every flag that was given — there's a test asserting both orders are refused.
  • UsageErr::InvalidFlag { reason: "conflicts with …" } is reused rather than adding a variant: UsageErr isn't #[non_exhaustive], so a new variant would be a breaking change for a diagnostic that reads fine as an invalid-flag reason.
  • Conflicts fire on flags the user gave (including via env, consistent with how required_unless treats a selector), never on defaults.
  • Stacked on feat(derive): nest commands to any depth #818.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.


Note

Medium Risk
Touches core argv post-binding validation and the clap→spec bridge, so incorrect conflict handling could accept or reject flag combinations wrongly. Changes are additive and covered by corpus/unit tests.

Overview
Adds conflicts on flags so two flags can be declared mutually exclusive and rejected when both are present — unlike overrides, which lets the last one win.

The clap bridge now carries conflicts_with into the spec (it was previously dropped because clap stores conflicts on the command). The parser enforces conflicts after binding, in either order, including values supplied via env.

Also wires conflicts=--out-file onto the CLI's --multi markdown flag, and documents the new conflicting_flags error in the corpus and spec docs.

Reviewed by Cursor Bugbot for commit 5de5a53. Bugbot is set up for automated code reviews on this repo. Configure here.

The spec could say `overrides`, `required_if` and `required_unless`, but not
that two flags must not be given together — while mise declares forty
`conflicts_with` relationships in clap, every one of which the
`From<&clap::Command>` bridge was silently dropping. Anything the derive is
going to express has to lower into the spec first, so the spec gains it here.

`conflicts` takes one target as a property or several as a child node, mirroring
`overrides`. The distinction between the two is the point: `overrides` resolves a
collision by letting the last flag win, `conflicts` reports it, for combinations
that have no sensible meaning at all.

The clap bridge reads `Command::get_arg_conflicts_with`, since clap keeps
conflicts on the command rather than on the argument — the conversion is the only
place both are in view. clap exposes only the direction the conflict was declared
in, which is enough: the check looks at every flag that was given, so either
order is refused.

Four corpus vectors and a new `conflicting_flags` error code go with it.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 35eb60a7-dab2-47ab-a628-c2e887661f6f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁█████████ 148,242,482 → 148,263,983 +0.01% 13.93 → 15.38ms +10.44%
startup ▄▄▄▄▄▄▄█████▁▁▁▅ 1,199,527 → 1,201,983 +0.20% 0.95 → 0.98ms +3.70%

No instruction-count regression above 1%.

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

5de5a532e62f vs 7047ae1232b2 · measured on the runner, not pushed to the history.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds mutually exclusive flag metadata to the usage specification and preserves clap conflict declarations during conversion.

  • Parses and serializes single or multiple conflicts selectors.
  • Enforces conflicts after argument binding, including environment-derived values.
  • Converts clap conflict targets using long names or short-only fallbacks.
  • Adds conformance vectors, diagnostics classification, and specification documentation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/src/spec/cmd.rs Converts clap conflicts into usage selectors and now preserves short-only targets as -x.
lib/src/spec/flag.rs Adds the conflicts model, KDL round-trip support, empty-vector JSON omission, and clap bridge tests.
lib/src/parse.rs Enforces declared conflicts after binding while excluding overridden flags and counting environment-derived values.
conformance/src/reference.rs Classifies conflict diagnostics under the new conformance error code.
corpus/08-choices-and-required.json Adds vectors covering order independence, unrelated flags, and environment-provided conflicts.
cli/usage.usage.kdl Declares the usage CLI’s --multi and --out-file options as conflicting.

Reviews (4): Last reviewed commit: "fix(spec): name a short-only flag in a c..." | Re-trigger Greptile

Comment thread lib/src/spec/cmd.rs Outdated
Comment thread lib/src/spec/flag.rs
A flag backed by an env var was only counted as given when it was the flag
named by someone else conflicts, not when it was the one doing the naming, so
the same pair was a conflict or not depending on which one happened to be typed.

clap treats an environment variable as a value source rather than a weaker kind
of default and reports the conflict in all three combinations — verified against
clap 4 directly, including env-on-both-sides. Two corpus vectors pin it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a259a46. Configure here.

Comment thread lib/src/spec/flag.rs
Comment thread lib/src/spec/cmd.rs
jdx added 2 commits August 12, 2026 01:20
Every flag in the generated JSON grew a `"conflicts": []`, which is what the
render check caught. The neighbouring lists all skip when empty; this one now
does too.

The render also picks up the first flag the bridge fix rescues: usage`s own
`--multi` declares `conflicts_with("--out-file")` in clap, and its committed spec
had never said so.
A conflict pointing at a flag with no long form was dropped: `get_long()` gives
nothing for `-q`, and the spec was left accepting a combination clap rejects —
the same class of loss this change set out to fix. Selectors take `-q` as readily
as `--long`, so the short form is used when it is the only name a flag has.

A conflict pointing at a positional is still dropped, and now deliberately: the
spec cannot name one in a conflict, and writing `--<name>` would be a selector
matching nothing, which reads as a relationship that holds.

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Both findings were real; the short-only one was the more serious.

Short-only conflicts dropped — fixed. get_long() gives nothing for -q, so a conflict pointing at a short-only flag was discarded and the spec was left accepting a combination clap rejects. Selectors already take -q as readily as --long (flag_keys renders both), so the bridge now falls back to the short form when it is the only name a flag has. Test added with a short-only target.

A conflict pointing at a positional is still dropped, and now deliberately, with a comment saying so: the spec has no way to name a positional in a conflict, and writing --<name> would produce a selector that matches nothing — which reads as a relationship that holds while doing nothing.

Empty conflicts serialized — already fixed, in the commit after the one reviewed. The render check caught it first: every flag in docs/cli/reference/commands.json had grown a "conflicts": [].

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx merged commit a3f0f78 into main Aug 12, 2026
9 checks passed
@jdx
jdx deleted the agent/spec-conflicts branch August 12, 2026 13:57
jdx added a commit that referenced this pull request Aug 12, 2026
…er (#820)

Stacked on #819, which taught the spec `conflicts`. This teaches the
derive to declare it, along with the other two relationships that need
only *whether* a flag was given: `required_if` and `required_unless`.

```rust
#[derive(usage::Cli)]
struct Rel {
    /// Read from a file
    #[usage(long, required_unless("--url", "--stdin"))]
    file: Option<String>,
    /// Read from standard input
    #[usage(short = 's', long, conflicts("--file", "--url"))]
    stdin: bool,
    /// Where to write
    #[usage(long, required_if = "--stdin")]
    out: Option<String>,
}
```

Each takes a flag the way the spec names one — `"--long"` or `"-s"` —
with one as a value and several as a list. They reach `FlagMeta`, so the
emitted KDL says what the struct says and `usage g markdown` and the
completions can mention them.

## Worth a look

- **A selector naming no flag is a compile error**, and so is one naming
its own field. This is the concrete advantage of declaring relationships
in code rather than in KDL: a typo'd selector in a hand-written spec is
a relationship that quietly does not hold, and nothing ever tells you.
Unit-tested through `Cli::from_input`, including resolution by long,
short, and negation spelling.
- **Env counts on both sides.** The checks read `__given_*` after the
environment fallback, so a value from the environment is as good as a
typed one — matching clap (verified directly, including
env-on-both-sides) and #819's usage-lib behaviour. An asymmetric rule
would make the same pair a conflict or not depending on which side
happened to be typed.
- **Conflicts are reported before required-ness**, matching usage-lib:
when a conflict has also left something unfilled, "you gave two flags
that cannot go together" beats "give me one more flag". There's a test
for that ordering.
- **`overrides` is deliberately not here.** It is the one relationship
that asks which flag came *last* rather than whether both arrived, and
nothing records arrival order yet — so it gets its own PR rather than a
half-answer in this one.
- The derive docs had a duplicated "Typed values" bullet and still
claimed `env` was unread and required-ness unreported, both of which
stopped being true a few PRs ago. Corrected.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5;
version: unavailable.*

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new post-binding constraint enforcement that changes which
command lines parse successfully, but the checks are well-tested and do
not touch auth or data handling.
> 
> **Overview**
> Teaches the derive to declare **flag relationships** that only need
whether a flag was given: `conflicts`, `required_if`, and
`required_unless`.
> 
> ```rust
> #[usage(long, conflicts("--file", "--url"))]
> stdin: bool,
> #[usage(long, required_if = "--stdin")]
> out: Option<String>,
> ```
> 
> Selectors use the spec's spelling (`"--long"` / `"-s"`), resolve at
compile time (unknown or self-referential names are errors), and flow
into `FlagMeta` so emitted KDL, docs, and completions see them.
Post-binding checks enforce them after env fallback — env counts on both
sides — and report conflicts before missing-required errors. `overrides`
is deliberately deferred; it needs arrival order.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b3d833d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant