Skip to content

feat(derive): let a variant hold its command in a Box - #828

Merged
jdx merged 1 commit into
mainfrom
agent/derive-boxed
Aug 12, 2026
Merged

feat(derive): let a variant hold its command in a Box#828
jdx merged 1 commit into
mainfrom
agent/derive-boxed

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Stacked on #827. This is the one you pointed at: "the purpose behind the boxing was to avoid clap issues when commands get too big."

An enum is as large as its biggest variant, so one thirty-flag subcommand makes every invocation of a mise-sized CLI move that much stack — and at that size clap itself starts to struggle, which is why mise boxes its largest commands.

#[derive(usage::Subcommands)]
enum Commands {
    Install(Box<Install>),   // boxed
    Nudge(Nudge),            // not, and the two work side by side
}

The box is read from the written form, as Option<T> is elsewhere. It is an artifact of how the variant holds the struct: the tables, the partial and build all speak to the struct itself, and the box goes back on at the single point a value is made. A test checks the emitted spec cannot tell the difference — boxing is not something a CLI has.

What it unlocks

The generated shadows are ordinary workspace members again. clippy::large_enum_variant was the reason they sat outside the workspace, and boxing answers the lint instead of silencing it — so 12k lines of generated code are now linted like everything else, and there is still no allow anywhere in the repo.

It is also slightly faster

Not the point, but worth recording: 51.1k instructions against 51.3k, because moving a Commands as large as its biggest variant costs more than a malloc and a pointer move. Wall clock 2.05µs, ratio still 117×.

One note on process

My first version shadowed name — the command's name — with the held type's name, so every command was suddenly called after its struct and no subcommand matched. The existing subcommand tests failed immediately, which is exactly the argument for having them; it took one diff to find.

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


Note

Medium Risk
Changes core derive codegen and regenerates very large benchmark shadows; parsing/spec behavior is covered by new conformance tests, but any derive bug affects all adopters.

Overview
usage-derive now accepts Install(Box<Install>) (and mixed boxed/unboxed enums). The macro strips the Box for tables, partials, and build, then re-wraps with Box::new when constructing the enum variant. Types like Box<cmds::Deep> are parsed syntactically so generated code keeps the full path. Emitted KDL/spec is unchanged.

Shadow crates are first-class workspace members again. xtask gen-shadow emits boxed subcommand variants (matching mise/clap practice), so large_enum_variant no longer forces exclude = ["benches/shadows"]. Regenerated mise and mise-clap shadows are the bulk of the diff.

Conformance adds tests that boxed variants parse like unboxed ones and that boxing does not leak into the spec.

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

Summary by CodeRabbit

  • New Features

    • Added support for boxed subcommand argument structures in generated CLI definitions.
    • Boxed and unboxed subcommands are both parsed correctly, including aliases and nested command paths.
    • Command names, aliases, options, and generated specifications remain unchanged.
  • Documentation

    • Clarified boxed subcommand support and its effect on enum storage.
  • Tests

    • Added end-to-end coverage for boxed variants and compatibility with existing unboxed variants.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The derive layer now supports boxed subcommand payloads. Generated mise and mise-clap command enums use Box, while parsing, aliases, fields, and specifications remain unchanged. Conformance tests cover boxed variants and path-qualified payload types.

Changes

Boxed subcommand support

Layer / File(s) Summary
Variant model and code generation
derive/src/model.rs, derive/src/codegen.rs, derive/src/lib.rs
The derive model detects Box<T> payloads and records their boxed state. Code generation conditionally wraps constructed payloads in Box. Documentation describes the storage-only change.
Generated shadow command enums
xtask/src/shadow.rs, benches/shadows/mise/src/lib.rs, benches/shadows/mise-clap/src/lib.rs
Generated subcommand variants now store argument structures in Box across nested and top-level command enums.
Conformance and project status
conformance/tests/subcommands.rs, Cargo.toml, PLAN.md
Tests cover boxed and unboxed parsing, aliases, fields, specifications, and module-qualified types. Workspace membership and shadow-generation status are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VariantParser
  participant DeriveCodegen
  participant ShadowGenerator
  participant ConformanceTests
  VariantParser->>DeriveCodegen: provide payload type and boxed state
  DeriveCodegen->>ShadowGenerator: generate Box-wrapped enum variants
  ShadowGenerator->>ConformanceTests: expose boxed command definitions
  ConformanceTests->>VariantParser: parse commands and compare specifications
