Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e5eba83
feat(derive): add usage::Config derive for settings declared in code
jdx Aug 21, 2026
65b4975
feat(derive): add parse_with_settings process entry
jdx Aug 21, 2026
365ecad
fix(derive): include the negate spelling in view selector matching
jdx Aug 21, 2026
7f33648
fix(help): do not repeat a negate-only flag's name before its spelling
jdx Aug 21, 2026
52d40ef
fix(docs): show a negate-only flag as the spelling a reader types
jdx Aug 21, 2026
82fe905
fix(docs): give a negate-only flag a reference heading
jdx Aug 21, 2026
9b2e76c
fix(derive): refuse a setting declaration that only fails at run time
jdx Aug 21, 2026
6cc3e50
fix(config): report a value too wide for its field, and render only K…
jdx Aug 21, 2026
4ea13a1
fix(help): write a negate-only flag's spelling once, and without a le…
jdx Aug 21, 2026
24f0f2c
docs(derive): link the settings guide rather than a private module
jdx Aug 21, 2026
1ff54de
feat(config)!: remove the build-time codegen backend
jdx Aug 21, 2026
e4a73cf
refactor(conformance): build a corpus prop on PropMeta::new
jdx Aug 21, 2026
9212e2e
fix(derive): refuse a default that only the merge would have accepted
jdx Aug 21, 2026
f1d30fb
fix(derive): hold a list default against its choices item by item
jdx Aug 21, 2026
7db63b3
fix(derive): one spelling for a string setting's values, so a default…
jdx Aug 21, 2026
c787f8a
fix(derive): report deprecations from the settings entry too
jdx Aug 21, 2026
230c793
docs(rust): state how a default and a choice are spelled
jdx Aug 21, 2026
c6cca4c
test(derive): assert the docs-only metadata survives the round trip
jdx Aug 21, 2026
c36b7ee
fix(derive): refuse a default the field's own type cannot hold
jdx Aug 21, 2026
ac682c6
fix(config)!: refuse two settings that answer to one name
jdx Aug 21, 2026
e95de43
fix(derive): measure a list setting's choice against its item type
jdx Aug 21, 2026
fe17366
test(derive): pin how the width check and a `ty` override interact
jdx Aug 21, 2026
fd71aa4
fix(derive): name the right mistake when one field collides with itself
jdx Aug 21, 2026
3e8a7c5
fix(derive): refuse a `ty` the field could never read
jdx Aug 21, 2026
7416f41
fix(derive): compare a `ty` override's item types, not just its outer…
jdx Aug 21, 2026
2d42470
test(derive): assert the spec endpoint answers with the settings
jdx Aug 21, 2026
b656c74
docs(rust): settle that an alias is written in full
jdx Aug 21, 2026
1e831bb
fix(derive): measure `u64` too, which a `ty` override can leave ungua…
jdx Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ jobs:
run: |
cargo clippy -p usage-lib --no-default-features -- -D warnings
cargo clippy -p usage-rs --no-default-features -- -D warnings
cargo clippy -p usage-config-build -- -D warnings
cargo clippy -p usage-config --no-default-features -- -D warnings
- run: mise r render
# Same reasoning as `render`: the shadow is checked in, so a change to the derive's
Expand Down Expand Up @@ -154,7 +153,7 @@ jobs:
- version: "1.91"
crates: usage-argv usage-derive usage-config usage-validation usage-rs usage-test
- version: "1.95"
crates: usage-lib usage-config-build clap_usage usage-cli
crates: usage-lib clap_usage usage-cli
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
9 changes: 1 addition & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ resolver = "2"
members = [
"argv",
"config",
"config-build",
"derive",
"test",
"usage-rs",
Expand Down Expand Up @@ -48,7 +47,7 @@ usage-config = { path = "./config", version = "5.1.0" }
usage-derive = { path = "./derive", version = "5.1.0" }
# No features, and defaults off. A feature named here is inherited by every member that writes
# `workspace = true` and inlines into their published manifests — so `clap` and `validation`
# reached `usage-config-build`, which runs in an adopter's build script and uses neither. Defaults
# reached members that use neither, one of them an adopter's build script. Defaults
# are off rather than absent because cargo *ignores* a member's `default-features = false` unless
# the workspace declaration sets it too, and warns that it may become a hard error. Members name
# what they need, `docs` included.
Expand Down
46 changes: 29 additions & 17 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ already written, which is the failure mode a status file has to avoid.
into the spec.

The first is underway. The second is **underway too**, which this file said it was
not: `usage-config` and `usage-config-build` are 8,220 lines, the spec's `config`
not: `usage-config` and the `usage::Config` derive are the settings model, the spec's `config`
block has a model behind it, and the derive lowers `#[usage(setting = …)]` into
`SETTINGS_BINDINGS` with a `Registry::drift` check over it. What has _not_
happened is adoption — none of mise, hk, pitchfork or fnox depends on the crate —
Expand Down Expand Up @@ -1791,7 +1791,9 @@ including telling you to delete the label afterwards.

## Config

Not started. Design first, implementation after the parser gate.
Implemented — the crates, the spec block, the derive, and the CLI binding all exist below.
What has not happened is adoption, which is tracked per-CLI by the fleet effort rather than
here.

### What the four CLIs already do

Expand Down Expand Up @@ -1828,17 +1830,24 @@ Where they differ is instructive, because it is mostly _drift_:

### The shape

- [ ] **Declare props in code**, `#[derive(usage::Config)]`, lowered into the
- [x] **Declare props in code**, `#[derive(usage::Config)]`, lowered into the
spec's `config { prop ... }` block so settings documentation flows through
the same pipeline as command documentation. Same canonicality rule as the
parser: code authors, the spec defines. **Decided (2026-08-21): the derive goes
on a registry-only declaration**, whose generated output feeds the CLI's existing
settings type rather than replacing it. Putting it on the final typed `Settings`
would be one source of truth, but it pushes layer bindings and merge policy onto
application fields and makes every adopter convert its whole registry in one step.
A registry-only declaration lets mise, hk, pitchfork and fnox adopt a layer at a
time, which is the migration path this section already judges likeliest to happen.
The cost is accepted and bounded: two declarations coexist during a migration.
parser: code authors, the spec defines. **Decided (2026-08-21, superseding the
registry-only decision of the same day): the derive goes on the real typed
`Settings` struct** — the same shape as `#[derive(usage::Cli)]`, which is the
argument that won. The author writes the struct the CLI already holds its settings
in; the derive generates `SETTINGS_PROPS`/`SETTINGS_REGISTRY`, `read(&Resolved)`,
and `spec_kdl()`, and a root's `#[usage(config = Settings)]` puts the block in its
emitted spec. One source of truth, no generated structs. The registry-only shape
was chosen for layer-at-a-time migration; the fleet's first adopters (pitchfork,
fnox, tak) are converting wholesale, so the incremental path's cost — two
coexisting declarations — was being paid for a benefit nobody scheduled. Nested
groups compose through `usage_config::Props` with compile-time `concat_props`
(duplicate keys refuse the build); `derive/src/config.rs`,
`conformance/tests/derive_config.rs`. The struct is the only declaration: the
build-time KDL-to-registry generator (`usage-config-build`) is gone, because a
second backend was a third description of every setting.
- [x] **A prop vocabulary that is the union of the four registries** — `type`
(bool, int, string, path, duration, list, map, plus a Rust-type escape
hatch), `default`, `env` and `deprecated_env`, `docs`, `deprecated` with
Expand Down Expand Up @@ -1885,13 +1894,16 @@ Where they differ is instructive, because it is mostly _drift_:
pipeline — `config/` and `SpecConfigProp` are here today, so this is also the
status quo. A split would buy an independent stability and MSRV policy at the
price of cross-repo coordination on every spec vocabulary change.
- [ ] Whether the four CLIs migrate incrementally (one layer at a time, keeping
- [x] Whether the four CLIs migrate incrementally (one layer at a time, keeping
their generated `Settings`) or by regenerating from a converted registry.
Incremental looks far more likely to actually happen. **Owned by the fleet
adoption effort (2026-08-21), not decided here** — it is a question about what
each CLI does, and the answer sets whether the first public APIs must wrap
existing layers and settings types without owning them. The registry-only
derive above deliberately keeps the incremental path open either way.
**Answered by the fleet adoption effort (2026-08-21): wholesale, on the real
struct.** The first adopters — pitchfork, fnox, tak — convert their registries
into `#[derive(usage::Config)]` structs in one PR each, stacked on their clap-swap
PRs; hk and aube are deferred (git/pkl layers, `env_only` bootstrap, per-item
provenance; aube's managed-policy ratchet and two-axis sources). There is no
second path held open for a later incremental adopter: `usage-config-build` was
removed with this decision, since keeping a KDL-first backend alive meant keeping
two generators emitting one registry shape.
- [x] fnox's model, where config files are not a settings source at all, is the
one real behavior change rather than a consolidation. Worth confirming that
is a fix and not a deliberate choice. **Decided (2026-08-21): preserve fnox's
Expand Down
33 changes: 32 additions & 1 deletion argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,10 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
let mut buf = [0u8; 4];
(*short as char).encode_utf8(&mut buf) == flag.name
}
_ => false,
// A flag whose only spelling is its negation — clap's `SetFalse`, tak's
// `--no-credit` — is named after that spelling, so the prefix would repeat it:
// `no-credit: --no-credit`.
_ => show.negate && flag.negate == Some(flag.name),
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
if !implied_matches {
let _ = write!(out, "{}:", flag.name);
Expand Down Expand Up @@ -1238,6 +1241,10 @@ pub(crate) fn flag_spelling(meta: &FlagMeta<'_>) -> String {
fn display_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
let usage = flag_usage_masked(meta, show);
match meta.flag.negate.filter(|_| show.negate) {
// A flag whose only spelling is its negation has nothing before it: the name prefix
// would repeat the spelling, so `flag_usage_masked` writes nothing and the negation
// is the whole entry. Joining with a space put one at the front of the column.
Some(negate) if usage.is_empty() => format!("--{negate}"),
Some(negate) if show.long.is_none() && show.short.is_none() => {
format!("{usage} --{negate}")
}
Expand Down Expand Up @@ -2785,6 +2792,30 @@ mod style_tests {
assert_eq!(display_usage_masked(&meta, &shown), "color: --no-color");
}

#[test]
fn a_flag_spelled_only_as_its_negation_writes_that_spelling_and_nothing_before_it() {
// clap's `SetFalse`, tak's `--no-credit`: the flag is *named* after its negation, so
// the `name:` prefix would repeat the spelling and there is no positive form to join
// it to. Both halves wrote nothing, and the join put a space at the front of the
// column: `" --no-credit"`.
let flag = Flag {
name: "no-credit",
negate: Some("no-credit"),
..Flag::BOOL
};
let meta = FlagMeta {
flag: &flag,
..FlagMeta::EMPTY
};
let shown = Shown {
long: None,
short: None,
negate: true,
};

assert_eq!(display_usage_masked(&meta, &shown), "--no-credit");
}

#[test]
fn flattened_next_line_deprecation_follows_help_without_a_blank_row() {
let flag = Flag {
Expand Down
31 changes: 0 additions & 31 deletions config-build/Cargo.toml

This file was deleted.

1 change: 0 additions & 1 deletion config-build/NOTICE.md

This file was deleted.

20 changes: 0 additions & 20 deletions config-build/examples/gen.rs

This file was deleted.

Loading
Loading