Loading

Possibly related PRs

  • jdx/usage#816: Both changes modify subcommand derive modeling and code generation.
  • jdx/usage#818: Both changes modify nested subcommand generation and variant selection.

Poem

A rabbit hops through enums bright,
And boxes payloads snug and light.
Commands parse, aliases stay,
Specs remain the same each day.
The shadow crates now bloom—
“Hop!” says Bun, “no fields assume!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: support for storing subcommand variant payloads in a Box.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds support for boxed subcommand payloads while keeping generated parser tables and emitted CLI specifications based on the inner command type.

  • Preserves qualified inner payload paths while syntactically unwrapping Box<T>.
  • Re-boxes built command values when constructing enum variants.
  • Updates shadow generation to box subcommands and brings generated shadow crates into the workspace.
  • Adds parsing, mixed boxed/unboxed, specification, and qualified-path coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
derive/src/model.rs Syntactically recognizes boxed variant payloads while preserving the complete inner type path; the previously reported qualified-path issue is fixed.
derive/src/codegen.rs Builds command payloads through the inner CommandArgs implementation and restores Box only when constructing the enum variant.
conformance/tests/subcommands.rs Adds end-to-end coverage for boxed parsing, mixed payload forms, spec transparency, and path-qualified inner command types.
xtask/src/shadow.rs Generates boxed subcommand variants so large generated command enums satisfy workspace linting.
Cargo.toml Adds the generated mise shadow crates as ordinary workspace members after boxing removes the large-enum lint problem.

Reviews (6): Last reviewed commit: "feat(derive): let a variant hold its com..." | Re-trigger Greptile

Comment thread derive/src/model.rs Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

Nothing was compared, and so nothing was gated. No series appears on both sides: either the base has no measurements recorded, or the two were measured on different runner classes, which are deliberately not comparable — counts shift between machine types by more than a real regression does.

New, nothing to compare against: markdown on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, startup on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.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.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

usage clap ratio
instructions, cold parse 51127 5960254 116x
usage: argv -> struct                            2070 ns      2.07 µs
clap: build tree + parse -> struct             499463 ns    499.46 µs
clap: parse -> struct, tree reused              23323 ns     23.32 µs
clap: build tree only                          312157 ns    312.16 µs

0d9f107e1a64 vs 9d1d41857bca · measured on the runner, not pushed to the history.

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Correct, and it was a bad shortcut on my part: I read the box out of a rendered type string, and type_name keeps only the last segment. Box<cmds::Deep> came out as Deep, so the generated code named a type that is not in scope, and the derive failed to compile unless the struct happened to be imported.

Taken apart syntactically now — the last path segment's angle-bracketed argument — which keeps the path and reads std::boxed::Box<T> as well as Box<T>. Box<T, A> names an allocator this cannot reason about, so it is left alone and fails as the type the user wrote, with their name in the error rather than mine.

There's a test with the command declared in another module and referred to as Box<cmds::Deep>.

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

@jdx
jdx force-pushed the agent/derive-boxed branch from d05c43d to 06cbca6 Compare August 12, 2026 15:50
@jdx
jdx force-pushed the agent/derive-boxed branch from 06cbca6 to 90f6edf Compare August 12, 2026 15:50
@jdx
jdx force-pushed the agent/derive-boxed branch from 90f6edf to 3c17ee6 Compare August 12, 2026 16:07

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

The unbox fix is present here — fn unbox takes the type apart syntactically, so Box<cmds::Deep> keeps its path — and there is a test with the command declared in another module. That finding is a thread re-anchored after a force-push.

Worth knowing what the force-push was for, though: this branch had silently lost four of #826's fixes, because I restacked it by replaying a content patch captured against the older base, which reverts newer changes touching the same lines. Rebuilt on the corrected parent, with the fixes verified on the pushed ref.

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

@jdx
jdx force-pushed the agent/derive-boxed branch from 3c17ee6 to fbba60b Compare August 12, 2026 16:34
Base automatically changed from agent/derive-aliases to main August 12, 2026 19:55
An enum is as large as its biggest variant, so one subcommand with thirty flags makes every
invocation of a mise-sized CLI move that much stack. mise answers that by boxing its largest
commands — and, per jdx, by boxing them to stay out of trouble with clap at that size.

`Install(Box<Install>)` now works, and the box is taken apart syntactically rather than by
rendering the type and parsing it back: `type_name` keeps only the last segment, so
`Box<cmds::Deep>` would have come out as `Deep` and named a type that is not in scope.
`std::boxed::Box<T>` is read too.

The box is an artifact of how the variant holds the struct: the tables, the partial and `build`
all speak to the struct itself, and the box goes back on at the one point a value is made.
Nothing about the spec changes, and a test checks the emitted KDL cannot tell.

The shadow generator boxes every variant, which is what let the generated crates rejoin the
workspace: `clippy::large_enum_variant` had been the reason they sat outside it, and answering
the lint rather than silencing it means 12k lines of generated code are linted like everything
else.

It is also faster, which was not the point: moving a `Commands` as large as its biggest variant
costs more than a malloc and a pointer.
@jdx
jdx force-pushed the agent/derive-boxed branch from fbba60b to 0d9f107 Compare August 12, 2026 19:55

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
conformance/tests/subcommands.rs (1)

406-443: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding negative coverage for Box<T, A>.

The PR states that a Box with an allocator parameter is left unwrapped and fails using the user-provided type. No test records that behavior, so a later change to the unwrapping rule would go unnoticed. If the repository has a compile-fail harness such as trybuild, add a case for Install(Box<Install, MyAlloc>).

#!/bin/bash
# Description: Check for an existing compile-fail test harness.
set -euo pipefail

rg -n 'trybuild|compile_fail|compiletest' --glob '!target' .
fd -t d 'ui' derive conformance
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@conformance/tests/subcommands.rs` around lines 406 - 443, Add negative
compile-fail coverage for the qualified boxed-command handling around
QualifiedCommands, using a Box<T, A> declaration such as Install(Box<Install,
MyAlloc>) and the repository’s existing compile-fail harness if available.
Assert that the allocator-parameter form is not unwrapped and therefore fails
with the user-provided type, preserving the current behavior.
xtask/src/shadow.rs (1)

401-405: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider qualifying Box for the Usage dialect.

This file qualifies prelude types in generated Usage-dialect code. Line 340 emits ::std::option::Option<...> and Dialect::vec emits ::std::vec::Vec<::std::string::String>, while the Clap dialect uses the bare names. Line 405 emits a bare Box<...> for both dialects, which breaks that pattern. The generated code compiles today, so this is a consistency concern only.

♻️ Proposed change to follow the existing dialect convention
-            out.push_str(&format!("    {variant}(Box<{}>),\n", sub_ty.args));
+            let boxed = match dialect {
+                Dialect::Usage => format!("::std::boxed::Box<{}>", sub_ty.args),
+                Dialect::Clap => format!("Box<{}>", sub_ty.args),
+            };
+            out.push_str(&format!("    {variant}({boxed}),\n"));

Note: the derive must accept ::std::boxed::Box<T> for this change. The PR description states that qualified paths are supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@xtask/src/shadow.rs` around lines 401 - 405, Update the generated type in the
shadow output around the variant formatting to qualify Box for the Usage dialect
as ::std::boxed::Box<...>, while preserving the Clap dialect’s existing bare Box
form. Ensure the derive handling accepts the qualified path and keep the
existing sub_ty.args generic payload unchanged.
Cargo.toml (1)

12-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define workspace.default-members for faster contributor builds.

The virtual workspace currently selects all members for bare root commands. Exclude benches/gate and both generated shadow crates from default-members; run them explicitly with --workspace in benchmark and lint jobs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` around lines 12 - 18, Define the workspace.default-members list
in Cargo.toml to include the regular workspace crates while excluding
benches/gate, benches/shadows/mise, and benches/shadows/mise-clap. Keep those
excluded crates available as workspace members so benchmark and lint jobs can
still target them explicitly with --workspace.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@PLAN.md`:
- Around line 191-197: Update the usage-derive v1 checklist entry to remove
“boxed subcommand variants” from the unsupported features list, keeping the
newly documented boxed generated command enum behavior consistent.

---

Nitpick comments:
In `@Cargo.toml`:
- Around line 12-18: Define the workspace.default-members list in Cargo.toml to
include the regular workspace crates while excluding benches/gate,
benches/shadows/mise, and benches/shadows/mise-clap. Keep those excluded crates
available as workspace members so benchmark and lint jobs can still target them
explicitly with --workspace.

In `@conformance/tests/subcommands.rs`:
- Around line 406-443: Add negative compile-fail coverage for the qualified
boxed-command handling around QualifiedCommands, using a Box<T, A> declaration
such as Install(Box<Install, MyAlloc>) and the repository’s existing
compile-fail harness if available. Assert that the allocator-parameter form is
not unwrapped and therefore fails with the user-provided type, preserving the
current behavior.

In `@xtask/src/shadow.rs`:
- Around line 401-405: Update the generated type in the shadow output around the
variant formatting to qualify Box for the Usage dialect as
::std::boxed::Box<...>, while preserving the Clap dialect’s existing bare Box
form. Ensure the derive handling accepts the qualified path and keep the
existing sub_ty.args generic payload unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b46f158-b0ff-401a-bd72-60b492536290

📥 Commits

Reviewing files that changed from the base of the PR and between 9d1d418 and 0d9f107.

📒 Files selected for processing (9)
  • Cargo.toml
  • PLAN.md
  • benches/shadows/mise-clap/src/lib.rs
  • benches/shadows/mise/src/lib.rs
  • conformance/tests/subcommands.rs
  • derive/src/codegen.rs
  • derive/src/lib.rs
  • derive/src/model.rs
  • xtask/src/shadow.rs

Comment thread PLAN.md
Comment on lines +191 to +197
counts: 13 secondary flag aliases, 3 `double_dash="automatic"`, 2 mounts, 2 restart
tokens, 1 `default_subcommand`, 1 default on a collecting flag. The
`default_subcommand` is the one that changes the _root's_ grammar, since
`mise build` routes through `run` in mise and answers at the root in the shadow.
The generated crates are ordinary workspace members: their command enums are boxed,
as the real mise boxes its own, so `large_enum_variant` has nothing to say and no
lint is silenced anywhere.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the derive v1 checklist entry for boxed variants.

This paragraph now states that the generated command enums are boxed. The usage-derive v1 item earlier in the same file still lists "boxed subcommand variants" among the features that are not yet supported. This PR adds that support, so the two statements conflict. Remove that phrase from the v1 list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PLAN.md` around lines 191 - 197, Update the usage-derive v1 checklist entry
to remove “boxed subcommand variants” from the unsupported features list,
keeping the newly documented boxed generated command enum behavior consistent.

@jdx
jdx merged commit 2e271be into main Aug 12, 2026
9 checks passed
@jdx
jdx deleted the agent/derive-boxed branch August 12, 2026 20:00
jdx added a commit that referenced this pull request Aug 12, 2026
…mmands nobody ran (#829)

Stacked on #828. The last number the gate owed, and it turned up a real
inefficiency on the way.

## The result

| invocation | usage | clap |
| --- | --- | --- |
| bare `mise` | **0** | — |
| `mise use -g node@20` | **3** | **6,560** |
| `mise settings set experimental true` | **4** | — |

At mise's scale — 211 commands, 711 flags — a parse with nothing to bind
never reaches the allocator, and binding three or four words costs one
allocation per value. clap builds its tree on the way to parsing and the
tree is the CLI, every time. About **2,000× fewer**.

## The inefficiency it found

Declared defaults were applied in `start()`, which builds the partial
for **every** command in the CLI rather than the selected one. So a bare
`mise` allocated **60 `String`s** for defaults it would never read — the
CLI's size leaking into the invocation, which is the one thing this
design exists to avoid.

They run in `check()` now, which runs for the selected command only,
guarded on `__given_*` so a negation that turned a defaulted `bool` off
during the parse is not undone afterwards. That is what takes the bare
parse from 60 allocations to 0.

## Two corrections to the instrument, which are the interesting part

- **The counter was wrong.** Armed per thread but counting into a
*global*, so tests running in parallel counted each other's allocations:
a 4-allocation parse read as 24, and a 0-allocation one as 3,
intermittently, depending on which tests overlapped. Both halves are
thread-local now. **usage-argv's own zero-alloc counter had the same
latent flaw** — harmless with one test, wrong the moment a second is
added — so it counts per thread too.
- **First-use costs are not the parser's.** The first parse on a fresh
thread pays for standard-library setup; that alone read as 60
allocations rather than 3. The measurement warms up and takes the least
of several runs, and says so.

I also dropped a property I had written and could not defend: comparing
the mise shadow against a hand-written one-command CLI and asserting the
counts were *equal*. They differ for reasons that have nothing to do
with size, so the assertion was wrong even though the property it was
reaching for is real. `bare mise == 0` states that property exactly, on
the CLI that matters.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes when defaults are applied in generated parse code (behavior
should match for real invocations but affects every derived CLI);
allocation tests rely on global allocator hooks and warmed measurements.
> 
> **Overview**
> Closes the gate’s **heap allocation** target with new
`benches/gate/tests/allocations.rs` tests: bare `mise` at full shadow
scale must allocate **0**, typical invocations only a handful (one per
bound value), and clap stays **>1,000** on the same argv. **PLAN.md**
records the results (0 / 3–4 vs clap’s 6,560) as all three runtime gates
met.
> 
> **usage-derive** no longer applies field **defaults in `start()`**
(which ran for every command’s partial). Defaults run in **`check()`**
for the selected command only, guarded on `__given_*` so negated
booleans stay correct—fixing ~60 spurious `String` allocations on an
empty argv.
> 
> Allocation instrumentation in **`argv/tests/no_alloc.rs`** and the new
gate tests uses **thread-local** armed flags and counters (not a global
atomic), avoiding parallel-test flakiness; measurements **warm up** and
take the minimum run to exclude first-use stdlib/allocator noise.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
2bda275. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
jdx added a commit that referenced this pull request Aug 12, 2026
The largest thing standing between the derive and mise. Independent of
#827#829, so it can go in either order.

mise names `PathBuf` **227 times** and its tool-version type **83
times** in `src/cli/`. A derive that only holds `String` cannot hold
mise's commands, however faithfully it expresses mise's spec.

```rust
#[usage(short = 'j', long)] jobs: Option<usize>,
#[usage(long)]              out: Option<PathBuf>,
#[usage(long)]              port: u16,          // bare: the type says it is required
#[usage(long, var)]         tool: Vec<Tool>,    // any FromStr of your own
#[usage(long, var)]         search: Option<Vec<PathBuf>>,
```

`Option<Vec<T>>` earns its place: "never given" and "given nothing" are
different answers, and mise's root asks for that distinction three
times.

## What keeps it cheap

The layering does not move. **Binding still collects text** — a word's
meaning is not a question about where it lands — and conversion happens
once, where the struct is built. So defaults, `env`, `choices` and the
variadic bounds all keep working on text, untouched.

**Measured on the same fixture as main: 40,129 instructions against
39,963.** +166, or 0.4%, nearly all of it bookkeeping the identity
conversion cannot avoid.

That comparison is worth a note. On #827/#828 this parse measures
~50.9k, and I nearly reported this branch's 40k as a 20% win — it is
not, it is a *smaller shadow*, because main has neither those branches'
91 aliases nor their boxed variants. Same trap the informative-benchmark
change was made for; the honest number needs the same fixture on both
sides.

## Errors say what the type says

```rust
Err(Error::InvalidValue(bad)) => {
    bad.name;    // "jobs"
    bad.value;   // "lots"
    bad.reason;  // "invalid digit found in string"
}
```

Better than anything this crate could invent about someone else's type:
`u16` explains "number too large than can fit", and a tool argument
explains itself. `Error` gained one variant and **lost `Copy`**, since
that variant owns two strings — but the payload is boxed, so `Error` is
still **40 bytes** and the `Result` it rides in on the hot path did not
grow. I checked that rather than assuming it.

A type no word could become is a compile error naming *that* type:

```
error[E0277]: the trait bound `HashMap<String, String>: FromStr` is not satisfied
```

## One gap, deliberate and documented

A word reaches a field through `from_utf8_lossy`, so a `PathBuf` holding
a path that is not UTF-8 gets replacement characters rather than bytes.
Rare, and wrong when it happens. The fix is for the partial to hold
`OsString` and let `build` decide — exact for `PathBuf` and `OsString`,
an error for `String` rather than a silent mangling — which is the next
PR rather than a bigger one now.

Also in here, from jdx's note that mise carries hacks purely to work
around clap: PLAN.md now lists what adoption should let mise **delete**,
checked against mise rather than assumed. Two of the five do not survive
contact — `command_effects.rs` argues its own case for staying one
central list, and `mise-extra.usage.kdl` turns out to be a docs link
template rather than a workaround.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Public `usage_argv::Error` API changes (`Copy` removed, new variant)
and derive codegen affects all parsers, but behavior is covered by new
conformance tests and conversion stays off the successful parse hot
path.
> 
> **Overview**
> **Typed CLI fields** move beyond string-only binding: a field can be
any `T: FromStr`, plus `Option<T>`, `Vec<T>`, and `Option<Vec<T>>` (so
“flag never given” vs “given but empty” stays distinct). The argv layer
still binds text; **`usage-derive` converts when building the struct**,
with a fast path that moves `std::string::String` without re-parsing.
> 
> Failed conversions surface as **`Error::InvalidValue`** (boxed
**`InvalidValue`** with field name, raw text, and the type’s `Display`
message). **`Error` no longer implements `Copy`**; the new variant is
boxed so the enum stays **40 bytes**.
> 
> **Subcommand `Args` builds** use the same **`field_final`** logic as
the root CLI (fixing typed fields on nested commands).
> 
> **`PLAN.md`** marks typed values done, notes the next step (non–UTF-8
via `OsString` in partials), and adds a mise adoption section on what
clap workarounds can be removed vs what should stay.
> 
> **`conformance/tests/typed.rs`** covers numeric/path/custom types,
`Option<Vec<_>>`, spec/KDL unchanged by Rust types, and subcommand
conversion errors.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
0501bb5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added type-driven conversion for CLI values, supporting numeric, path,
custom `FromStr`, optional, and repeated fields.
* Added support for optional collections, distinguishing absent values
from empty collections.
* Conversion failures now report the field, rejected value, and reason.

* **Documentation**
* Updated documentation for supported typed-value patterns and current
non-UTF-8 handling limitations.

* **Tests**
* Added comprehensive coverage for typed fields, collections, conversion
errors, and numeric validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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