From e5eba8326d41c11684f67b3e2ed8318964126348 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:30:39 +0000 Subject: [PATCH 01/28] feat(derive): add usage::Config derive for settings declared in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet's settings pattern keeps three descriptions of every setting in step by hand: a settings.toml registry, a build.rs generator, and the struct the CLI reads. #[derive(usage::Config)] collapses them to one — the struct is the declaration, and the derive generates SETTINGS_PROPS / SETTINGS_REGISTRY, read(&Resolved), and spec_kdl(). A root deriving Cli names the type with #[usage(config = Settings)] and its emitted spec carries the config block, so docs, JSON schema and the config completers read declarations made in Rust exactly as ones made in KDL. This supersedes the registry-only decision recorded earlier today: the derive sits on the real typed Settings struct, the same shape as usage_config::Props trait with compile-time concat_props, which refuses duplicate keys across flattened groups. usage-config gains the spec_kdl renderer, FromValue for the narrower integers a struct actually holds, and PropMeta carries long_help, default_note, since, deprecation versions and examples so the emitted block stays lossless (usage-config-build now populates them too). The generated ::usage_config:: paths now resolve through crate_name with a usage-rs fallback like every other usage crate, and usage-rs gains a `config` feature exporting the derive and the resolver as usage::config. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + PLAN.md | 42 +- config-build/src/emit.rs | 27 + config-build/tests/golden/settings.rs | 2 + config/src/lib.rs | 4 + config/src/props.rs | 112 +++ config/src/read.rs | 35 + config/src/registry.rs | 17 + config/src/spec.rs | 272 +++++++ conformance/src/config.rs | 6 + conformance/tests/derive_config.rs | 293 +++++++ derive/src/codegen.rs | 32 +- derive/src/config.rs | 1074 +++++++++++++++++++++++++ derive/src/lib.rs | 33 + derive/src/model.rs | 39 +- docs/.vitepress/config.mts | 1 + docs/rust/args-and-flags.md | 35 +- docs/rust/settings.md | 142 ++++ usage-rs/Cargo.toml | 3 + usage-rs/src/lib.rs | 25 + 20 files changed, 2146 insertions(+), 49 deletions(-) create mode 100644 config/src/props.rs create mode 100644 config/src/spec.rs create mode 100644 conformance/tests/derive_config.rs create mode 100644 derive/src/config.rs create mode 100644 docs/rust/settings.md diff --git a/Cargo.lock b/Cargo.lock index 3847a7ecf..f5192860a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2354,6 +2354,7 @@ version = "5.1.0" dependencies = [ "serde", "usage-argv", + "usage-config", "usage-derive", "usage-lib", "usage-test", diff --git a/PLAN.md b/PLAN.md index f54c8602a..5527f7aaf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 @@ -1828,17 +1830,23 @@ 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`. A CLI that wants layer-at-a-time can still + write the block in KDL and use `usage-config-build`, which is unchanged. - [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 @@ -1885,13 +1893,15 @@ 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). A later + incremental adopter still has the KDL + `usage-config-build` path, which wraps + nothing and owns nothing it did not generate. - [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 diff --git a/config-build/src/emit.rs b/config-build/src/emit.rs index 8cc22eda6..8fea376bd 100644 --- a/config-build/src/emit.rs +++ b/config-build/src/emit.rs @@ -176,6 +176,33 @@ fn prop_meta(key: &str, prop: &SpecConfigProp, problems: &mut Vec) -> St if let Some(help) = prop.help.as_deref() { let _ = writeln!(fields, " help: Some({}),", rust_str(help)); } + if let Some(long_help) = prop.long_help.as_deref() { + let _ = writeln!(fields, " long_help: Some({}),", rust_str(long_help)); + } + if let Some(note) = prop.default_note.as_deref() { + let _ = writeln!(fields, " default_note: Some({}),", rust_str(note)); + } + if let Some(since) = prop.since.as_deref() { + let _ = writeln!(fields, " since: Some({}),", rust_str(since)); + } + if let Some(at) = prop.deprecated_warn_at.as_deref() { + let _ = writeln!( + fields, + " deprecated_warn_at: Some({}),", + rust_str(at) + ); + } + if let Some(at) = prop.deprecated_remove_at.as_deref() { + let _ = writeln!( + fields, + " deprecated_remove_at: Some({}),", + rust_str(at) + ); + } + if !prop.examples.is_empty() { + let examples: Vec = prop.examples.iter().map(|e| rust_str(e)).collect(); + let _ = writeln!(fields, " examples: &[{}],", examples.join(", ")); + } let new = format!( "::usage_config::PropMeta::new({}, {})", rust_str(key), diff --git a/config-build/tests/golden/settings.rs b/config-build/tests/golden/settings.rs index e966c89c9..eacd76d8d 100644 --- a/config-build/tests/golden/settings.rs +++ b/config-build/tests/golden/settings.rs @@ -13,6 +13,7 @@ pub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[ ::usage_config::PropMeta { deprecated: Some("Use jobs instead."), renamed_to: Some("jobs"), + deprecated_warn_at: Some("2026.12.0"), ..::usage_config::PropMeta::new("concurrency", ::usage_config::Ty::Uint) }, ::usage_config::PropMeta { @@ -34,6 +35,7 @@ pub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[ cli: &["--jobs", "-j"], bindings: &[("git", "hk.jobs")], help: Some("How many jobs to run at once"), + default_note: Some("0 = one per core"), ..::usage_config::PropMeta::new("jobs", ::usage_config::Ty::Uint) }, ::usage_config::PropMeta { diff --git a/config/src/lib.rs b/config/src/lib.rs index add4271c4..8ac7f8f35 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -61,10 +61,12 @@ pub mod explain; #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))] pub mod files; pub mod layer; +pub mod props; pub mod read; pub mod registry; pub mod resolve; pub mod source; +pub mod spec; pub mod ty; pub mod value; @@ -74,9 +76,11 @@ pub use explain::explain; #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))] pub use files::{FileLayer, Format}; pub use layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput, Warning, WarningKind}; +pub use props::{concat_props, Props}; pub use read::{Fold, FromValue, ReadError, ReadErrorKind, ReadErrors}; pub use registry::{Lookup, Merge, PropId, PropMeta, Registry, Scope}; pub use resolve::{resolve, Layers, Resolved}; pub use source::{FileScope, Origin, SourceKind, Trust}; +pub use spec::spec_kdl; pub use ty::{Parser, Ty, TypeError}; pub use value::{Const, Value}; diff --git a/config/src/props.rs b/config/src/props.rs new file mode 100644 index 000000000..d6fcb234f --- /dev/null +++ b/config/src/props.rs @@ -0,0 +1,112 @@ +//! What `#[derive(usage::Config)]` generates, and how flattened groups compose. +//! +//! A derive expansion sees one struct. A settings struct that flattens another — pitchfork +//! keeps eight groups in eight structs — therefore joins tables through this trait's +//! associated const, the same way `usage::Cli` joins a flattened group's flags: the child +//! declares its own slice, and the parent concatenates at compile time. Nothing is assembled +//! at run time, and a prop's id is its position in the joined slice. + +use crate::read::Fold; +use crate::registry::PropMeta; + +/// A group of settings declared in code. +/// +/// Implemented by `#[derive(usage::Config)]`, not by hand: the derive is what keeps +/// [`Props::PROPS`] and [`Props::read_at`] describing the same fields in the same order, +/// which is the invariant everything here leans on. +pub trait Props: Sized { + /// This group's settings, in declaration order. + /// + /// A flattened child's props follow the parent's own, so an id is a position in the + /// parent's joined slice — which is why reading takes a `base`. + const PROPS: &'static [PropMeta]; + + /// Read this group's fields from a fold, its props starting at `base`. + /// + /// `None` means a field could not be read and the fold has recorded why. Every field is + /// still visited first — the errors are a list, not the first thing found — so a caller + /// checks [`Fold::finish`] before treating `None` as anything but "already reported". + #[doc(hidden)] + fn read_at(fold: &mut Fold<'_>, base: u16) -> Option; +} + +/// Join groups of prop metadata into one slice, at compile time. +/// +/// The settings counterpart of `usage_argv::spec::concat_flag_metas`, for the same reason: a +/// flattened struct's props belong in the parent's registry, and the parent's macro expansion +/// has only a type to reach them through. +/// +/// `N` must be the summed length of `groups`. Two groups declaring the same key are refused +/// here, at compile time — the parent and the struct it flattens each declared it, a collision +/// neither expansion can see. +pub const fn concat_props(groups: &[&[PropMeta]]) -> [PropMeta; N] { + let mut out = [PropMeta::new("", crate::ty::Ty::Any); N]; + let mut at = 0; + let mut g = 0; + while g < groups.len() { + let group = groups[g]; + let mut i = 0; + while i < group.len() { + out[at] = group[i]; + at += 1; + i += 1; + } + g += 1; + } + assert!( + at == N, + "`N` must be the summed length of the groups, or the registry would describe a \ + setting that does not exist" + ); + let mut a = 0; + while a < N { + let mut b = a + 1; + while b < N { + assert!( + !str_eq(out[a].key, out[b].key), + "two flattened groups declare the same setting key, so one of them could \ + never be reached: give one of them another key or prefix" + ); + b += 1; + } + a += 1; + } + out +} + +/// Whether two strings are equal, in a const context. +const fn str_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ty::Ty; + + #[test] + fn groups_join_in_order_and_ids_are_positions() { + static OWN: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + static CHILD: &[PropMeta] = &[ + PropMeta::new("task.output", Ty::String), + PropMeta::new("task.jobs", Ty::Uint), + ]; + const N: usize = OWN.len() + CHILD.len(); + static JOINED: [PropMeta; N] = concat_props(&[OWN, CHILD]); + assert_eq!(JOINED[0].key, "jobs"); + assert_eq!(JOINED[1].key, "task.output"); + assert_eq!(JOINED[2].key, "task.jobs"); + } +} diff --git a/config/src/read.rs b/config/src/read.rs index 688f6f3bf..c19e34293 100644 --- a/config/src/read.rs +++ b/config/src/read.rs @@ -250,6 +250,41 @@ impl FromValue for f64 { } } +impl FromValue for f32 { + fn from_value(value: &Value) -> Result { + f64::from_value(value).map(|f| f as Self) + } +} + +/// The narrower integers a struct actually holds — a fleet CLI keeps `jobs` in a `usize` and a +/// verbosity in a `u8` — read through the same rule as [`u64`]: a value that does not fit is +/// reported rather than wrapped, because wrapping is the shape of this bug everywhere it exists. +macro_rules! narrower_int { + ($($ty:ty => $expected:literal,)*) => {$( + impl FromValue for $ty { + fn from_value(value: &Value) -> Result { + match value { + Value::Int(i) => { + Self::try_from(*i).map_err(|_| mismatch($expected, value)) + } + other => Err(mismatch($expected, other)), + } + } + } + )*}; +} + +narrower_int! { + u8 => "a non-negative integer that fits 8 bits", + u16 => "a non-negative integer that fits 16 bits", + u32 => "a non-negative integer that fits 32 bits", + usize => "a non-negative integer", + i8 => "an integer that fits 8 bits", + i16 => "an integer that fits 16 bits", + i32 => "an integer that fits 32 bits", + isize => "an integer", +} + impl FromValue for String { fn from_value(value: &Value) -> Result { match value { diff --git a/config/src/registry.rs b/config/src/registry.rs index 070296ce1..2be404b38 100644 --- a/config/src/registry.rs +++ b/config/src/registry.rs @@ -103,6 +103,17 @@ pub struct PropMeta { /// An explicit optionality contract, when the declaration does not use inference. pub optional: Option, pub help: Option<&'static str>, + pub long_help: Option<&'static str>, + /// Prose beside the default, for a default the registry cannot hold — one computed at + /// runtime, or one whose literal alone would mislead ("0 = one per core"). + pub default_note: Option<&'static str>, + /// The release that introduced this setting, when the declaration says. + pub since: Option<&'static str>, + /// The release that starts warning about a deprecated setting, and the one that removes it. + pub deprecated_warn_at: Option<&'static str>, + pub deprecated_remove_at: Option<&'static str>, + /// Values worth showing a reader, verbatim. + pub examples: &'static [&'static str], } impl PropMeta { @@ -126,6 +137,12 @@ impl PropMeta { aliases: &[], optional: None, help: None, + long_help: None, + default_note: None, + since: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + examples: &[], } } } diff --git a/config/src/spec.rs b/config/src/spec.rs new file mode 100644 index 000000000..f24a18f02 --- /dev/null +++ b/config/src/spec.rs @@ -0,0 +1,272 @@ +//! A registry written back out as the spec's `config` block. +//! +//! `usage-config-build` reads a spec and generates a registry; a CLI that declares its +//! settings in code with `#[derive(usage::Config)]` goes the other way, and this is the other +//! way: the registry it derived, rendered as the `config { prop … }` block the spec grammar +//! defines, so docs, JSON schema, completions and every other spec consumer read declarations +//! made in Rust exactly as they read ones made in KDL. +//! +//! Written by hand rather than through a KDL library for the same reason the argv crate's +//! spec writer is: this crate has no dependencies, and the grammar being emitted is the small +//! fixed one the spec parser defines. + +use crate::registry::{Merge, PropMeta, Scope}; +use crate::value::Const; +use std::fmt::Write; + +/// The spec `config` block for these settings, as KDL. +/// +/// Ends with a newline, so it can be appended to an emitted spec as-is. Props are written in +/// registry order, which for a derived registry is declaration order. +pub fn spec_kdl(props: &[PropMeta]) -> String { + let mut out = String::from("config {\n"); + for meta in props { + let _ = write_prop(&mut out, meta); + } + out.push_str("}\n"); + out +} + +fn write_prop(out: &mut String, meta: &PropMeta) -> std::fmt::Result { + write!( + out, + " prop {} type={}", + quoted(meta.key), + quoted(&meta.ty.name()) + )?; + if let Some(default) = scalar_default(meta.default) { + write!(out, " default={default}")?; + } + if let Some(note) = meta.default_note { + write!(out, " default_note={}", quoted(note))?; + } + if let Some(optional) = meta.optional { + write!(out, " optional=#{optional}")?; + } + match meta.merge { + Merge::Replace => {} + Merge::Union => out.push_str(" merge=\"union\""), + Merge::Deep => out.push_str(" merge=\"deep\""), + } + if let Some(parse) = meta.parse { + write!(out, " parse={}", quoted(parse.name()))?; + } + match meta.scope { + Scope::Any => {} + Scope::Global => out.push_str(" scope=\"global\""), + Scope::Env => out.push_str(" scope=\"env\""), + } + if meta.hide { + out.push_str(" hide=#true"); + } + if let Some(deprecated) = meta.deprecated { + write!(out, " deprecated={}", quoted(deprecated))?; + } + if let Some(at) = meta.deprecated_warn_at { + write!(out, " deprecated_warn_at={}", quoted(at))?; + } + if let Some(at) = meta.deprecated_remove_at { + write!(out, " deprecated_remove_at={}", quoted(at))?; + } + if let Some(renamed_to) = meta.renamed_to { + write!(out, " renamed_to={}", quoted(renamed_to))?; + } + if let Some(since) = meta.since { + write!(out, " since={}", quoted(since))?; + } + if let Some(help) = meta.help { + write!(out, " help={}", quoted(help))?; + } + if let Some(long_help) = meta.long_help { + write!(out, " long_help={}", quoted(long_help))?; + } + + let mut children = Vec::new(); + if let Some(Const::List(items)) = meta.default { + // A list default is a child node — `default 80 443` — because several values do not + // fit one `default=` entry. + let rendered: Vec = items.iter().map(|item| const_kdl(*item)).collect(); + children.push(format!("default {}", rendered.join(" "))); + } + if !meta.envs.is_empty() { + children.push(word_list("env", meta.envs)); + } + if !meta.deprecated_envs.is_empty() { + children.push(word_list("deprecated_env", meta.deprecated_envs)); + } + if !meta.aliases.is_empty() { + children.push(word_list("alias", meta.aliases)); + } + if !meta.cli.is_empty() { + children.push(word_list("cli", meta.cli)); + } + for example in meta.examples { + children.push(format!("example {}", quoted(example))); + } + // One `source` node per kind, holding every key bound in it, in declaration order — + // `source "pkl" "exclude" "defaults.exclude"`. + let mut kinds: Vec<&str> = Vec::new(); + for (kind, _) in meta.bindings { + if !kinds.contains(kind) { + kinds.push(kind); + } + } + for kind in kinds { + let keys: Vec = meta + .bindings + .iter() + .filter(|(k, _)| *k == kind) + .map(|(_, key)| quoted(key)) + .collect(); + children.push(format!("source {} {}", quoted(kind), keys.join(" "))); + } + if !meta.choices.is_empty() { + let mut block = String::from("choices {\n"); + for choice in meta.choices { + let _ = writeln!(block, " choice {}", const_kdl(*choice)); + } + block.push_str(" }"); + children.push(block); + } + + if children.is_empty() { + out.push('\n'); + } else { + out.push_str(" {\n"); + for child in children { + let _ = writeln!(out, " {child}"); + } + out.push_str(" }\n"); + } + Ok(()) +} + +/// A scalar default as a KDL entry value, or `None` for a list (a child node) or a table +/// (which the prop grammar cannot hold, and a generator refuses before it gets here). +fn scalar_default(default: Option) -> Option { + match default? { + Const::List(_) | Const::Map(_) => None, + scalar => Some(const_kdl(scalar)), + } +} + +/// One constant as KDL writes it: `#true`, `4`, `1.5`, `"text"`. +fn const_kdl(value: Const) -> String { + match value { + Const::Bool(b) => format!("#{b}"), + Const::Int(i) => i.to_string(), + // `{:?}` keeps the decimal point, which KDL requires of a float — `1.0`, not `1`. + Const::Float(f) => format!("{f:?}"), + Const::Str(s) => quoted(s), + Const::List(items) => items + .iter() + .map(|item| const_kdl(*item)) + .collect::>() + .join(" "), + // Unreachable from a derived registry — the derive refuses table defaults — and not + // something the prop grammar can spell. + Const::Map(_) => String::new(), + } +} + +fn word_list(name: &str, words: &[&str]) -> String { + let quoted: Vec = words.iter().map(|word| quoted(word)).collect(); + format!("{name} {}", quoted.join(" ")) +} + +/// `text` as a quoted KDL string. +fn quoted(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c => out.push(c), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ty::{Parser, Ty}; + + #[test] + fn a_registry_renders_as_the_config_block_the_spec_grammar_defines() { + static PROPS: &[PropMeta] = &[ + PropMeta { + default: Some(Const::Int(4)), + default_note: Some("0 = one per core"), + envs: &["HK_JOBS", "HK_JOB"], + deprecated_envs: &["HK_JOBS_OLD"], + cli: &["--jobs", "-j"], + bindings: &[("git", "hk.jobs")], + help: Some("How many jobs to run at once"), + ..PropMeta::new("jobs", Ty::Uint) + }, + PropMeta { + merge: Merge::Union, + parse: Some(Parser::ListByComma), + envs: &["HK_EXCLUDE"], + bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")], + ..PropMeta::new("exclude", Ty::List(&Ty::String)) + }, + PropMeta { + default: Some(Const::Str("git")), + choices: &[ + Const::Str("git"), + Const::Str("patch-file"), + Const::Str("none"), + ], + help: Some("How to \"stash\" first"), + ..PropMeta::new("stash", Ty::String) + }, + PropMeta { + default: Some(Const::List(&[Const::Int(80), Const::Int(443)])), + ..PropMeta::new("ports", Ty::List(&Ty::Uint)) + }, + PropMeta { + scope: Scope::Env, + hide: true, + envs: &["CI"], + ..PropMeta::new("ci", Ty::Bool) + }, + ]; + let kdl = spec_kdl(PROPS); + assert_eq!( + kdl, + r#"config { + prop "jobs" type="uint" default=4 default_note="0 = one per core" help="How many jobs to run at once" { + env "HK_JOBS" "HK_JOB" + deprecated_env "HK_JOBS_OLD" + cli "--jobs" "-j" + source "git" "hk.jobs" + } + prop "exclude" type="list" merge="union" parse="list_by_comma" { + env "HK_EXCLUDE" + source "pkl" "exclude" "defaults.exclude" + } + prop "stash" type="string" default="git" help="How to \"stash\" first" { + choices { + choice "git" + choice "patch-file" + choice "none" + } + } + prop "ports" type="list" { + default 80 443 + } + prop "ci" type="bool" scope="env" hide=#true { + env "CI" + } +} +"# + ); + } +} diff --git a/conformance/src/config.rs b/conformance/src/config.rs index c634c08b2..a60edf697 100644 --- a/conformance/src/config.rs +++ b/conformance/src/config.rs @@ -704,6 +704,12 @@ fn registry_of(settings: &[Setting]) -> Result { aliases: &[], optional: None, help: None, + long_help: None, + default_note: None, + since: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + examples: &[], }); } Ok(Registry::new(Box::leak(props.into_boxed_slice()))) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs new file mode 100644 index 000000000..67b25cb4f --- /dev/null +++ b/conformance/tests/derive_config.rs @@ -0,0 +1,293 @@ +//! A settings struct as its own declaration. +//! +//! The claim under test: `#[derive(usage::Config)]` on the struct a CLI already holds its +//! settings in generates the same registry `usage-config-build` would have generated from a +//! spec, a reader that fills the struct from a resolution, and a `config` block the spec +//! parser reads back — so the registry, the reader, and the documentation cannot drift from +//! the struct or from each other. That drift is the fleet's `settings.toml` + `build.rs` +//! pattern's whole failure mode: three descriptions of every setting, kept in step by hand. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::PathBuf; + +use usage_config::{resolve, Const, EnvLayer, Layers, Ty, Value}; +use usage_derive::{Cli, Config}; + +fn default_cache_dir() -> PathBuf { + PathBuf::from("/tmp/ex-cache") +} + +/// The `task.*` settings, as their own group. +#[derive(Config, Debug, PartialEq)] +#[usage(prefix = "task")] +struct TaskSettings { + /// How task output is interleaved + #[usage(default = "prefix", choices("prefix", "interleave"))] + output: String, + + /// Jobs for tasks alone + #[usage(env = "EX_TASK_JOBS")] + jobs: Option, +} + +/// Every setting ex has — one of everything the derive carries. +#[derive(Config, Debug, PartialEq)] +struct Settings { + /// How many jobs to run at once + #[usage( + env("EX_JOBS", "EX_JOB"), + deprecated_env = "EX_JOBS_OLD", + default = 4, + cli("--jobs", "-j"), + source("git", "ex.jobs") + )] + jobs: u64, + + /// Paths to leave alone + #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] + exclude: Option>, + + /// Where the cache lives + #[usage( + env = "EX_CACHE_DIR", + default_fn = default_cache_dir, + default_note = "under the user cache directory" + )] + cache_dir: PathBuf, + + /// Which files to match + // A keyword as a field, which is ordinary for a setting key and needs `r#` in Rust. + #[usage(default = "all")] + r#match: String, + + /// How long to wait, if at all + // The registry declares a duration; the field holds its text, the same way a generated + // struct does — the crate that owns the duration type owns its spelling. + #[usage(env = "EX_TIMEOUT", ty = "duration")] + timeout: Option, + + /// Whether this checkout may run its own hooks + #[usage(scope = "global", default = false)] + trusted: bool, + + /// Ports to listen on + #[usage(default(80, 443))] + ports: Vec, + + /// Only from the environment + #[usage(env = "CI", scope = "env", hide)] + ci: Option, + + /// Rewrite these URL prefixes + #[usage(merge = "deep")] + url_replacements: Option>, + + #[usage(flatten)] + task: TaskSettings, +} + +#[test] +fn the_registry_is_the_struct_in_declaration_order() { + let keys: Vec<&str> = Settings::SETTINGS_PROPS + .iter() + .map(|meta| meta.key) + .collect(); + assert_eq!( + keys, + vec![ + "jobs", + "exclude", + "cache_dir", + "match", + "timeout", + "trusted", + "ports", + "ci", + "url_replacements", + "task.output", + "task.jobs", + ], + "a flattened group's props follow its field's position, under its prefix" + ); + + let jobs = &Settings::SETTINGS_PROPS[0]; + assert_eq!(jobs.ty, Ty::Uint); + assert_eq!(jobs.default, Some(Const::Int(4))); + assert_eq!(jobs.envs, &["EX_JOBS", "EX_JOB"]); + assert_eq!(jobs.deprecated_envs, &["EX_JOBS_OLD"]); + assert_eq!(jobs.cli, &["--jobs", "-j"]); + assert_eq!(jobs.bindings, &[("git", "ex.jobs")]); + assert_eq!(jobs.help, Some("How many jobs to run at once")); + + let cache_dir = &Settings::SETTINGS_PROPS[2]; + assert_eq!(cache_dir.ty, Ty::Path); + assert_eq!(cache_dir.default, None, "a computed default is not a const"); + assert_eq!(cache_dir.optional, Some(true)); + assert_eq!( + cache_dir.default_note, + Some("under the user cache directory") + ); + + let timeout = &Settings::SETTINGS_PROPS[4]; + assert_eq!( + timeout.ty, + Ty::Option(&Ty::Duration), + "an `Option` field is an optional setting, and `ty` renames what the spec calls it" + ); + + let ports = &Settings::SETTINGS_PROPS[6]; + assert_eq!( + ports.default, + Some(Const::List(&[Const::Int(80), Const::Int(443)])) + ); + + // The joined registry resolves dotted keys like any other. + let found = Settings::SETTINGS_REGISTRY + .lookup("task.output") + .expect("declared"); + assert_eq!( + Settings::SETTINGS_REGISTRY.get(found.id).help, + Some("How task output is interleaved") + ); +} + +#[test] +fn nothing_supplied_reads_as_the_declared_defaults() { + let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new()).expect("resolves"); + let settings = Settings::read(&resolved).expect("every field reads"); + assert_eq!( + settings, + Settings { + jobs: 4, + exclude: None, + cache_dir: PathBuf::from("/tmp/ex-cache"), + r#match: "all".to_string(), + timeout: None, + trusted: false, + ports: vec![80, 443], + ci: None, + url_replacements: None, + task: TaskSettings { + output: "prefix".to_string(), + jobs: None, + }, + } + ); +} + +#[test] +fn a_layer_fills_the_struct_through_the_declared_types() { + let env = EnvLayer::new([ + ("EX_JOB".to_string(), "9".to_string()), + ("EX_EXCLUDE".to_string(), "target, dist".to_string()), + ("EX_CACHE_DIR".to_string(), "/var/cache/ex".to_string()), + ("EX_TASK_JOBS".to_string(), "2".to_string()), + ("CI".to_string(), "1".to_string()), + ]); + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).expect("resolves"); + let settings = Settings::read(&resolved).expect("every value fits its field"); + assert_eq!( + settings.jobs, 9, + "the second declared variable still sets it" + ); + assert_eq!( + settings.exclude.as_deref(), + Some(&["target".to_string(), "dist".to_string()][..]), + "the named parser splits one variable into the list" + ); + assert_eq!( + settings.cache_dir, + PathBuf::from("/var/cache/ex"), + "a supplied value wins over `default_fn`" + ); + assert_eq!(settings.ci, Some(true)); + assert_eq!( + settings.task.jobs, + Some(2), + "a flattened field reads through its prefix" + ); +} + +#[test] +fn the_emitted_config_block_is_the_spec_grammar() { + // Parsed by the reference implementation, not merely inspected: the block the derive + // renders has to be a declaration usage-lib reads, or docs, JSON schema and completions + // see a different CLI than the one that runs. + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Settings::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + + let jobs = spec.config.props.get("jobs").expect("declared"); + assert_eq!(jobs.envs, vec!["EX_JOBS".to_string(), "EX_JOB".to_string()]); + assert_eq!(jobs.deprecated_envs, vec!["EX_JOBS_OLD".to_string()]); + assert_eq!(jobs.cli, vec!["--jobs".to_string(), "-j".to_string()]); + assert!( + matches!( + jobs.default, + Some(usage::spec::config::SpecConfigValue::Int(4)) + ), + "the declared default survives the round-trip: {:?}", + jobs.default + ); + assert_eq!(jobs.help.as_deref(), Some("How many jobs to run at once")); + + let output = spec.config.props.get("task.output").expect("declared"); + assert_eq!(output.choices.len(), 2); + + let cache_dir = spec.config.props.get("cache_dir").expect("declared"); + assert_eq!( + cache_dir.default_note.as_deref(), + Some("under the user cache directory") + ); + assert_eq!(cache_dir.optional, Some(true)); +} + +/// A tool with settings, declaring which type holds them. +#[derive(Cli, Debug)] +#[usage(bin = "ex", config = Settings)] +struct Ex { + /// How many jobs to run at once + #[usage(long, short = 'j', setting = "jobs")] + jobs: Option, +} + +#[test] +fn the_flags_this_cli_reads_are_the_flags_its_settings_declare() { + // The one-line adopter test, now with both sides generated: the bindings from the `Cli` + // derive, the registry from the `Config` derive. + assert_eq!( + Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), + Vec::::new() + ); +} + +#[test] +fn the_clis_spec_carries_its_settings() { + let kdl = Ex::to_kdl(); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the whole spec"); + assert_eq!(spec.bin, "ex"); + assert!( + spec.config.props.contains_key("task.output"), + "the config block rides in the emitted spec: {kdl}" + ); +} + +#[test] +fn the_command_line_outranks_every_other_layer() { + let argv = [OsStr::new("-j"), OsStr::new("12")]; + let (_, cli) = Ex::parse_from_with_settings(&argv).expect("parses"); + let env = EnvLayer::new([("EX_JOBS".to_string(), "9".to_string())]); + let resolved = resolve( + Settings::SETTINGS_REGISTRY, + Layers::new().then(&cli).then(&env), + ) + .expect("resolves"); + let settings = Settings::read(&resolved).expect("reads"); + assert_eq!(settings.jobs, 12); + assert_eq!( + resolved.get_key("jobs"), + Some(&Value::Int(12)), + "and the registry agrees with the struct" + ); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index bc1cbf902..21584d828 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -262,6 +262,17 @@ pub fn emit(cli: &Cli) -> TokenStream { // see another struct's fields. A root that does neither gets the compile-time guard instead of // the layer, so a group's binding cannot go quietly uncollected. let resolves = cli.fields.iter().any(|f| f.setting.is_some()) || cli.settings; + let config = crate::config::config_path(); + // A root that names its settings type gets a spec carrying the `config` block, appended + // after the command tree: KDL nodes are read by name, so position is presentation. Written + // as an append rather than as the whole body so that it composes with `spec_extra`, which + // appends to the same string — a root can have both. + let config_extra = match &cli.config { + Some(ty) => quote! { + __usage_kdl.push_str(&#config::spec_kdl(<#ty>::SETTINGS_PROPS)); + }, + None => TokenStream::new(), + }; let parts = settings(cli); // Only the layer calls it, so a root that has children and no settings of its own emits // neither: the guard below is what speaks for that case. @@ -270,7 +281,7 @@ pub fn emit(cli: &Cli) -> TokenStream { .as_ref() .filter(|_| resolves) .map(|s| s.bindings.clone()); - let settings_layer = resolves.then(settings_layer); + let settings_layer = resolves.then(|| settings_layer(&config)); let settings_guard = (!resolves).then(|| settings_guard(cli)).flatten(); // The name an adopter uses, forwarding to the one inside the const block, which is where // the table that reads it lives. @@ -305,7 +316,7 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn parse_from_with_settings<'v>( argv: &[&'v ::std::ffi::OsStr], ) -> ::std::result::Result< - (Self, ::usage_config::CliLayer), + (Self, #config::CliLayer), usage_argv::Error<'static, 'v>, > { // The layer from what argv left, and only then the rest: `check` fills a field @@ -852,6 +863,7 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn to_kdl() -> ::std::string::String { #[allow(unused_mut)] let mut __usage_kdl = SPEC.to_kdl(); + #config_extra #spec_extra __usage_kdl } @@ -3743,31 +3755,31 @@ fn settings(cli: &Cli) -> Option { /// /// One loop over what every command contributed, so a flattened group's value and the root's own /// become entries the same way. A second conversion is the thing this whole stack keeps deleting. -fn settings_layer() -> TokenStream { +fn settings_layer(config: &TokenStream) -> TokenStream { quote! { /// This command line as a layer, for `usage_config::resolve`. - pub fn settings_layer(partial: &Partial) -> ::usage_config::CliLayer { - let mut __usage_layer = ::usage_config::CliLayer::new( + pub fn settings_layer(partial: &Partial) -> #config::CliLayer { + let mut __usage_layer = #config::CliLayer::new( ::std::iter::empty::<(::std::string::String, ::std::string::String)>(), ); for (__usage_key, __usage_given) in settings_given(partial) { __usage_layer = match __usage_given { usage_argv::spec::SettingGiven::Bool(__usage_value) => { - __usage_layer.with_value(__usage_key, ::usage_config::Value::Bool(__usage_value)) + __usage_layer.with_value(__usage_key, #config::Value::Bool(__usage_value)) } usage_argv::spec::SettingGiven::Int(__usage_value) => { - __usage_layer.with_value(__usage_key, ::usage_config::Value::Int(__usage_value)) + __usage_layer.with_value(__usage_key, #config::Value::Int(__usage_value)) } usage_argv::spec::SettingGiven::Text(__usage_value) => { __usage_layer - .with_value(__usage_key, ::usage_config::Value::String(__usage_value)) + .with_value(__usage_key, #config::Value::String(__usage_value)) } usage_argv::spec::SettingGiven::List(__usage_items) => __usage_layer.with_value( __usage_key, - ::usage_config::Value::List( + #config::Value::List( __usage_items .into_iter() - .map(::usage_config::Value::String) + .map(#config::Value::String) .collect(), ), ), diff --git a/derive/src/config.rs b/derive/src/config.rs new file mode 100644 index 000000000..3ae6acd8a --- /dev/null +++ b/derive/src/config.rs @@ -0,0 +1,1074 @@ +//! `#[derive(usage::Config)]`: a settings struct as the declaration of its own registry. +//! +//! The fleet's pattern — a `settings.toml` registry, a `build.rs` generator, and a settings +//! struct the generator emits — keeps three descriptions of every setting in step by hand. +//! This derive collapses them to one: the author writes the struct the CLI already holds its +//! settings in, and the derive generates the `usage_config` registry, the reader that fills +//! the struct from a resolution, and the spec `config` block that documents it. There is no +//! second declaration left to drift from. +//! +//! ```ignore +//! /// How this tool behaves, resolved from flags, the environment, and files. +//! #[derive(usage::Config)] +//! struct Settings { +//! /// How many jobs to run at once +//! #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] +//! jobs: u64, +//! +//! /// Paths to leave alone +//! #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] +//! exclude: Option>, +//! +//! /// Where the cache lives +//! #[usage(env = "EX_CACHE_DIR", default_fn = default_cache_dir, +//! default_note = "under the user cache directory")] +//! cache_dir: std::path::PathBuf, +//! +//! #[usage(flatten)] +//! task: TaskSettings, +//! } +//! +//! /// The `task.*` settings. +//! #[derive(usage::Config)] +//! #[usage(prefix = "task")] +//! struct TaskSettings { +//! /// How task output is interleaved +//! #[usage(default = "prefix", choices("prefix", "interleave"))] +//! output: String, +//! } +//! ``` +//! +//! The struct's field types are the declaration's types: `bool`, integers, `String`, +//! `PathBuf`, `Vec`, `BTreeMap`, and `Option` for a setting that may be +//! absent. A field is read with `usage_config::FromValue`, so a type this table does not +//! name can still be a field by saying what the spec should call it: `ty = "duration"` on a +//! `String` field holds a span of time the way the registry declares one. +//! +//! Nesting composes through `usage_config::Props`, the way `usage::Cli` composes flattened +//! groups: a child declares its own props (usually under a `prefix`), and the parent joins +//! the slices at compile time. Two groups declaring the same key are refused at compile +//! time by the join. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::spanned::Spanned; +use syn::{Data, DeriveInput, Expr, ExprLit, Fields, Lit, Meta, UnOp}; + +use crate::crate_name::{crate_name, FoundCrate}; +use crate::model::{attrs, doc_comment, flag_value, ident_of, nested, string_value}; + +/// The config crate as the adopter depended on it. +/// +/// The same resolution the other generated paths use: a direct `usage-config` dependency +/// wins, otherwise the `usage-rs` facade provides it as `usage::config`, and the bare name +/// is left to produce the useful compiler error when neither was declared. +pub(crate) fn config_path() -> TokenStream { + match crate_name("usage-config") { + Ok(FoundCrate::Itself) => quote!(::usage_config), + Ok(FoundCrate::Name(name)) => { + let config = format_ident!("{}", name.replace('-', "_")); + quote!(::#config) + } + _ => match crate_name("usage-rs") { + Ok(FoundCrate::Itself) => quote!(::usage_rs::config), + Ok(FoundCrate::Name(name)) => { + let facade = format_ident!("{}", name.replace('-', "_")); + quote!(::#facade::config) + } + _ => quote!(::usage_config), + }, + } +} + +/// The declaration, read from the struct. +pub struct Config { + ident: syn::Ident, + fields: Vec, +} + +enum Field { + Prop(Box), + /// Another `Config` struct whose props follow this position in the joined registry. + Flatten { + ident: syn::Ident, + ty: Box, + }, +} + +struct Prop { + ident: syn::Ident, + key: String, + ty: Ty, + /// The type the field holds and the fold reads, `Option` peeled. + read_ty: syn::Type, + /// Whether the field is an `Option` — absence is a legitimate state. + optional_field: bool, + default: Option, + /// A runtime default: `fn() -> T`, applied after the fold when no layer supplied one. + default_fn: Option, + default_note: Option, + envs: Vec, + deprecated_envs: Vec, + cli: Vec, + aliases: Vec, + /// `(kind, key)` pairs, in declaration order. + bindings: Vec<(String, String)>, + choices: Vec, + merge: Option, + scope: Option, + parse: Option, + hide: bool, + deprecated: Option, + deprecated_warn_at: Option, + deprecated_remove_at: Option, + since: Option, + examples: Vec, + help: Option, + long_help: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Merge { + Union, + Deep, +} + +#[derive(Clone, Copy, PartialEq)] +enum Scope { + Global, + Env, +} + +/// The declared type, mirroring `usage_config::Ty`. +#[derive(Clone, PartialEq)] +enum Ty { + Bool, + Int, + Uint, + Float, + String, + Path, + Url, + Duration, + Object, + Any, + List(Box), + Set(Box), + Map(Box), +} + +impl Ty { + fn tokens(&self, config: &TokenStream) -> TokenStream { + match self { + Self::Bool => quote!(#config::Ty::Bool), + Self::Int => quote!(#config::Ty::Int), + Self::Uint => quote!(#config::Ty::Uint), + Self::Float => quote!(#config::Ty::Float), + Self::String => quote!(#config::Ty::String), + Self::Path => quote!(#config::Ty::Path), + Self::Url => quote!(#config::Ty::Url), + Self::Duration => quote!(#config::Ty::Duration), + Self::Object => quote!(#config::Ty::Object), + Self::Any => quote!(#config::Ty::Any), + Self::List(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::List(&#inner)) + } + Self::Set(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::Set(&#inner)) + } + Self::Map(inner) => { + let inner = inner.tokens(config); + quote!(#config::Ty::Map(&#inner)) + } + } + } + + /// Whether values of this type are a collection, which is what `merge` policies and + /// named parsers are about. + fn is_collection(&self) -> bool { + matches!( + self, + Self::List(_) | Self::Set(_) | Self::Map(_) | Self::Object + ) + } + + /// Whether `value` could be a value of this type, read the way the merge reads one. + /// + /// Permissive exactly where coercion is: a string default under an `int` type would be + /// coerced at runtime, but a declaration is the place to write the value as what it is. + fn admits(&self, value: &Const) -> bool { + match (self, value) { + (Self::Any, _) => true, + (Self::Bool, Const::Bool(_)) => true, + (Self::Int | Self::Uint | Self::Float, Const::Int(_)) => true, + (Self::Float, Const::Float(_)) => true, + (Self::String | Self::Path | Self::Url | Self::Duration, Const::Str(_)) => true, + // The coercion rule the registry itself follows: a string type reads a bare + // number or boolean as its text. + ( + Self::String | Self::Path | Self::Url | Self::Duration, + Const::Bool(_) | Const::Int(_) | Const::Float(_), + ) => true, + (Self::List(item) | Self::Set(item), Const::List(items)) => { + items.iter().all(|value| item.admits(value)) + } + // One bare value where a list belongs is a list of one, the same rule + // `Ty::coerce` applies. + (Self::List(item) | Self::Set(item), scalar) => item.admits(scalar), + _ => false, + } + } +} + +/// A literal a registry can hold as a `const`. +#[derive(Clone, PartialEq)] +enum Const { + Bool(bool), + Int(i64), + Float(f64), + Str(String), + List(Vec), +} + +impl Const { + fn tokens(&self, config: &TokenStream) -> TokenStream { + match self { + Self::Bool(b) => quote!(#config::Const::Bool(#b)), + Self::Int(i) => quote!(#config::Const::Int(#i)), + Self::Float(f) => quote!(#config::Const::Float(#f)), + Self::Str(s) => quote!(#config::Const::Str(#s)), + Self::List(items) => { + let items = items.iter().map(|item| item.tokens(config)); + quote!(#config::Const::List(&[#(#items),*])) + } + } + } +} + +impl Config { + pub fn from_input(input: &DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "usage::Config describes a settings struct, so it needs a struct with named \ + fields", + )); + }; + let Fields::Named(named) = &data.fields else { + return Err(syn::Error::new_spanned( + &data.fields, + "usage::Config reads settings into named fields; a tuple or unit struct has \ + nowhere to put them", + )); + }; + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "usage::Config does not support generic parameters: the generated registry \ + is `const` and every field has to be a concrete type a value can be read \ + into", + )); + } + + let mut prefix = None; + for attr in attrs(&input.attrs) { + for meta in nested(attr)? { + match ident_of(meta.path()).as_str() { + "prefix" => prefix = Some(string_value(&meta)?), + other => { + return Err(syn::Error::new_spanned( + meta.path(), + format!("usage::Config does not understand `{other}` on the struct"), + )); + } + } + } + } + + let mut fields = Vec::new(); + for field in &named.named { + fields.push(Field::from_field(field, prefix.as_deref())?); + } + + // A duplicate within one struct is visible here, so it is refused here, with spans. + // Duplicates across flattened structs are refused by `concat_props` at compile time. + let mut keys: Vec<(&String, &syn::Ident)> = Vec::new(); + for field in &fields { + if let Field::Prop(prop) = field { + if let Some((_, first)) = keys.iter().find(|(key, _)| **key == prop.key) { + return Err(syn::Error::new( + prop.ident.span(), + format!( + "`{}` and `{first}` declare the same setting key `{}`", + prop.ident, prop.key + ), + )); + } + keys.push((&prop.key, &prop.ident)); + } + } + + Ok(Self { + ident: input.ident.clone(), + fields, + }) + } +} + +impl Field { + fn from_field(field: &syn::Field, prefix: Option<&str>) -> syn::Result { + let ident = field.ident.clone().expect("named fields only"); + + let mut flatten = false; + let mut key_attr = None; + let mut ty_attr = None; + let mut explicit_optional = None; + let mut prop = Prop { + ident: ident.clone(), + key: String::new(), + ty: Ty::Any, + read_ty: field.ty.clone(), + optional_field: false, + default: None, + default_fn: None, + default_note: None, + envs: Vec::new(), + deprecated_envs: Vec::new(), + cli: Vec::new(), + aliases: Vec::new(), + bindings: Vec::new(), + choices: Vec::new(), + merge: None, + scope: None, + parse: None, + hide: false, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + since: None, + examples: Vec::new(), + help: None, + long_help: None, + }; + (prop.help, prop.long_help) = doc_comment(&field.attrs, false)?; + + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + let name = ident_of(meta.path()); + match name.as_str() { + "flatten" => flatten = flag_value(&meta)?, + "key" => key_attr = Some(string_value(&meta)?), + "ty" => ty_attr = Some((string_value(&meta)?, meta.path().span())), + "env" => prop.envs.extend(strings(&meta)?), + "deprecated_env" => prop.deprecated_envs.extend(strings(&meta)?), + "cli" => prop.cli.extend(strings(&meta)?), + "alias" | "aliases" => prop.aliases.extend(strings(&meta)?), + "example" | "examples" => prop.examples.extend(strings(&meta)?), + "source" => { + let mut words = strings(&meta)?; + if words.len() < 2 { + return Err(syn::Error::new_spanned( + &meta, + "`source` takes a kind and at least one key, as in \ + `source(\"git\", \"hk.jobs\")`", + )); + } + let kind = words.remove(0); + prop.bindings + .extend(words.into_iter().map(|key| (kind.clone(), key))); + } + "default" => prop.default = Some(const_value(&meta)?), + "default_fn" => { + prop.default_fn = Some(meta.require_name_value()?.value.clone()); + } + "default_note" => prop.default_note = Some(string_value(&meta)?), + "choices" => prop.choices = consts(&meta)?, + "merge" => { + prop.merge = Some(match string_value(&meta)?.as_str() { + "union" => Merge::Union, + "deep" => Merge::Deep, + "replace" => { + return Err(syn::Error::new_spanned( + &meta, + "`replace` is the default; say nothing instead", + )) + } + other => { + return Err(syn::Error::new_spanned( + &meta, + format!("`merge` is `union` or `deep`, not `{other}`"), + )) + } + }); + } + "scope" => { + prop.scope = Some(match string_value(&meta)?.as_str() { + "global" => Scope::Global, + "env" => Scope::Env, + other => { + return Err(syn::Error::new_spanned( + &meta, + format!("`scope` is `global` or `env`, not `{other}`"), + )) + } + }); + } + "parse" => { + let parse = string_value(&meta)?; + if !matches!( + parse.as_str(), + "list_by_comma" + | "list_by_colon" + | "list_by_os_path_separator" + | "set_by_comma" + ) { + return Err(syn::Error::new_spanned( + &meta, + format!("`{parse}` is not a parser the spec names"), + )); + } + prop.parse = Some(parse); + } + "hide" => prop.hide = flag_value(&meta)?, + "optional" => explicit_optional = Some(flag_value(&meta)?), + "deprecated" => prop.deprecated = Some(string_value(&meta)?), + "deprecated_warn_at" => prop.deprecated_warn_at = Some(string_value(&meta)?), + "deprecated_remove_at" => { + prop.deprecated_remove_at = Some(string_value(&meta)?) + } + "since" => prop.since = Some(string_value(&meta)?), + "help" => prop.help = Some(string_value(&meta)?), + "long_help" => prop.long_help = Some(string_value(&meta)?), + other => { + return Err(syn::Error::new_spanned( + meta.path(), + format!("usage::Config does not understand `{other}`"), + )); + } + } + } + } + + if flatten { + // A flattened group is another declaration, not a prop: everything about its + // settings belongs on its own fields. + let described = [ + (!prop.envs.is_empty(), "env"), + (prop.default.is_some(), "default"), + (prop.default_fn.is_some(), "default_fn"), + (!prop.cli.is_empty(), "cli"), + (!prop.bindings.is_empty(), "source"), + (!prop.choices.is_empty(), "choices"), + (prop.merge.is_some(), "merge"), + (prop.scope.is_some(), "scope"), + (prop.parse.is_some(), "parse"), + (prop.hide, "hide"), + (key_attr.is_some(), "key"), + (ty_attr.is_some(), "ty"), + ]; + if let Some((_, what)) = described.iter().find(|(given, _)| *given) { + return Err(syn::Error::new( + ident.span(), + format!( + "`{what}` describes a setting, and `flatten` says this field is a \ + group of them: put it on the group's own fields" + ), + )); + } + return Ok(Self::Flatten { + ident, + ty: Box::new(field.ty.clone()), + }); + } + + let unraw = ident.to_string(); + let unraw = unraw.strip_prefix("r#").unwrap_or(&unraw); + prop.key = match key_attr { + Some(key) => key, + None => unraw.to_string(), + }; + if let Some(prefix) = prefix { + prop.key = format!("{prefix}.{}", prop.key); + } + + // The field's type is the declaration's type. `Option` peels to T and marks the + // setting optional; a `ty` override renames what the spec calls it without changing + // what the field holds. + let (optional_field, inner) = peel_option(&field.ty); + prop.optional_field = optional_field; + prop.read_ty = inner.clone(); + prop.ty = match ty_attr { + Some((name, span)) => parse_ty_name(&name) + .ok_or_else(|| syn::Error::new(span, format!("`{name}` is not a spec type")))?, + None => infer_ty(&inner).ok_or_else(|| { + syn::Error::new( + inner.span(), + "this type does not name a spec type on its own; say what the spec \ + should call it, as in `ty = \"duration\"` on a String field", + ) + })?, + }; + + if let Some(optional) = explicit_optional { + if optional != prop.optional_field { + return Err(syn::Error::new( + ident.span(), + "`optional` and the field's type disagree: `Option` is how a field \ + says a setting may be absent", + )); + } + } + if prop.optional_field && prop.default.is_some() { + return Err(syn::Error::new( + ident.span(), + "a setting with a `default` always has a value, so the field cannot be an \ + `Option`: give it the inner type", + )); + } + if prop.default.is_some() && prop.default_fn.is_some() { + return Err(syn::Error::new( + ident.span(), + "`default` and `default_fn` are two answers to the same question; declare one", + )); + } + if prop.optional_field && prop.default_fn.is_some() { + return Err(syn::Error::new( + ident.span(), + "a setting with a `default_fn` always has a value, so the field cannot be an \ + `Option`: give it the inner type", + )); + } + if let Some(default) = &prop.default { + if !prop.ty.admits(default) { + return Err(syn::Error::new( + ident.span(), + format!( + "the default is not a value `{}` can hold", + type_name(&prop.ty) + ), + )); + } + if !prop.choices.is_empty() && !prop.choices.iter().any(|choice| choice == default) { + return Err(syn::Error::new( + ident.span(), + "the default is not one of the declared choices", + )); + } + } + if !prop.choices.is_empty() && prop.ty == Ty::Bool { + return Err(syn::Error::new( + ident.span(), + "choices on a `bool` cannot say anything its two values do not already", + )); + } + for choice in &prop.choices { + if !prop.ty.admits(choice) { + return Err(syn::Error::new( + ident.span(), + format!("a choice is not a value `{}` can hold", type_name(&prop.ty)), + )); + } + } + if prop.merge.is_some() && !prop.ty.is_collection() { + return Err(syn::Error::new( + ident.span(), + "`merge` says how a collection combines across layers, and this is not one", + )); + } + if prop.parse.is_some() && !prop.ty.is_collection() { + return Err(syn::Error::new( + ident.span(), + "`parse` splits one string into several values, and this field holds one", + )); + } + + Ok(Self::Prop(Box::new(prop))) + } +} + +fn type_name(ty: &Ty) -> String { + match ty { + Ty::Bool => "bool".into(), + Ty::Int => "int".into(), + Ty::Uint => "uint".into(), + Ty::Float => "float".into(), + Ty::String => "string".into(), + Ty::Path => "path".into(), + Ty::Url => "url".into(), + Ty::Duration => "duration".into(), + Ty::Object => "object".into(), + Ty::Any => "any".into(), + Ty::List(inner) => format!("list<{}>", type_name(inner)), + Ty::Set(inner) => format!("set<{}>", type_name(inner)), + Ty::Map(inner) => format!("map", type_name(inner)), + } +} + +/// `Option` peeled to `T`, and whether there was one to peel. +fn peel_option(ty: &syn::Type) -> (bool, syn::Type) { + if let syn::Type::Path(path) = ty { + if let Some(last) = path.path.segments.last() { + if last.ident == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &last.arguments { + if let Some(syn::GenericArgument::Type(inner)) = args.args.first() { + return (true, inner.clone()); + } + } + } + } + } + (false, ty.clone()) +} + +/// The spec type a Rust type names on its own, or `None` for one that needs `ty = "..."`. +fn infer_ty(ty: &syn::Type) -> Option { + let syn::Type::Path(path) = ty else { + return None; + }; + let last = path.path.segments.last()?; + let name = last.ident.to_string(); + let generic = |index: usize| -> Option { + if let syn::PathArguments::AngleBracketed(args) = &last.arguments { + args.args + .iter() + .filter_map(|arg| match arg { + syn::GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) + .nth(index) + } else { + None + } + }; + Some(match name.as_str() { + "bool" => Ty::Bool, + "u8" | "u16" | "u32" | "u64" | "usize" => Ty::Uint, + "i8" | "i16" | "i32" | "i64" | "isize" => Ty::Int, + "f32" | "f64" => Ty::Float, + "String" => Ty::String, + "PathBuf" => Ty::Path, + "Value" => Ty::Any, + "Vec" => Ty::List(Box::new(infer_ty(&generic(0)?)?)), + "BTreeMap" => { + // The key is part of the shape: a spec map is keyed by strings, and a map keyed + // by anything else has no spelling a config file could write. + let key = generic(0)?; + if !matches!(infer_ty(&key), Some(Ty::String)) { + return None; + } + Ty::Map(Box::new(infer_ty(&generic(1)?)?)) + } + _ => return None, + }) +} + +/// A spec type by the name a spec writes: `uint`, `list`, `map`. +fn parse_ty_name(name: &str) -> Option { + let name = name.trim(); + if let Some(inner) = name.strip_prefix("list<").and_then(|s| s.strip_suffix('>')) { + return Some(Ty::List(Box::new(parse_ty_name(inner)?))); + } + if let Some(inner) = name.strip_prefix("set<").and_then(|s| s.strip_suffix('>')) { + return Some(Ty::Set(Box::new(parse_ty_name(inner)?))); + } + if let Some(inner) = name.strip_prefix("map<").and_then(|s| s.strip_suffix('>')) { + let (key, value) = inner.split_once(',')?; + if key.trim() != "string" { + return None; + } + return Some(Ty::Map(Box::new(parse_ty_name(value)?))); + } + Some(match name { + "bool" => Ty::Bool, + "int" => Ty::Int, + "uint" => Ty::Uint, + "float" => Ty::Float, + "string" => Ty::String, + "path" => Ty::Path, + "url" => Ty::Url, + "duration" => Ty::Duration, + "object" => Ty::Object, + "any" => Ty::Any, + _ => return None, + }) +} + +/// `env = "X"` or `env("A", "B")`, as the strings. +fn strings(meta: &Meta) -> syn::Result> { + match meta { + Meta::NameValue(_) => Ok(vec![string_value(meta)?]), + Meta::List(list) => { + let parsed = list.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + parsed + .into_iter() + .map(|lit| match lit { + Lit::Str(s) => Ok(s.value()), + other => Err(syn::Error::new_spanned(other, "expected a string")), + }) + .collect() + } + Meta::Path(path) => Err(syn::Error::new_spanned( + path, + "expected a value, as in `env = \"EX_JOBS\"` or `env(\"A\", \"B\")`", + )), + } +} + +/// `default = 4`, `default = "x"`, `default = -1`, or `default(80, 443)` for a list. +fn const_value(meta: &Meta) -> syn::Result { + match meta { + Meta::NameValue(nv) => const_expr(&nv.value), + Meta::List(list) => { + let parsed = list.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + Ok(Const::List( + parsed + .into_iter() + .map(|expr| const_expr(&expr)) + .collect::>()?, + )) + } + Meta::Path(path) => Err(syn::Error::new_spanned( + path, + "expected a value, as in `default = 4` or `default(80, 443)`", + )), + } +} + +fn consts(meta: &Meta) -> syn::Result> { + let Meta::List(list) = meta else { + return Err(syn::Error::new_spanned( + meta, + "expected a list, as in `choices(\"a\", \"b\")`", + )); + }; + let parsed = list + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated)?; + parsed.into_iter().map(|expr| const_expr(&expr)).collect() +} + +fn const_expr(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { lit, .. }) => const_lit(lit, false), + Expr::Unary(unary) if matches!(unary.op, UnOp::Neg(_)) => { + if let Expr::Lit(ExprLit { lit, .. }) = unary.expr.as_ref() { + return const_lit(lit, true); + } + Err(syn::Error::new_spanned(expr, "expected a literal")) + } + other => Err(syn::Error::new_spanned( + other, + "expected a literal the registry can hold as a const; a computed value is \ + `default_fn`", + )), + } +} + +fn const_lit(lit: &Lit, negated: bool) -> syn::Result { + let value = match lit { + Lit::Bool(b) => Const::Bool(b.value()), + Lit::Int(i) => Const::Int(i.base10_parse::()?), + Lit::Float(f) => Const::Float(f.base10_parse::()?), + Lit::Str(s) => Const::Str(s.value()), + other => { + return Err(syn::Error::new_spanned( + other, + "expected a boolean, number, or string", + )) + } + }; + Ok(match (negated, value) { + (false, value) => value, + (true, Const::Int(i)) => Const::Int(-i), + (true, Const::Float(f)) => Const::Float(-f), + (true, _) => { + return Err(syn::Error::new_spanned( + lit, + "only a number can be negative", + )) + } + }) +} + +pub fn emit(config: &Config) -> TokenStream { + let ident = &config.ident; + let cfg = config_path(); + + let metas: Vec = config + .fields + .iter() + .filter_map(|field| match field { + Field::Prop(prop) => Some(prop_meta(prop, &cfg)), + Field::Flatten { .. } => None, + }) + .collect(); + + let has_flatten = config + .fields + .iter() + .any(|field| matches!(field, Field::Flatten { .. })); + + // Without flattening the props are one literal slice. With it, the slices join at + // compile time in field order, so an id is a position in the joined table — the same + // arrangement `usage::Cli` uses for a flattened group's flags. + let metas_len = metas.len(); + let props_decl = if has_flatten { + let parts: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let meta = prop_meta(prop, &cfg); + quote!(&[#meta]) + } + Field::Flatten { ty, .. } => quote!(<#ty as #cfg::Props>::PROPS), + }) + .collect(); + quote! { + const __USAGE_PARTS: &[&[#cfg::PropMeta]] = &[#(#parts),*]; + const __USAGE_LEN: usize = { + let mut total = 0; + let mut i = 0; + while i < __USAGE_PARTS.len() { + total += __USAGE_PARTS[i].len(); + i += 1; + } + total + }; + static __USAGE_PROPS: [#cfg::PropMeta; __USAGE_LEN] = + #cfg::concat_props(__USAGE_PARTS); + } + } else { + quote! { + static __USAGE_PROPS: [#cfg::PropMeta; #metas_len] = [#(#metas),*]; + } + }; + + // Reads in declaration order, advancing a cursor: one id per own prop, a group's length + // for a flattened child. Everything is read before anything is judged, so the fold holds + // every error rather than the first one. + let reads: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let local = read_local(&prop.ident); + let read_ty = &prop.read_ty; + let method = if prop.optional_field || prop.default_fn.is_some() { + quote!(optional) + } else { + quote!(required) + }; + quote! { + let #local: ::std::option::Option<#read_ty> = + __usage_fold.#method(#cfg::PropId(__usage_at)); + __usage_at += 1; + } + } + Field::Flatten { ident, ty } => { + let local = read_local(ident); + quote! { + let #local: ::std::option::Option<#ty> = + <#ty as #cfg::Props>::read_at(__usage_fold, __usage_at); + __usage_at += <#ty as #cfg::Props>::PROPS.len() as u16; + } + } + }) + .collect(); + + let builds: Vec = config + .fields + .iter() + .map(|field| match field { + Field::Prop(prop) => { + let name = &prop.ident; + let local = read_local(name); + if let Some(default_fn) = &prop.default_fn { + quote!(#name: #local.unwrap_or_else(#default_fn)) + } else if prop.optional_field { + quote!(#name: #local) + } else { + quote!(#name: #local?) + } + } + Field::Flatten { ident, .. } => { + let local = read_local(ident); + quote!(#ident: #local?) + } + }) + .collect(); + + quote! { + const _: () = { + #props_decl + + impl #cfg::Props for #ident { + const PROPS: &'static [#cfg::PropMeta] = &__USAGE_PROPS; + + fn read_at( + __usage_fold: &mut #cfg::Fold<'_>, + __usage_base: u16, + ) -> ::std::option::Option { + let mut __usage_at: u16 = __usage_base; + #(#reads)* + let _ = __usage_at; + ::std::option::Option::Some(Self { + #(#builds),* + }) + } + } + + impl #ident { + /// Every setting this struct declares, one entry per field, flattened groups + /// included. What `usage-config-build` would have generated, generated from + /// the struct instead — there is no second declaration to keep in step. + pub const SETTINGS_PROPS: &'static [#cfg::PropMeta] = + ::PROPS; + + /// The registry over [`Self::SETTINGS_PROPS`], for `resolve`, `drift`, and + /// the layers. + pub const SETTINGS_REGISTRY: #cfg::Registry = + #cfg::Registry::new(Self::SETTINGS_PROPS); + + /// This resolution's values, as the struct. + /// + /// Every field is read before anything is returned, so the error is the whole + /// list of what is wrong rather than the first thing found. + pub fn read( + __usage_resolved: &#cfg::Resolved, + ) -> ::std::result::Result { + let mut __usage_fold = __usage_resolved.fold(); + let __usage_read = ::read_at(&mut __usage_fold, 0); + __usage_fold.finish()?; + ::std::result::Result::Ok(__usage_read.expect( + "the fold reported nothing, so every field was read", + )) + } + + /// The spec `config` block for these settings, as KDL. + /// + /// What documents, JSON schema and completions read. A CLI deriving + /// `usage::Cli` names this type in `#[usage(config = ...)]` instead of + /// calling this, and its `to_kdl` carries the block. + pub fn spec_kdl() -> ::std::string::String { + #cfg::spec_kdl(Self::SETTINGS_PROPS) + } + } + }; + } +} + +/// The local a field is read into, unrawed: `r#match` reads into `__usage_read_match`. +fn read_local(ident: &syn::Ident) -> syn::Ident { + let name = ident.to_string(); + let name = name.strip_prefix("r#").unwrap_or(&name); + format_ident!("__usage_read_{name}") +} + +/// One prop as registry metadata, in struct-update form over `PropMeta::new`. +fn prop_meta(prop: &Prop, cfg: &TokenStream) -> TokenStream { + let key = &prop.key; + let ty = if prop.optional_field { + let inner = prop.ty.tokens(cfg); + quote!(#cfg::Ty::Option(&#inner)) + } else { + prop.ty.tokens(cfg) + }; + let mut fields = Vec::new(); + if let Some(default) = &prop.default { + let default = default.tokens(cfg); + fields.push(quote!(default: ::std::option::Option::Some(#default))); + } + if let Some(merge) = prop.merge { + let merge = match merge { + Merge::Union => quote!(#cfg::Merge::Union), + Merge::Deep => quote!(#cfg::Merge::Deep), + }; + fields.push(quote!(merge: #merge)); + } + if let Some(scope) = prop.scope { + let scope = match scope { + Scope::Global => quote!(#cfg::Scope::Global), + Scope::Env => quote!(#cfg::Scope::Env), + }; + fields.push(quote!(scope: #scope)); + } + if let Some(parse) = &prop.parse { + let parser = match parse.as_str() { + "list_by_comma" => quote!(#cfg::Parser::ListByComma), + "list_by_colon" => quote!(#cfg::Parser::ListByColon), + "list_by_os_path_separator" => quote!(#cfg::Parser::ListByOsPathSeparator), + _ => quote!(#cfg::Parser::SetByComma), + }; + fields.push(quote!(parse: ::std::option::Option::Some(#parser))); + } + if !prop.envs.is_empty() { + let envs = &prop.envs; + fields.push(quote!(envs: &[#(#envs),*])); + } + if !prop.deprecated_envs.is_empty() { + let envs = &prop.deprecated_envs; + fields.push(quote!(deprecated_envs: &[#(#envs),*])); + } + if !prop.cli.is_empty() { + let cli = &prop.cli; + fields.push(quote!(cli: &[#(#cli),*])); + } + if !prop.bindings.is_empty() { + let pairs = prop + .bindings + .iter() + .map(|(kind, key)| quote!((#kind, #key))); + fields.push(quote!(bindings: &[#(#pairs),*])); + } + if !prop.choices.is_empty() { + let choices = prop.choices.iter().map(|choice| choice.tokens(cfg)); + fields.push(quote!(choices: &[#(#choices),*])); + } + if prop.hide { + fields.push(quote!(hide: true)); + } + if let Some(deprecated) = &prop.deprecated { + fields.push(quote!(deprecated: ::std::option::Option::Some(#deprecated))); + } + if !prop.aliases.is_empty() { + let aliases = &prop.aliases; + fields.push(quote!(aliases: &[#(#aliases),*])); + } + if prop.optional_field || prop.default_fn.is_some() { + fields.push(quote!(optional: ::std::option::Option::Some(true))); + } + if let Some(help) = &prop.help { + fields.push(quote!(help: ::std::option::Option::Some(#help))); + } + if let Some(long_help) = &prop.long_help { + fields.push(quote!(long_help: ::std::option::Option::Some(#long_help))); + } + if let Some(note) = &prop.default_note { + fields.push(quote!(default_note: ::std::option::Option::Some(#note))); + } + if let Some(since) = &prop.since { + fields.push(quote!(since: ::std::option::Option::Some(#since))); + } + if let Some(at) = &prop.deprecated_warn_at { + fields.push(quote!(deprecated_warn_at: ::std::option::Option::Some(#at))); + } + if let Some(at) = &prop.deprecated_remove_at { + fields.push(quote!(deprecated_remove_at: ::std::option::Option::Some(#at))); + } + if !prop.examples.is_empty() { + let examples = &prop.examples; + fields.push(quote!(examples: &[#(#examples),*])); + } + quote! { + #cfg::PropMeta { + #(#fields,)* + ..#cfg::PropMeta::new(#key, #ty) + } + } +} diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 0be1e5b02..72b833c9d 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -412,6 +412,7 @@ use syn::{parse_macro_input, DeriveInput}; mod case; mod codegen; +mod config; mod crate_name; mod model; @@ -467,6 +468,38 @@ pub fn derive_subcommands(input: TokenStream) -> TokenStream { } } +/// Compile a settings struct into its own registry, reader, and spec `config` block. +/// +/// The struct the CLI already holds its settings in becomes the declaration: field types are +/// the settings' types, doc comments are their help, and `#[usage(...)]` carries what a spec's +/// `prop` node would — `env`, `default`, `merge`, `scope`, `choices`, `source` bindings. +/// The derive generates `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read(&Resolved)`, and +/// `spec_kdl()`, so the registry, the reader, and the documentation cannot drift from the +/// struct or from each other. See the [config module](config) docs for the field vocabulary. +/// +/// ```ignore +/// #[derive(usage::Config)] +/// struct Settings { +/// /// How many jobs to run at once +/// #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] +/// jobs: u64, +/// #[usage(flatten)] +/// task: TaskSettings, +/// } +/// ``` +/// +/// A group flattens into another with `#[usage(flatten)]`, declaring its dotted keys under +/// its own `#[usage(prefix = "task")]`. The joined registry refuses duplicate keys at +/// compile time. +#[proc_macro_derive(Config, attributes(usage))] +pub fn derive_config(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match config::Config::from_input(&input) { + Ok(config) => config::emit(&config).into(), + Err(e) => e.to_compile_error().into(), + } +} + /// Compile an enum into the words one value may be. /// /// What a CLI calls an enum — `--shell bash` — and what the spec calls `choices`. The diff --git a/derive/src/model.rs b/derive/src/model.rs index ff37f6a08..a4dfcb629 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -87,6 +87,12 @@ pub struct Cli { /// nothing itself. A struct that declares arguments of its own is refused, because /// forwarding would drop them; see [`check`](Self::check). pub dispatch: Dispatch, + /// The type deriving `usage::Config` whose settings this CLI's emitted spec carries. + /// + /// Named rather than discovered — a macro sees one item — so `to_kdl` can append the + /// type's `config` block and the spec documents the settings the way it documents the + /// commands. Root-only, like `settings`: one spec, one `config` block. + pub config: Option, /// The oldest `usage` that can read the emitted spec, when the CLI says. /// /// Declared rather than computed. Working it out would mean a table from every property to @@ -686,6 +692,7 @@ impl Cli { spec_extra: None, settings: false, dispatch: Dispatch::default(), + config: None, min_usage_version: None, usage: None, effect: None, @@ -817,6 +824,17 @@ impl Cli { "run_with" => cli.dispatch.run_with = flag_value(&meta)?, "run_async" => cli.dispatch.run_async = flag_value(&meta)?, "run_async_with" => cli.dispatch.run_async_with = flag_value(&meta)?, + "config" => { + let value = &meta.require_name_value()?.value; + if !matches!(value, Expr::Path(_)) { + return Err(syn::Error::new_spanned( + value, + "`config` names a type deriving `usage::Config`, as in \ + `config = Settings`", + )); + } + cli.config = Some(syn::parse2(quote::ToTokens::to_token_stream(value))?); + } "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), "alias" | "aliases" if clap_attr => { @@ -1183,6 +1201,15 @@ impl Cli { is asked for its own by whoever flattens it", )); } + // One spec, one `config` block: the settings belong to the program, and only the + // root emits a spec for them to land in. + if self.config.is_some() { + return Err(self.misplaced( + ident, + "`config` belongs on the root, where `#[derive(Cli)]` is: the emitted \ + spec carries one `config` block for the whole program", + )); + } // One spec, one claim about which `usage` can read it — and only the root writes a // spec at all, so a command declaring it was storing a value with nowhere to go. if self.min_usage_version.is_some() { @@ -3692,7 +3719,7 @@ pub fn type_name(ty: &Type) -> String { /// Native `#[usage(...)]` plus clap-compatible `#[command(...)]` and /// `#[arg(...)]` attributes. The latter appears on fields (and inline variants), /// while the iterator is shared by all three model readers. -fn attrs(attrs: &[Attribute]) -> impl Iterator { +pub(crate) fn attrs(attrs: &[Attribute]) -> impl Iterator { attrs.iter().filter(|a| { a.path().is_ident("usage") || a.path().is_ident("command") @@ -3710,21 +3737,21 @@ fn value_attrs(attrs: &[Attribute]) -> impl Iterator { .filter(|a| a.path().is_ident("usage") || a.path().is_ident("value")) } -fn nested(attr: &Attribute) -> syn::Result> { +pub(crate) fn nested(attr: &Attribute) -> syn::Result> { let list = attr.meta.require_list()?; let parsed = list .parse_args_with(syn::punctuated::Punctuated::::parse_terminated)?; Ok(parsed.into_iter().collect()) } -fn ident_of(path: &syn::Path) -> String { +pub(crate) fn ident_of(path: &syn::Path) -> String { path.segments .last() .map(|s| s.ident.to_string()) .unwrap_or_default() } -fn string_value(meta: &Meta) -> syn::Result { +pub(crate) fn string_value(meta: &Meta) -> syn::Result { let value = &meta.require_name_value()?.value; match value { Expr::Lit(ExprLit { @@ -4433,7 +4460,7 @@ fn char_value(meta: &Meta) -> syn::Result { } /// A boolean option, where the bare word means true: `global` or `global = true`. -fn flag_value(meta: &Meta) -> syn::Result { +pub(crate) fn flag_value(meta: &Meta) -> syn::Result { match meta { Meta::Path(_) => Ok(true), _ => { @@ -4456,7 +4483,7 @@ fn flag_value(meta: &Meta) -> syn::Result { /// The first paragraph is the short form; the whole comment is the long form and is only /// reported when it says more than the short one. Prose is flowed by default, while /// `verbatim` keeps line breaks and whitespace for tables, examples, and ASCII art. -fn doc_comment( +pub(crate) fn doc_comment( attrs: &[Attribute], verbatim: bool, ) -> syn::Result<(Option, Option)> { diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f66e769d5..5c5dda36a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -69,6 +69,7 @@ export default defineConfig({ { text: "Migrating from clap", link: "/rust/migrating-from-clap" }, { text: "clap Compatibility", link: "/rust/clap-compatibility" }, { text: "Validation", link: "/rust/validation" }, + { text: "Settings", link: "/rust/settings" }, { text: "Help and Errors", link: "/rust/help" }, { text: "Performance", link: "/rust/performance" }, { text: "Completions", link: "/rust/completions" }, diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index 5f58c016d..06887bb53 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -236,23 +236,24 @@ A global flag may be given **once per command level**, with the innermost occurr On the root `#[derive(Cli)]` struct: -| Attribute | Effect | -| ----------------------------------- | -------------------------------------------------------------- | -| `bin = "…"` | The binary name (used in help and the spec) | -| `name = "…"` | A friendly display name | -| `version` / `version = "…"` | Enable `--version`/`-V`; bare form uses `CARGO_PKG_VERSION` | -| `long_version = "…"` | Extended `--version` text while `-V` stays concise | -| `about` / `long_about` | Description (doc comments work too) | -| `usage = "…"` | Verbatim synopsis line(s), replacing the generated one | -| `before_help` / `after_help` | Extra text around the help page (`*_long_help` variants too) | -| `unknown_flags = "value"\|"error"` | Treat unknown flags as values instead of errors | -| `default_subcommand = "run"` | Command to assume when argv names none | -| `multicall` | Treat argv[0]'s basename as a subcommand (busybox-style) | -| `view("bin", root = "command")` | Promote a command as another executable surface | -| `completion` | Generate completion support ([Completions](/rust/completions)) | -| `settings` | Generate config-settings bindings | -| `min_usage_version = "…"` | Declare the minimum usage version the spec needs | -| `group("name", required, multiple)` | Declare a flag group ([Validation](/rust/validation#groups)) | +| Attribute | Effect | +| ----------------------------------- | ----------------------------------------------------------------- | +| `bin = "…"` | The binary name (used in help and the spec) | +| `name = "…"` | A friendly display name | +| `version` / `version = "…"` | Enable `--version`/`-V`; bare form uses `CARGO_PKG_VERSION` | +| `long_version = "…"` | Extended `--version` text while `-V` stays concise | +| `about` / `long_about` | Description (doc comments work too) | +| `usage = "…"` | Verbatim synopsis line(s), replacing the generated one | +| `before_help` / `after_help` | Extra text around the help page (`*_long_help` variants too) | +| `unknown_flags = "value"\|"error"` | Treat unknown flags as values instead of errors | +| `default_subcommand = "run"` | Command to assume when argv names none | +| `multicall` | Treat argv[0]'s basename as a subcommand (busybox-style) | +| `view("bin", root = "command")` | Promote a command as another executable surface | +| `completion` | Generate completion support ([Completions](/rust/completions)) | +| `settings` | Generate config-settings bindings | +| `config = Settings` | Emit the named type's `config` block ([Settings](/rust/settings)) | +| `min_usage_version = "…"` | Declare the minimum usage version the spec needs | +| `group("name", required, multiple)` | Declare a flag group ([Validation](/rust/validation#groups)) | On a `#[derive(Args)]` struct (refused on the root): diff --git a/docs/rust/settings.md b/docs/rust/settings.md new file mode 100644 index 000000000..6278e33b2 --- /dev/null +++ b/docs/rust/settings.md @@ -0,0 +1,142 @@ +# Settings + +CLIs that resolve configuration from several places — flags, environment variables, config +files — have historically kept three descriptions of every setting in step by hand: a registry +file, a code generator, and the struct the program reads. `#[derive(usage::Config)]` collapses +them to one. The struct the CLI already holds its settings in becomes the declaration; the +derive generates the [usage-config](https://docs.rs/usage-config) registry, the reader that +fills the struct from a resolution, and the spec `config` block that documents it. + +Enable the `config` feature: + +```toml +[dependencies] +usage = { package = "usage-rs", version = "5.1", features = ["config"] } +``` + +## Declaring + +```rust +use usage_rs as usage; + +/// How this tool behaves, resolved from flags, the environment, and files. +#[derive(usage::Config)] +struct Settings { + /// How many jobs to run at once + #[usage(env = "EX_JOBS", default = 4, cli("--jobs", "-j"))] + jobs: u64, + + /// Paths to leave alone + #[usage(env = "EX_EXCLUDE", merge = "union", parse = "list_by_comma")] + exclude: Option>, + + /// Where the cache lives + #[usage(env = "EX_CACHE_DIR", default_fn = default_cache_dir, + default_note = "under the user cache directory")] + cache_dir: std::path::PathBuf, + + #[usage(flatten)] + task: TaskSettings, +} + +/// The `task.*` settings. +#[derive(usage::Config)] +#[usage(prefix = "task")] +struct TaskSettings { + /// How task output is interleaved + #[usage(default = "prefix", choices("prefix", "interleave"))] + output: String, +} + +fn default_cache_dir() -> std::path::PathBuf { + dirs::cache_dir().unwrap_or_default().join("ex") +} +``` + +The field's type is the setting's type. `bool`, integers, `f64`, `String`, `PathBuf`, +`Vec`, and `BTreeMap` name their spec types on their own; `Option` says +absence is a legitimate state. A type outside that table says what the spec should call it +with `ty = "…"` — a `String` field with `ty = "duration"` holds a span of time as its text, +which is how the fleet's registries store one. Doc comments become `help` and `long_help`, +exactly as they do for flags. + +Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): + +| Attribute | Effect | +| -------------------------------------------------------- | --------------------------------------------------------------------- | +| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | +| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | +| `default = 4` / `default(80, 443)` | The value when no layer supplies one | +| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | +| `default_note = "…"` | Prose beside the default, for docs | +| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | +| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | +| `choices("a", "b")` | The only values it accepts | +| `merge = "union"` / `"deep"` | How a collection combines across layers | +| `scope = "global"` / `"env"` | Where a value is accepted from | +| `parse = "list_by_comma"` | How one string becomes several values | +| `alias("other")` | Equivalent keys accepted without a warning | +| `key = "match"` | The dotted key, when the field name is not it | +| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | +| `flatten` | Splice another `Config` struct's settings in at this position | + +A flattened group declares its own dotted keys under its own `#[usage(prefix = "…")]`, and +the parent joins the slices at compile time — two groups declaring the same key are a compile +error, not a shadowed setting. + +## Resolving + +The derive generates `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read`, and `spec_kdl` on the +struct. The CLI names the layers it has — that stays its own business — and the registry +decides what every value means: + +```rust +use usage::config::{resolve, EnvLayer, FileLayer, FileScope, Layers}; + +let (cli, cli_layer) = Ex::parse_from_with_settings(&argv)?; +let env = EnvLayer::from_process(); +let project = FileLayer::find_up("ex.toml", &cwd, None, FileScope::Project); +let resolved = resolve( + Settings::SETTINGS_REGISTRY, + Layers::new().then(&cli_layer).then(&env).then(&project), +)?; +let settings = Settings::read(&resolved)?; +for warning in usage::config::explain::warnings(&resolved) { + eprintln!("{warning}"); +} +``` + +`read` visits every field before returning, so the error is the whole list of what is wrong +rather than the first thing found. Provenance is the merge's own output: `explain`, `list`, +and per-setting `origin` come free, without a second merge to drift from the first. + +## The spec carries the settings + +A root deriving [`Cli`](/rust/args-and-flags) names its settings type, and its emitted spec +carries the `config` block — so docs, JSON schema, and the reserved `config_keys` / +`config_values` completers read declarations made in Rust exactly as they read ones made in +KDL: + +```rust +#[derive(usage::Cli)] +#[usage(bin = "ex", config = Settings)] +struct Ex { + /// How many jobs to run at once + #[usage(long, short = 'j', setting = "jobs")] + jobs: Option, +} +``` + +`setting = "key"` on a flag is the executable binding; `cli("--jobs")` on the field is the +documented one. The adopter's whole drift test is one line: + +```rust +assert_eq!(Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), Vec::::new()); +``` + +## Spec-first instead + +A CLI that would rather declare settings in KDL keeps the other direction: +[usage-config-build](https://docs.rs/usage-config-build) reads the spec's `config` block at +build time and generates the registry _and_ the typed `Settings` struct. The two backends +emit the same registry shape, so nothing downstream can tell which way a CLI chose. diff --git a/usage-rs/Cargo.toml b/usage-rs/Cargo.toml index 4e5b2809c..335796b35 100644 --- a/usage-rs/Cargo.toml +++ b/usage-rs/Cargo.toml @@ -15,6 +15,7 @@ usage-argv = { workspace = true } usage-derive = { workspace = true, optional = true } usage-test = { workspace = true, optional = true } usage-validation = { workspace = true, optional = true } +usage-config = { workspace = true, optional = true } [features] # Applications get a usable CLI out of the box: parse tables, help, and @@ -32,6 +33,8 @@ validation = ["dep:usage-validation"] # Assertions about what a command line parses to, what a page says, and what a shell is # offered. A dev-dependency feature: nothing in an application's own code calls it. test = ["spec", "help", "dep:usage-test"] +# Settings: the `usage::Config` derive, and the resolver as `usage::config`. +config = ["spec", "dep:usage-config"] [package.metadata.release] shared-version = true diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index faa235c28..62964f200 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -52,6 +52,10 @@ extern crate self as usage_rs; pub use usage_argv as argv; pub use usage_argv::*; +#[cfg(feature = "config")] +pub use usage_config as config; +#[cfg(feature = "config")] +pub use usage_derive::Config; #[cfg(feature = "spec")] pub use usage_derive::{Args, Cli, Subcommands, ValueEnum}; #[cfg(feature = "test")] @@ -97,6 +101,27 @@ mod tests { assert_eq!(Internal::spec().bin, Some("internal")); } + #[cfg(feature = "config")] + #[derive(crate::Config)] + struct InternalSettings { + /// How many jobs to run at once + #[usage(env = "INTERNAL_JOBS", default = 4)] + jobs: u64, + } + + #[cfg(feature = "config")] + #[test] + fn the_config_derive_resolves_the_facade_from_inside_the_facade() { + let resolved = crate::config::resolve( + InternalSettings::SETTINGS_REGISTRY, + crate::config::Layers::new(), + ) + .expect("resolves"); + let settings = InternalSettings::read(&resolved).expect("reads"); + assert_eq!(settings.jobs, 4); + assert!(InternalSettings::spec_kdl().contains(r#"prop "jobs""#)); + } + #[cfg(feature = "validation")] #[test] fn derives_evaluate_portable_validation_expressions() { From 65b4975b0dd86a671d1938cf7104f5b359afc667 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:38:35 +0000 Subject: [PATCH 02/28] feat(derive): add parse_with_settings process entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet's `main` shape is `Cli::parse()` — help, version, and failures handled by the entry point that is the process — but the settings layer was only reachable through parse_from_with_settings, which hands errors back. parse_with_settings() is parse() with the layer beside the struct, and parse_from_argv_with_settings() is its argv0-stripping, view- and multicall-aware counterpart. The help/version/error rendering now lives in one shared __usage_exit_on_error helper instead of being owned by parse(). Co-Authored-By: Claude Fable 5 --- conformance/tests/derive_config.rs | 13 ++ derive/src/codegen.rs | 271 +++++++++++++++++++++-------- 2 files changed, 211 insertions(+), 73 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 67b25cb4f..d05037057 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -291,3 +291,16 @@ fn the_command_line_outranks_every_other_layer() { "and the registry agrees with the struct" ); } + +#[test] +fn the_argv_entry_strips_argv0_and_returns_the_layer() { + // What a fleet `main` calls: the process-shaped argv with the settings beside the + // struct. `parse_with_settings` wraps this with the same help/version/exit behaviour + // as `parse`, which a test cannot hold without leaving the process. + let argv = [OsStr::new("ex"), OsStr::new("-j"), OsStr::new("7")]; + let (cli, layer) = Ex::parse_from_argv_with_settings(&argv).expect("parses"); + assert_eq!(cli.jobs, Some(7)); + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(7))); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 21584d828..32f813741 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -273,6 +273,83 @@ pub fn emit(cli: &Cli) -> TokenStream { }, None => TokenStream::new(), }; + // The argv collection every process entry shares: the full refs for parsing, the + // view- or multicall-rewritten words for help routing, and the selected view. + // Before the preamble, which interpolates the intercept. + let (spec_endpoint, spec_endpoint_intercept) = spec_endpoint_fns(cli); + let parse_preamble = quote! { + let __usage_all: ::std::vec::Vec<::std::ffi::OsString> = + ::std::env::args_os().collect(); + let __usage_all_refs: ::std::vec::Vec<&::std::ffi::OsStr> = + __usage_all.iter().map(|a| a.as_os_str()).collect(); + // In the shared preamble rather than in `parse` alone: the point of the + // endpoint is that *any* usage binary answers `__usage_spec__`, so a CLI + // that resolves settings has to answer it too. + #spec_endpoint_intercept + let __usage_raw: ::std::vec::Vec<::std::ffi::OsString> = + if let ::std::option::Option::Some((__usage_argv0, __usage_words)) = + __usage_all_refs.split_first() + { + if let ::std::option::Option::Some(__usage_view) = + usage_argv::spec::view_for_program(&SPEC, __usage_argv0) + { + if __usage_words.first().is_some_and(|word| { + usage_argv::is_version_arg(SPEC.root.cmd, word) + }) + { + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect() + } else { + __usage_view + .root + .split_ascii_whitespace() + .map(::std::ffi::OsString::from) + .chain( + __usage_words + .iter() + .map(|word| (*word).to_os_string()), + ) + .collect() + } + } else if SPEC.multicall { + let mut __usage_words: ::std::vec::Vec<::std::ffi::OsString> = + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect(); + if let ::std::option::Option::Some(__usage_word) = + __usage_argv0.to_str().and_then(|s| { + usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) + }) + { + __usage_words.insert( + 0, + ::std::ffi::OsString::from(__usage_word), + ); + } + __usage_words + } else { + __usage_words + .iter() + .map(|word| (*word).to_os_string()) + .collect() + } + } else { + ::std::vec::Vec::new() + }; + let __usage_argv: ::std::vec::Vec<&::std::ffi::OsStr> = + __usage_raw.iter().map(|a| a.as_os_str()).collect(); + let __usage_selected_view = __usage_all + .first() + .and_then(|argv0| usage_argv::spec::view_for_program(&SPEC, argv0)); + // This is the entry point that *is* the process — it already exits for a help + // request — so it answers a failure the way a command-line program does: + // the message on stderr, and a non-zero status. `parse_from` hands the error + // back instead, for a library embedding this that wants to decide. + }; + let parts = settings(cli); // Only the layer calls it, so a root that has children and no settings of its own emits // neither: the guard below is what speaks for that case. @@ -337,7 +414,6 @@ pub fn emit(cli: &Cli) -> TokenStream { let post = post_binding(cli); let (completion, completion_intercept) = completion_fns(cli); let spec_extra = spec_extra_append(cli); - let (spec_endpoint, spec_endpoint_intercept) = spec_endpoint_fns(cli); // `field: local` rather than the shorthand, because the locals are prefixed: // a field called `text` or `parser` would otherwise collide with something the // generated code needs. @@ -377,6 +453,93 @@ pub fn emit(cli: &Cli) -> TokenStream { built_value(cli, view_sub_build, &view_field_finals) }; + // The process entry with a settings layer beside it, for a CLI that binds settings and + // resolves them at startup — the fleet's `main` shape. Same help, version, and error + // behaviour as `parse`, through the same shared renderer. + let settings_parse_entry = settings_bindings.as_ref().map(|_| { + quote! { + /// Parse a full argv, including the program name, and the settings it + /// gave values for. + /// + /// The `parse_from_argv` counterpart of [`Self::parse_from_with_settings`]: + /// argv0 is stripped, views and multicall applets are honoured, and the + /// layer is built from what the parser saw. + pub fn parse_from_argv_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + let ::std::option::Option::Some((__usage_argv0, __usage_words)) = + argv.split_first() + else { + return Self::parse_from_with_settings(&[]); + }; + if let ::std::option::Option::Some(__usage_view) = + usage_argv::spec::view_for_program(&SPEC, __usage_argv0) + { + let mut __usage_rewritten = ::std::vec::Vec::with_capacity( + __usage_words.len() + + __usage_view.root.split_ascii_whitespace().count(), + ); + __usage_rewritten.extend( + __usage_view.root + .split_ascii_whitespace() + .map(::std::ffi::OsStr::new), + ); + __usage_rewritten.extend_from_slice(__usage_words); + #defaults + read_argv_into_view( + Self::command(), + &__usage_rewritten, + &mut partial, + ::std::option::Option::Some(__usage_view), + )?; + // Before `check`, for the same reason `parse_from_with_settings` is: + // the layer holds what argv gave, not what the environment filled. + let __usage_settings = settings_layer(&partial); + check_with_view( + &mut partial, + ::std::option::Option::Some(__usage_view), + )?; + return ::std::result::Result::Ok((#built_for_view, __usage_settings)); + } + if SPEC.multicall { + if let ::std::option::Option::Some(__usage_word) = + __usage_argv0.to_str().and_then(|s| { + usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) + }) + { + let mut __usage_rewritten = + ::std::vec::Vec::with_capacity(argv.len()); + __usage_rewritten.push(::std::ffi::OsStr::new(__usage_word)); + __usage_rewritten.extend_from_slice(__usage_words); + return Self::parse_from_with_settings(&__usage_rewritten); + } + } + Self::parse_from_with_settings(__usage_words) + } + + /// Parse the process's own arguments, and the settings they gave values for. + /// + /// [`Self::parse`] with the layer beside the struct: it prints a help page or + /// a version and leaves, and renders a failure to stderr with exit 2. + pub fn parse_with_settings() -> (Self, #config::CliLayer) { + #completion_intercept + #parse_preamble + match Self::parse_from_argv_with_settings(&__usage_all_refs) { + ::std::result::Result::Ok(__usage_parsed) => __usage_parsed, + ::std::result::Result::Err(e) => Self::__usage_exit_on_error( + e, + &__usage_all_refs, + &__usage_argv, + __usage_selected_view, + ), + } + } + } + }); + let min_usage_version = option_str(cli.min_usage_version.as_deref()); let views: Vec<_> = cli .views @@ -1057,73 +1220,7 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn parse() -> Self { #completion_intercept - let __usage_all: ::std::vec::Vec<::std::ffi::OsString> = - ::std::env::args_os().collect(); - let __usage_all_refs: ::std::vec::Vec<&::std::ffi::OsStr> = - __usage_all.iter().map(|a| a.as_os_str()).collect(); - #spec_endpoint_intercept - let __usage_raw: ::std::vec::Vec<::std::ffi::OsString> = - if let ::std::option::Option::Some((__usage_argv0, __usage_words)) = - __usage_all_refs.split_first() - { - if let ::std::option::Option::Some(__usage_view) = - usage_argv::spec::view_for_program(&SPEC, __usage_argv0) - { - if __usage_words.first().is_some_and(|word| { - usage_argv::is_version_arg(SPEC.root.cmd, word) - }) - { - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect() - } else { - __usage_view - .root - .split_ascii_whitespace() - .map(::std::ffi::OsString::from) - .chain( - __usage_words - .iter() - .map(|word| (*word).to_os_string()), - ) - .collect() - } - } else if SPEC.multicall { - let mut __usage_words: ::std::vec::Vec<::std::ffi::OsString> = - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect(); - if let ::std::option::Option::Some(__usage_word) = - __usage_argv0.to_str().and_then(|s| { - usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) - }) - { - __usage_words.insert( - 0, - ::std::ffi::OsString::from(__usage_word), - ); - } - __usage_words - } else { - __usage_words - .iter() - .map(|word| (*word).to_os_string()) - .collect() - } - } else { - ::std::vec::Vec::new() - }; - let __usage_argv: ::std::vec::Vec<&::std::ffi::OsStr> = - __usage_raw.iter().map(|a| a.as_os_str()).collect(); - let __usage_selected_view = __usage_all - .first() - .and_then(|argv0| usage_argv::spec::view_for_program(&SPEC, argv0)); - // This is the entry point that *is* the process — it already exits for a help - // request — so it answers a failure the way a command-line program does: - // the message on stderr, and a non-zero status. `parse_from` hands the error - // back instead, for a library embedding this that wants to decide. + #parse_preamble // Collected here rather than printed where they are found: `parse` is the // entry point that *is* the process, so it is the one that may write to // stderr. A failure prints nothing about deprecations — the error is what @@ -1142,8 +1239,36 @@ pub fn emit(cli: &Cli) -> TokenStream { } parsed } + ::std::result::Result::Err(e) => Self::__usage_exit_on_error( + e, + &__usage_all_refs, + &__usage_argv, + __usage_selected_view, + ), + } + } + + #settings_parse_entry + + /// Render a parse failure the way the process entry does, and leave. + /// + /// This is reached only from an entry point that *is* the process — it + /// already exits for a help request — so it answers a failure the way a + /// command-line program does: the message on stderr, and a non-zero status. + /// `parse_from` hands the error back instead, for a library embedding this + /// that wants to decide. One copy, shared by `parse` and by + /// `parse_with_settings` when settings are bound. + fn __usage_exit_on_error<'v>( + __usage_error: usage_argv::Error<'static, 'v>, + __usage_all_refs: &[&'v ::std::ffi::OsStr], + __usage_argv: &[&'v ::std::ffi::OsStr], + __usage_selected_view: ::std::option::Option< + &usage_argv::spec::ViewMeta<'static>, + >, + ) -> ! { + match __usage_error { // Not failures: someone asked a question, and the answer goes to stdout. - ::std::result::Result::Err(usage_argv::Error::Version { long }) => { + usage_argv::Error::Version { long } => { #runtime_program_for_version let __usage_bin = __usage_selected_view .map(|view| view.bin) @@ -1156,7 +1281,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::println!("{__usage_bin} {__usage_version}"); usage_argv::__usage_process_exit(0); } - ::std::result::Result::Err(usage_argv::Error::Help { cmd, long }) => { + usage_argv::Error::Help { cmd, long } => { #effective_spec let __usage_want = if long { usage_argv::help::Page::Long @@ -1173,7 +1298,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(0), } } - ::std::result::Result::Err(usage_argv::Error::MissingArgsHelp { cmd }) => { + usage_argv::Error::MissingArgsHelp { cmd } => { #effective_spec let __usage_want = usage_argv::help::Page::Short; #render_page_stderr @@ -1185,7 +1310,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(2), } } - ::std::result::Result::Err(usage_argv::Error::HelpAll { cmd }) => { + usage_argv::Error::HelpAll { cmd } => { #effective_spec let __usage_want = usage_argv::help::Page::All; #render_page @@ -1197,7 +1322,7 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::option::Option::None => usage_argv::__usage_process_exit(0), } } - ::std::result::Result::Err(e) => { + e => { #effective_spec let __usage_failure = match __usage_selected_view { ::std::option::Option::Some(view) => { From 365ecad3f0fc36458d96450b18220acfdea34f17 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:20:24 +0000 Subject: [PATCH 03/28] fix(derive): include the negate spelling in view selector matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A global flag whose only spelling is its negation — clap's SetFalse, tak's --no-credit — produced an empty selector list in view_field_active, and an empty matches! does not parse. The negation is a spelling of the same flag, so it belongs in both the runtime selector match and its compile-time counterpart; a flag that somehow has no spellings now answers false instead of failing to expand. Found by tak's settings adoption. Co-Authored-By: Claude Fable 5 --- conformance/tests/derive_config.rs | 51 ++++++++++++++++++++++++++++++ derive/src/codegen.rs | 21 +++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index d05037057..e8a2b40fa 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -304,3 +304,54 @@ fn the_argv_entry_strips_argv0_and_returns_the_layer() { resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(7))); } + +/// A tool whose only spelling for a bound flag is the negation. +/// +/// tak's `--no-credit`: the setting defaults to true and the command line can only turn it +/// off. The regression here was in view-aware codegen — a global flag's selector list was +/// built from longs and shorts alone, so a negate-only flag produced an empty `matches!` +/// that did not parse. +#[derive(Cli, Debug)] +#[usage(bin = "negonly")] +struct NegOnly { + /// Leave the credit line off + // clap's SetFalse spelling: the single long becomes the negative spelling, so this + // flag's *only* selector is the negation. + #[arg(long = "no-credit", action = clap::ArgAction::SetFalse, global = true)] + #[usage(default = "true", setting = "credit")] + credit: bool, + + #[usage(subcommand)] + command: Option, +} + +#[derive(usage_derive::Subcommands, Debug)] +enum NegOnlyCommands { + /// Run it + Run, +} + +#[test] +fn a_negate_only_global_flag_still_binds_its_setting() { + static PROPS: &[usage_config::PropMeta] = &[usage_config::PropMeta { + default: Some(Const::Bool(true)), + cli: &["--no-credit"], + ..usage_config::PropMeta::new("credit", Ty::Bool) + }]; + const REGISTRY: usage_config::Registry = usage_config::Registry::new(PROPS); + assert_eq!( + REGISTRY.drift(NegOnly::SETTINGS_BINDINGS), + Vec::::new() + ); + + let argv = [OsStr::new("--no-credit")]; + let (cli, layer) = NegOnly::parse_from_with_settings(&argv).expect("parses"); + assert!(!cli.credit); + let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(resolved.get_key("credit"), Some(&Value::Bool(false))); + + // And left off, the default stands: absence is not a value. + let (cli, layer) = NegOnly::parse_from_with_settings(&[]).expect("parses"); + assert!(cli.credit); + assert!(layer.is_empty()); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 32f813741..0eb5bb55d 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2918,6 +2918,7 @@ fn view_field_active(field: &Field) -> TokenStream { longs, hidden_longs, shorts, + negate, global: true, .. } = &field.kind @@ -2927,17 +2928,27 @@ fn view_field_active(field: &Field) -> TokenStream { let long_selectors = longs .iter() .chain(hidden_longs) + // The negation is a spelling of the same flag, and for a negative-only flag it is + // the *only* one: leaving it out made the selector list empty, and an empty + // `matches!` does not parse. + .chain(negate.iter()) .map(|long| format!("--{long}")); let short_selectors = shorts.iter().map(|short| format!("-{short}")); let selectors: Vec = long_selectors.chain(short_selectors).collect(); + let named = if selectors.is_empty() { + quote!(false) + } else { + quote! { + __usage_view.globals.iter().any(|__usage_selector| { + matches!(*__usage_selector, #(#selectors)|*) + }) + } + }; quote! { match __usage_view { ::std::option::Option::None => true, ::std::option::Option::Some(__usage_view) => { - __usage_view.all_globals - || __usage_view.globals.iter().any(|__usage_selector| { - matches!(*__usage_selector, #(#selectors)|*) - }) + __usage_view.all_globals || #named } } } @@ -2952,6 +2963,7 @@ fn field_active_in_view(field: &Field, view: &ViewDecl) -> bool { longs, hidden_longs, shorts, + negate, global: true, .. } = &field.kind @@ -2962,6 +2974,7 @@ fn field_active_in_view(field: &Field, view: &ViewDecl) -> bool { || longs .iter() .chain(hidden_longs) + .chain(negate.iter()) .map(|long| format!("--{long}")) .chain(shorts.iter().map(|short| format!("-{short}"))) .any(|selector| view.globals.contains(&selector)) From 7f336482871e35577012f42eda9ae2ed89970440 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:26:15 +0000 Subject: [PATCH 04/28] fix(help): do not repeat a negate-only flag's name before its spelling A flag whose only spelling is its negation is named after that spelling, so the `name:` prefix help adds for a name the forms do not imply rendered `no-credit: --no-credit`. Found by tak's settings adoption, where the setting-bound flag is `SetFalse`. Co-Authored-By: Claude Fable 5 --- argv/src/help.rs | 5 ++++- conformance/tests/derive_config.rs | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 31927c84a..3eb508ddd 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -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), }; if !implied_matches { let _ = write!(out, "{}:", flag.name); diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index e8a2b40fa..92289025e 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -355,3 +355,13 @@ fn a_negate_only_global_flag_still_binds_its_setting() { assert!(cli.credit); assert!(layer.is_empty()); } + +#[test] +fn a_negate_only_flag_is_not_named_twice_in_help() { + // `no-credit: --no-credit` — the declared name of a `SetFalse` flag is its negation, so + // the `name:` prefix help adds for a name the forms do not imply only repeats it. + let page = + usage_argv::help::render(NegOnly::spec(), NegOnly::spec().root.cmd, false).expect("page"); + assert!(page.contains("--no-credit"), "{page}"); + assert!(!page.contains("no-credit: --no-credit"), "{page}"); +} From 52d40ef76da92cd711af718e3f78a61d10b47a93 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:32:18 +0000 Subject: [PATCH 05/28] fix(docs): show a negate-only flag as the spelling a reader types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flag whose only form is its negation displayed as its identity — `color:`, so a docs heading read `### color:` for a flag you enter as `--color`. The spec still writes `flag color: negate=--color`, which is what keeps the identity; this is the usage *display* string, which help pages, markdown and manpages read. The clap-bridge test that asserted the old string is updated, since that string is exactly what changed. Co-Authored-By: Claude Fable 5 --- lib/src/spec/flag.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 3cb45cd57..52665cb63 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -839,7 +839,18 @@ impl SpecFlag { pub fn usage(&self) -> String { let mut parts = vec![]; let name = get_name_from_short_and_long(&self.short, &self.long).unwrap_or_default(); - if name != self.name { + // A flag whose only spelling is its negation — clap's `SetFalse`, tak's + // `--no-credit` — is named after that spelling, so the `name:` prefix would repeat + // it and the spelling a reader has to type would appear nowhere. + let negation_only = self.short.is_empty() + && self.long.is_empty() + && self + .negate + .as_deref() + .is_some_and(|negate| negate.trim_start_matches('-') == self.name); + if negation_only { + parts.push(self.negate.clone().unwrap_or_default()); + } else if name != self.name { parts.push(format!("{}:", self.name)); } if let Some(short) = self.short.first() { @@ -1791,8 +1802,12 @@ mod tests { assert!(color.long.is_empty()); assert!(color.short.is_empty()); assert_eq!(color.negate.as_deref(), Some("--color")); - assert_eq!(color.usage(), "color:"); - assert_eq!(color.usage, "color:"); + // Displayed as the spelling a reader has to type. `color:` names the flag's + // identity, which the *spec* keeps below, but as a usage string it showed a reader + // nothing they could enter — a docs heading read `### color:` for a flag whose only + // form is `--color`. + assert_eq!(color.usage(), "--color"); + assert_eq!(color.usage, "--color"); let rendered = spec.to_string(); assert!( From 82fe9052433d4abf4f995f792dd229b739d84b33 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:38:50 +0000 Subject: [PATCH 06/28] fix(docs): give a negate-only flag a reference heading The reference builds a flag's heading from its long and short forms, so a flag whose only spelling is its negation got an empty one: `### \`\``. It now falls back to the negation, which is the spelling a reader types. Co-Authored-By: Claude Fable 5 --- lib/src/docs/models.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index fadd24891..17eed1172 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -823,6 +823,13 @@ fn reference_usage(flag: &crate::SpecFlag) -> String { .map(|long| format!("--{long}")), ) .collect(); + // A flag whose only spelling is its negation — clap's `SetFalse`, tak's `--no-credit` — + // has no long or short form to list, so without this the reference heading was empty. + if forms.is_empty() { + if let Some(negate) = &flag.negate { + forms.push(negate.clone()); + } + } if flag.usage.trim().starts_with(&format!("{}:", flag.name)) { forms.insert(0, format!("{}:", flag.name)); } From 9b2e76c61b76a902838b36e3963be8cd9c983bad Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:26 +0000 Subject: [PATCH 07/28] fix(derive): refuse a setting declaration that only fails at run time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways `usage::Config` accepted a declaration it should not have. `prop_meta` wrote `optional: Some(true)` for an `Option` field and for `default_fn`, and nothing at all otherwise — so a plain non-`Option` field with no default reached the registry with `optional` unset. The documented inference for an unset value is "no default means optional", which is the opposite of what `read` does: it reports the key as missing. Docs, the JSON schema and the `config_keys` completer all read the registry, so the registry now states the contract instead of letting each consumer guess. A field with a declared default still says nothing, because there the inference agrees. `Ty::admits` grouped `Int | Uint | Float` against an integer constant, so `#[usage(default = -1)]` on a `u64` compiled. The resolver seeds a declared default with no coercion and `u64::from_value` refuses a negative value, so every `read` failed with a type error the author could do nothing about. A negative `choice` on a `uint` is the same mistake: a value nothing can supply. The `flatten` conflict list had gone stale. `#[usage(flatten, alias = "task")]` parsed the alias into a prop that the flatten branch then dropped, with no message — and the same for `deprecated_env`, `example`, `default_note`, `optional`, `deprecated`, its two version attributes, and `since`. A doc comment stays allowed, because it describes the group. Also adds the unit tests the `config` root attribute never had, matching what `settings` and `min_usage_version` already have. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 49 +++++++++- derive/src/config.rs | 145 ++++++++++++++++++++++++++++- derive/src/model.rs | 50 ++++++++++ 3 files changed, 236 insertions(+), 8 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 92289025e..9eabfc5c5 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -1,9 +1,8 @@ //! A settings struct as its own declaration. //! //! The claim under test: `#[derive(usage::Config)]` on the struct a CLI already holds its -//! settings in generates the same registry `usage-config-build` would have generated from a -//! spec, a reader that fills the struct from a resolution, and a `config` block the spec -//! parser reads back — so the registry, the reader, and the documentation cannot drift from +//! settings in generates the registry the resolver reads, a reader that fills the struct from +//! a resolution, and a `config` block the spec parser reads back — so the registry, the reader, and the documentation cannot drift from //! the struct or from each other. That drift is the fleet's `settings.toml` + `build.rs` //! pattern's whole failure mode: three descriptions of every setting, kept in step by hand. @@ -243,6 +242,50 @@ fn the_emitted_config_block_is_the_spec_grammar() { assert_eq!(cache_dir.optional, Some(true)); } +/// A setting nothing declares a value for: not an `Option`, and no default. +#[derive(Config, Debug, PartialEq)] +struct Required { + /// The token every request needs + #[usage(env = "EX_TOKEN")] + token: String, +} + +#[test] +fn a_setting_nothing_defaults_says_it_is_required() { + // Leaving `optional` unset invited the reader's own inference — "no default means + // optional" — while `read` reports the key as missing. Docs, the JSON schema and the + // `config_keys` completer all read the registry, so the registry has to state what + // `read` will do rather than let each consumer guess. + assert_eq!(Required::SETTINGS_PROPS[0].optional, Some(false)); + assert_eq!( + Settings::SETTINGS_PROPS[0].optional, + None, + "a setting with a declared default always has one, and inference agrees" + ); + + // Through the spec too, because that is the copy a docs page reads. + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Required::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + assert_eq!( + spec.config.props.get("token").expect("declared").optional, + Some(false) + ); + + // And the reader says the same thing: nothing supplied it, so it is missing. + let resolved = resolve(Required::SETTINGS_REGISTRY, Layers::new()).expect("resolves"); + Required::read(&resolved).expect_err("a required setting nothing supplied"); + + let env = EnvLayer::new([("EX_TOKEN".to_string(), "abc".to_string())]); + let resolved = + resolve(Required::SETTINGS_REGISTRY, Layers::new().then(&env)).expect("resolves"); + assert_eq!( + Required::read(&resolved).expect("supplied"), + Required { + token: "abc".to_string() + } + ); +} + /// A tool with settings, declaring which type holds them. #[derive(Cli, Debug)] #[usage(bin = "ex", config = Settings)] diff --git a/derive/src/config.rs b/derive/src/config.rs index 3ae6acd8a..f0c4938f9 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -202,7 +202,12 @@ impl Ty { match (self, value) { (Self::Any, _) => true, (Self::Bool, Const::Bool(_)) => true, - (Self::Int | Self::Uint | Self::Float, Const::Int(_)) => true, + (Self::Int | Self::Float, Const::Int(_)) => true, + // A `uint` names a non-negative number, and the resolver seeds a declared default + // with no coercion, so `default = -1` on a `u64` field would compile and then fail + // every `read` with a type error the author cannot fix at run time. The span is + // here, so refuse it here. + (Self::Uint, Const::Int(i)) => *i >= 0, (Self::Float, Const::Float(_)) => true, (Self::String | Self::Path | Self::Url | Self::Duration, Const::Str(_)) => true, // The coercion rule the registry itself follows: a string type reads a bare @@ -467,6 +472,15 @@ impl Field { (prop.hide, "hide"), (key_attr.is_some(), "key"), (ty_attr.is_some(), "ty"), + (!prop.deprecated_envs.is_empty(), "deprecated_env"), + (!prop.aliases.is_empty(), "alias"), + (!prop.examples.is_empty(), "example"), + (prop.default_note.is_some(), "default_note"), + (explicit_optional.is_some(), "optional"), + (prop.deprecated.is_some(), "deprecated"), + (prop.deprecated_warn_at.is_some(), "deprecated_warn_at"), + (prop.deprecated_remove_at.is_some(), "deprecated_remove_at"), + (prop.since.is_some(), "since"), ]; if let Some((_, what)) = described.iter().find(|(given, _)| *given) { return Err(syn::Error::new( @@ -925,8 +939,8 @@ pub fn emit(config: &Config) -> TokenStream { impl #ident { /// Every setting this struct declares, one entry per field, flattened groups - /// included. What `usage-config-build` would have generated, generated from - /// the struct instead — there is no second declaration to keep in step. + /// included. The registry a `build.rs` used to generate, generated from the + /// struct instead — there is no second declaration to keep in step. pub const SETTINGS_PROPS: &'static [#cfg::PropMeta] = ::PROPS; @@ -1040,8 +1054,21 @@ fn prop_meta(prop: &Prop, cfg: &TokenStream) -> TokenStream { let aliases = &prop.aliases; fields.push(quote!(aliases: &[#(#aliases),*])); } - if prop.optional_field || prop.default_fn.is_some() { - fields.push(quote!(optional: ::std::option::Option::Some(true))); + // The optionality contract, stated rather than inferred. A registry that leaves this + // unset invites the reader's inference — "no default means optional" — and a plain + // non-`Option` field with no default is the one case where that inference disagrees with + // `read`, which reports the key as missing. Docs, the JSON schema and the completers all + // read the registry, so they have to be told what `read` will do. + let optional = if prop.optional_field || prop.default_fn.is_some() { + Some(true) + } else if prop.default.is_none() { + Some(false) + } else { + // A declared default always resolves, and inference already agrees with that. + None + }; + if let Some(optional) = optional { + fields.push(quote!(optional: ::std::option::Option::Some(#optional))); } if let Some(help) = &prop.help { fields.push(quote!(help: ::std::option::Option::Some(#help))); @@ -1072,3 +1099,111 @@ fn prop_meta(prop: &Prop, cfg: &TokenStream) -> TokenStream { } } } + +#[cfg(test)] +mod tests { + use super::Config; + + /// The message a bad declaration produces, which is the part worth asserting on: it is + /// what the author sees, and the point of checking here is that they see it *here* rather + /// than at the first `read` in production. + fn rejection(body: &str) -> String { + match Config::from_input(&syn::parse_str::(body).expect("valid Rust")) { + Ok(_) => panic!("should not have compiled"), + Err(e) => e.to_string(), + } + } + + fn accepted(body: &str) { + Config::from_input(&syn::parse_str::(body).expect("valid Rust")) + .unwrap_or_else(|e| panic!("should have compiled: {e}")); + } + + #[test] + fn a_uint_refuses_a_negative_default_or_choice() { + // The resolver seeds a declared default with no coercion, and `u64::from_value` + // refuses a negative value — so this compiled and then failed every `read` with a type + // error the author could do nothing about at run time. A choice is the same shape of + // mistake: a value nothing can ever supply. + let err = rejection( + r#" + struct Settings { + #[usage(default = -1)] + jobs: u64, + } + "#, + ); + assert!( + err.contains("the default is not a value `uint` can hold"), + "unhelpful: {err}" + ); + + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, -1))] + jobs: u64, + } + "#, + ); + assert!( + err.contains("a choice is not a value `uint` can hold"), + "unhelpful: {err}" + ); + + // A signed field still takes one, which is the whole difference. + accepted( + r#" + struct Settings { + #[usage(default = -1)] + offset: i64, + } + "#, + ); + } + + #[test] + fn every_setting_attribute_on_a_flattened_field_is_refused() { + // `flatten` says the field is a group of settings, so anything describing *one* + // setting was parsed into a prop that the flatten branch then dropped. The checked + // list had grown stale: `#[usage(flatten, alias = "task")]` compiled, and the alias + // simply did not exist. + for attribute in [ + r#"env = "EX_X""#, + r#"deprecated_env = "EX_OLD""#, + r#"alias = "other""#, + r#"example = "1""#, + r#"default_note = "note""#, + "optional = true", + r#"deprecated = "gone""#, + r#"deprecated_warn_at = "6.0.0""#, + r#"deprecated_remove_at = "7.0.0""#, + r#"since = "5.2.0""#, + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(flatten, {attribute})] + task: TaskSettings, + }} + "# + )); + assert!( + err.contains("describes a setting, and `flatten` says this field is a group"), + "`{attribute}` was accepted on a flattened field: {err}" + ); + } + + // A doc comment is not one of them: it describes the group, and `help` is how the + // derive carries a doc comment. + accepted( + r#" + struct Settings { + /// The task settings + #[usage(flatten)] + task: TaskSettings, + } + "#, + ); + } +} diff --git a/derive/src/model.rs b/derive/src/model.rs index a4dfcb629..9e30eaa86 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -7035,6 +7035,56 @@ mod tests { ); } + #[test] + fn config_names_a_type_deriving_usage_config() { + // A path, because the attribute names a *type* whose `spec_kdl()` the emitted spec + // calls. A string looks close enough to the other metadata attributes to write by + // mistake, and it compiled to a `config` block that was silently never emitted. + let parsed = cli(r#" + #[usage(config = Settings)] + struct Ex { + #[usage(long)] + plain: bool, + } + "#) + .expect("parses"); + assert!(parsed.config.is_some(), "`config = Settings` was dropped"); + + let err = rejection( + r#" + #[usage(config = "Settings")] + struct Ex { + #[usage(long)] + plain: bool, + } + "#, + ); + assert!( + err.contains("names a type deriving `usage::Config`"), + "unhelpful: {err}" + ); + } + + #[test] + fn the_config_attribute_belongs_on_the_root() { + // One program, one `config` block. On a group it would have been parsed and never + // read, which is the silence the position check exists to replace. + let err = position_error( + r#" + #[usage(config = Settings)] + struct Ex { + #[usage(long)] + plain: bool, + } + "#, + false, + ); + assert!( + err.contains("`config` belongs on the root"), + "unhelpful: {err}" + ); + } + #[test] fn a_setting_is_allowed_wherever_a_flag_is() { // It used to be refused outside the root, because only the root generated a layer and one From 6cc3e50b14a6b58f0751e2d50bcf9bdf7309ef55 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:38 +0000 Subject: [PATCH 08/28] fix(config): report a value too wide for its field, and render only KDL a parser reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FromValue for f32` reached `f64` and then cast with `as`, so a `float` setting of `1e300` read into an `f32` field as `inf` and called it a successful read — an effective value nothing declared. The narrowing rule stated for the integers immediately below holds here now: a finite value that does not fit is reported. Rounding a value that does fit is ordinary precision loss and still allowed, and an infinity a layer actually supplied is still the value it supplied. `spec_kdl` is public and renders registries written by hand as well as derived ones, so its two guards belong in the renderer rather than in the callers. A `choice` node carries one value: a list rendered as `choice 1 2`, two arguments where one was declared, and a table as a bare `choice` with none. Both are skipped now. And KDL forbids a raw control character anywhere in a document, so `quoted` escapes the rest as `\u{…}` — a help string carrying an ANSI escape wrote a block no parser would read back. Co-Authored-By: Claude Opus 5 --- config/src/read.rs | 35 ++++++++++++++++- config/src/spec.rs | 93 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/config/src/read.rs b/config/src/read.rs index c19e34293..f4b022efb 100644 --- a/config/src/read.rs +++ b/config/src/read.rs @@ -252,7 +252,15 @@ impl FromValue for f64 { impl FromValue for f32 { fn from_value(value: &Value) -> Result { - f64::from_value(value).map(|f| f as Self) + let wide = f64::from_value(value)?; + let narrow = wide as Self; + // The rule the narrower integers below follow, held here too: a value that does not + // fit is reported rather than wrapped. Rounding a finite value is ordinary precision + // loss; turning `1e300` into `inf` is a value the declaration never named. + if narrow.is_infinite() && wide.is_finite() { + return Err(mismatch("a number that fits 32 bits", value)); + } + Ok(narrow) } } @@ -337,6 +345,31 @@ mod tests { use crate::ty::{Parser, Ty}; use crate::value::Const; + /// A value that does not fit the field it reads into is reported rather than wrapped. + /// + /// The narrower integers say so; `f32` reached `f64` and then cast with `as`, which turns + /// `1e300` into `inf` and calls it a successful read — a setting whose effective value + /// nothing declared. + #[test] + fn a_value_too_wide_for_the_field_is_reported_rather_than_wrapped() { + u8::from_value(&Value::Int(256)).expect_err("256 does not fit 8 bits"); + i8::from_value(&Value::Int(-129)).expect_err("-129 does not fit 8 bits"); + usize::from_value(&Value::Int(-1)).expect_err("-1 is not non-negative"); + assert_eq!(u8::from_value(&Value::Int(255)).expect("255 fits"), 255); + + f32::from_value(&Value::Float(1e300)).expect_err("1e300 is not an f32"); + f32::from_value(&Value::Float(-1e300)).expect_err("-1e300 is not an f32"); + // Rounding a value that does fit is ordinary precision loss, which is allowed. + assert_eq!( + f32::from_value(&Value::Float(0.1)).expect("0.1 fits"), + 0.1_f32 + ); + // An infinity a layer actually supplied is the value it supplied, not an overflow. + assert!(f32::from_value(&Value::Float(f64::INFINITY)) + .expect("an infinity reads as one") + .is_infinite()); + } + static PROPS: &[PropMeta] = &[ PropMeta { default: Some(Const::Int(4)), diff --git a/config/src/spec.rs b/config/src/spec.rs index f24a18f02..9a5b7fbe3 100644 --- a/config/src/spec.rs +++ b/config/src/spec.rs @@ -1,10 +1,9 @@ //! A registry written back out as the spec's `config` block. //! -//! `usage-config-build` reads a spec and generates a registry; a CLI that declares its -//! settings in code with `#[derive(usage::Config)]` goes the other way, and this is the other -//! way: the registry it derived, rendered as the `config { prop … }` block the spec grammar -//! defines, so docs, JSON schema, completions and every other spec consumer read declarations -//! made in Rust exactly as they read ones made in KDL. +//! A CLI declares its settings in code with `#[derive(usage::Config)]`, and this is the way +//! back out: the registry it derived, rendered as the `config { prop … }` block the spec +//! grammar defines, so docs, JSON schema, completions and every other spec consumer read +//! declarations made in Rust exactly as they read ones made in KDL. //! //! Written by hand rather than through a KDL library for the same reason the argv crate's //! spec writer is: this crate has no dependencies, and the grammar being emitted is the small @@ -120,10 +119,19 @@ fn write_prop(out: &mut String, meta: &PropMeta) -> std::fmt::Result { .collect(); children.push(format!("source {} {}", quoted(kind), keys.join(" "))); } - if !meta.choices.is_empty() { + // One `choice` node carries one value, so only a scalar belongs here. A registry written + // by hand can hold a list or a table; rendering one produced `choice 1 2` or a bare + // `choice`, neither of which the prop grammar can read back. + let choices: Vec = meta + .choices + .iter() + .filter(|choice| !matches!(choice, Const::List(_) | Const::Map(_))) + .map(|choice| const_kdl(*choice)) + .collect(); + if !choices.is_empty() { let mut block = String::from("choices {\n"); - for choice in meta.choices { - let _ = writeln!(block, " choice {}", const_kdl(*choice)); + for choice in choices { + let _ = writeln!(block, " choice {choice}"); } block.push_str(" }"); children.push(block); @@ -185,6 +193,12 @@ fn quoted(text: &str) -> String { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), + // KDL forbids a raw control character in a document at all, so the rest go through + // the escape it does spell. A `help` string that carried an ANSI escape wrote a + // block no parser would read back. + c if c.is_control() => { + let _ = write!(out, "\\u{{{:x}}}", c as u32); + } c => out.push(c), } } @@ -237,6 +251,30 @@ mod tests { envs: &["CI"], ..PropMeta::new("ci", Ty::Bool) }, + // The rest of the vocabulary, in one prop: where each of these lands — an entry on + // the `prop` node, or a child of it — is exactly what a golden string is for. + PropMeta { + optional: Some(true), + aliases: &["fail-fast.legacy", "failfast"], + examples: &["true", "false"], + deprecated: Some("use `stop-on-error`"), + deprecated_warn_at: Some("6.0.0"), + deprecated_remove_at: Some("7.0.0"), + since: Some("5.2.0"), + help: Some("Stop at the first failure"), + long_help: Some("Whether a failing job stops the rest."), + ..PropMeta::new("fail_fast", Ty::Option(&Ty::Bool)) + }, + // A `choice` node carries one value, and a registry written by hand can hold a + // list where one belongs. The scalar survives; the list is not something the prop + // grammar can spell, so it is left out rather than written as two arguments. + PropMeta { + choices: &[ + Const::Str("plain"), + Const::List(&[Const::Int(1), Const::Int(2)]), + ], + ..PropMeta::new("level", Ty::Any) + }, ]; let kdl = spec_kdl(PROPS); assert_eq!( @@ -265,8 +303,47 @@ mod tests { prop "ci" type="bool" scope="env" hide=#true { env "CI" } + prop "fail_fast" type="option" optional=#true deprecated="use `stop-on-error`" deprecated_warn_at="6.0.0" deprecated_remove_at="7.0.0" since="5.2.0" help="Stop at the first failure" long_help="Whether a failing job stops the rest." { + alias "fail-fast.legacy" "failfast" + example "true" + example "false" + } + prop "level" type="any" { + choices { + choice "plain" + } + } } "# ); } + + /// KDL forbids a raw control character anywhere in a document, so a value carrying one has + /// to go out as the escape KDL does spell — or the block this renders is one no parser, + /// including usage's own, will read back. + #[test] + fn a_control_character_in_a_value_is_escaped_rather_than_written() { + static PROPS: &[PropMeta] = &[PropMeta { + help: Some("plain\u{1b}[0m and \u{0}"), + ..PropMeta::new("color", Ty::Bool) + }]; + assert_eq!( + spec_kdl(PROPS), + "config {\n prop \"color\" type=\"bool\" help=\"plain\\u{1b}[0m and \\u{0}\"\n}\n" + ); + } + + /// A prop whose only choices are shapes the grammar cannot spell gets no `choices` block at + /// all, rather than one holding a node with no argument. + #[test] + fn choices_no_single_value_can_hold_leave_no_block_behind() { + static PROPS: &[PropMeta] = &[PropMeta { + choices: &[Const::Map(&[("a", Const::Int(1))])], + ..PropMeta::new("shape", Ty::Any) + }]; + assert_eq!( + spec_kdl(PROPS), + "config {\n prop \"shape\" type=\"any\"\n}\n" + ); + } } From 4ea13a10a3e0218d0a3008fea86a7d0066f7c8e6 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:38 +0000 Subject: [PATCH 09/28] fix(help): write a negate-only flag's spelling once, and without a leading space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flag whose only spelling is its negation — clap's `SetFalse`, tak's `--no-credit` — is named after that spelling, so the two recent fixes made both renderers write the negation itself instead of a `name:` prefix that would repeat it. Each then added it a second time. In `usage-argv`, `flag_usage_masked` correctly writes nothing, and `display_usage_masked` joined that empty string to the negation with a space: `" --no-credit"`, indented one column past every other row. In `usage-lib`'s docs models, `column_usage` appended ` / ` plus the negation unconditionally, so a generated page read `--color / --color`. A flag that has a positive spelling still lists both, which is what the separator is for. Co-Authored-By: Claude Opus 5 --- argv/src/help.rs | 28 ++++++++++++++++++++++++++++ lib/src/docs/models.rs | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 3eb508ddd..03c225079 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -1241,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}") } @@ -2788,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 { diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 17eed1172..edee0e32d 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -777,10 +777,15 @@ const SHORT_COL: usize = 4; /// The twin of `column_usage` in `usage-argv`'s `help` module; the two must agree, and the gate /// over mise's spec is what says they do. fn column_usage(flag: &crate::SpecFlag) -> String { - let rest = flag.negate.as_ref().map_or_else( - || flag.usage.trim().to_string(), - |negate| format!("{} / {}", flag.usage.trim(), negate.trim()), - ); + let usage = flag.usage.trim(); + let rest = match flag.negate.as_deref().map(str::trim) { + // `SpecFlag::usage` already writes the negation for a flag that has no other + // spelling — clap's `SetFalse`, tak's `--no-credit` — and appending it again rendered + // `--color / --color`. + Some(negate) if negate == usage => usage.to_string(), + Some(negate) => format!("{usage} / {negate}"), + None => usage.to_string(), + }; let Some(long) = flag.long.first() else { return rest; }; @@ -1066,3 +1071,29 @@ impl SpecExample { } } } + +#[cfg(test)] +mod tests { + /// A flag whose only spelling is its negation — clap's `SetFalse`, tak's `--no-credit` — + /// carries that spelling as its usage string already, and the flags column appended the + /// negation a second time: `--color / --color`. + #[test] + fn a_flag_spelled_only_as_its_negation_is_listed_once() { + let spec: crate::Spec = "flag \"color:\" negate=\"--color\"\n" + .parse() + .expect("a spec"); + assert_eq!(super::column_usage(&spec.cmd.flags[0]), "--color"); + } + + /// And a flag that has both keeps both, which is what the ` / ` is for. + #[test] + fn a_flag_with_a_positive_spelling_lists_its_negation_beside_it() { + let spec: crate::Spec = "flag \"--color\" negate=\"--no-color\"\n" + .parse() + .expect("a spec"); + assert_eq!( + super::column_usage(&spec.cmd.flags[0]), + " --color / --no-color" + ); + } +} From 24f0f2ce4554c71b648a6ee76383ddd8d616d469 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:50 +0000 Subject: [PATCH 10/28] docs(derive): link the settings guide rather than a private module `[config module](config)` pointed at `derive`'s private `config` module, so rustdoc emitted `private_intra_doc_links` and the link resolved to nothing on docs.rs. The field vocabulary it was reaching for is the published guide. Co-Authored-By: Claude Opus 5 --- derive/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 72b833c9d..f1c26fb1f 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -475,7 +475,8 @@ pub fn derive_subcommands(input: TokenStream) -> TokenStream { /// `prop` node would — `env`, `default`, `merge`, `scope`, `choices`, `source` bindings. /// The derive generates `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read(&Resolved)`, and /// `spec_kdl()`, so the registry, the reader, and the documentation cannot drift from the -/// struct or from each other. See the [config module](config) docs for the field vocabulary. +/// struct or from each other. The whole field vocabulary is in the guide: +/// . /// /// ```ignore /// #[derive(usage::Config)] From 1ff54de99cbcfaa5365506fe0c8ab6b9d48feb59 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:50 +0000 Subject: [PATCH 11/28] feat(config)!: remove the build-time codegen backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `usage-config-build` read a spec's `config` block at build time and generated the registry plus a typed `Settings` struct. With `#[derive(usage::Config)]` on the real struct, keeping it meant two generators emitting one registry shape, and a KDL-first adopter still holding a second description of every setting — the drift the derive exists to remove. The struct is the only declaration now; `Settings::spec_kdl()` renders the spec block from it, so docs, the JSON schema and the completers read declarations made in Rust exactly as they read KDL. The crate is published, so this drops it from the workspace, the MSRV matrix and the docs that offered it as the other direction. Its one test that outlived it moves to `usage-conformance`: `usage-lib` and `usage-config` each write a float their own way and have to agree, and conformance is the one crate that can see both — it was living in `config-build` only because that crate happened to depend on the two. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 3 +- Cargo.lock | 8 - Cargo.toml | 3 +- PLAN.md | 14 +- config-build/Cargo.toml | 31 - config-build/NOTICE.md | 1 - config-build/examples/gen.rs | 20 - config-build/src/emit.rs | 707 ------------------ config-build/src/lib.rs | 175 ----- config-build/src/settings.rs | 434 ----------- config-build/tests/fixtures/hk.usage.kdl | 81 -- .../tests/fixtures/split-settings.usage.kdl | 5 - config-build/tests/fixtures/split.usage.kdl | 5 - config-build/tests/generated.rs | 328 -------- config-build/tests/golden/settings.rs | 228 ------ config-build/tests/optional_aliases.rs | 70 -- config-build/tests/refusals.rs | 701 ----------------- config/Cargo.toml | 6 +- config/src/lib.rs | 8 +- config/src/registry.rs | 4 +- config/src/ty.rs | 4 +- conformance/src/config.rs | 5 +- conformance/tests/config_value_display.rs | 29 + corpus/config/README.md | 4 +- docs/rust/settings.md | 14 +- docs/spec/resolution.md | 14 +- lib/Cargo.toml | 2 +- lib/src/spec/config.rs | 3 +- 28 files changed, 74 insertions(+), 2833 deletions(-) delete mode 100644 config-build/Cargo.toml delete mode 120000 config-build/NOTICE.md delete mode 100644 config-build/examples/gen.rs delete mode 100644 config-build/src/emit.rs delete mode 100644 config-build/src/lib.rs delete mode 100644 config-build/src/settings.rs delete mode 100644 config-build/tests/fixtures/hk.usage.kdl delete mode 100644 config-build/tests/fixtures/split-settings.usage.kdl delete mode 100644 config-build/tests/fixtures/split.usage.kdl delete mode 100644 config-build/tests/generated.rs delete mode 100644 config-build/tests/golden/settings.rs delete mode 100644 config-build/tests/optional_aliases.rs delete mode 100644 config-build/tests/refusals.rs create mode 100644 conformance/tests/config_value_display.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ba63093e..0bd58b9b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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: diff --git a/Cargo.lock b/Cargo.lock index f5192860a..a30dc91bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2284,14 +2284,6 @@ dependencies = [ "yaml_serde", ] -[[package]] -name = "usage-config-build" -version = "5.1.0" -dependencies = [ - "usage-config", - "usage-lib", -] - [[package]] name = "usage-conformance" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 63b1d3c4e..3eef60b39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "argv", "config", - "config-build", "derive", "test", "usage-rs", @@ -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. diff --git a/PLAN.md b/PLAN.md index 5527f7aaf..3fac11270 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 — @@ -1845,8 +1845,9 @@ Where they differ is instructive, because it is mostly _drift_: 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`. A CLI that wants layer-at-a-time can still - write the block in KDL and use `usage-config-build`, which is unchanged. + `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 @@ -1899,9 +1900,10 @@ Where they differ is instructive, because it is mostly _drift_: 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). A later - incremental adopter still has the KDL + `usage-config-build` path, which wraps - nothing and owns nothing it did not generate. + 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 diff --git a/config-build/Cargo.toml b/config-build/Cargo.toml deleted file mode 100644 index d7ca7e2f4..000000000 --- a/config-build/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "usage-config-build" -description = "Generates a usage-config settings registry from a usage spec, at build time" -version = "5.1.0" -edition = "2021" -rust-version = "1.95" -homepage = { workspace = true } -documentation = { workspace = true } -repository = { workspace = true } -authors = { workspace = true } -license = { workspace = true } - -# Runs in a build script, so its dependencies cost an adopter's *build* and never ship: the KDL -# parser lives here and `usage-config` carries none of it. -[dependencies] -# No features: all this needs is `Spec::parse_file` and the `config` sub-model. `docs` would add -# tera and roff to every adopter's build script to render markdown nobody asked for. -usage-lib = { workspace = true } -# For the *rules*, not for the generated code: a default has to be held to the same choices, and read -# by the same coercion, that the runtime will apply — and a second implementation of those rules here -# is exactly the drift this crate removes elsewhere. It carries no dependencies of its own. -usage-config = { workspace = true } - -# The generated code is compiled and run in the tests, which is the only way to know it is code -# rather than plausible text. -[dev-dependencies] -usage-config = { workspace = true, features = ["toml"] } - -[package.metadata.release] -shared-version = true -release = true diff --git a/config-build/NOTICE.md b/config-build/NOTICE.md deleted file mode 120000 index dbac6d24b..000000000 --- a/config-build/NOTICE.md +++ /dev/null @@ -1 +0,0 @@ -../NOTICE.md \ No newline at end of file diff --git a/config-build/examples/gen.rs b/config-build/examples/gen.rs deleted file mode 100644 index f3355b28a..000000000 --- a/config-build/examples/gen.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Regenerates the checked-in registry the tests compile. -//! -//! ```sh -//! cargo run -p usage-config-build --example gen > config-build/tests/golden/settings.rs -//! ``` -//! -//! An example rather than a build script, because the point of checking the file in is that a -//! human reads the diff: generated code that nobody looks at is where a generator's mistakes live. - -const SPEC: &str = "config-build/tests/fixtures/hk.usage.kdl"; - -fn main() { - match usage_config_build::source(SPEC) { - Ok(source) => print!("{source}"), - Err(err) => { - eprintln!("{SPEC}: {err}"); - std::process::exit(1); - } - } -} diff --git a/config-build/src/emit.rs b/config-build/src/emit.rs deleted file mode 100644 index 8fea376bd..000000000 --- a/config-build/src/emit.rs +++ /dev/null @@ -1,707 +0,0 @@ -//! The spec's settings written out as `const` Rust. -//! -//! Two rules run through all of it. Nothing is guessed: a declaration this crate cannot represent -//! faithfully is refused rather than approximated, because an approximation compiles and then -//! misbehaves at run time in the author's users' hands. And every problem is collected, because an -//! author fixing a registry wants the list rather than one round trip per mistake. - -use std::collections::BTreeMap; -use std::fmt::Write as _; - -use usage::spec::config::{ - SpecConfig, SpecConfigMerge, SpecConfigProp, SpecConfigScope, SpecConfigValue, -}; -use usage::spec::config_type::{Base, SpecConfigType}; - -/// The generated registry, or everything wrong with it. -pub(crate) fn registry(config: &SpecConfig, name: &str) -> Result> { - let mut problems = Vec::new(); - let props: Vec<(&String, &SpecConfigProp)> = config.props.iter().collect(); - - check_keys(&props, &mut problems); - check_aliases(&props, &mut problems); - check_renames(&props, &mut problems); - let idents = identifiers(&props, &mut problems); - - let mut body = String::new(); - for (key, prop) in &props { - let _ = writeln!(body, "{}", prop_meta(key, prop, &mut problems)); - } - - let settings = crate::settings::settings(&props, &idents, &mut problems); - - if !problems.is_empty() { - return Err(problems); - } - - let mut out = String::new(); - // The name is a file name, or whatever a caller passed to `source_of_spec`, and it goes into a - // *line* comment: a newline in it ends that comment and everything after it reads as code. The - // same rule the keys in this file follow, and the same reason. - let name = one_line(name); - let _ = writeln!( - out, - "// @generated by usage-config-build from `{name}`. Do not edit.\n\ - //\n\ - // Every setting this CLI has, as consts: there is no second declaration of a setting to\n\ - // keep in step with this one, which is the drift this file exists to remove." - ); - let _ = writeln!( - out, - "\npub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[\n{body}];" - ); - let _ = writeln!( - out, - "\npub const SETTINGS_REGISTRY: ::usage_config::Registry =\n \ - ::usage_config::Registry::new(SETTINGS_PROPS);" - ); - - // Ids as consts, because a `PropId` *is* the index into the table above — so generated code - // reads a setting without looking its name up, and a typo in a key is a compile error rather - // than a `None` at run time. - let _ = writeln!( - out, - "\n/// Each setting's id, which is its position in [`SETTINGS_PROPS`].\n\ - pub mod prop {{\n \ - use ::usage_config::PropId;\n" - ); - for (index, ((key, prop), ident)) in props.iter().zip(&idents).enumerate() { - // Both through `one_line`: a doc comment is a line, and a key can hold a newline as easily - // as help can — after which the rest of the key is code. - let key = one_line(key); - if let Some(help) = prop.help.as_deref() { - let _ = writeln!(out, " /// `{key}` — {}", one_line(help)); - } else { - let _ = writeln!(out, " /// `{key}`"); - } - let _ = writeln!(out, " pub const {ident}: PropId = PropId({index});"); - } - let _ = writeln!(out, "}}"); - out.push_str(&settings); - Ok(out) -} - -/// One `PropMeta`, as the struct-update form: a field this crate does not set is whatever -/// `PropMeta::new` says it is, so a field added to the runtime does not break generated code. -fn prop_meta(key: &str, prop: &SpecConfigProp, problems: &mut Vec) -> String { - let mut fields = String::new(); - if let Some(default) = default_const(key, prop, problems) { - let _ = writeln!(fields, " default: Some({default}),"); - } - match prop.merge { - SpecConfigMerge::Replace => {} - SpecConfigMerge::Union => { - let _ = writeln!(fields, " merge: ::usage_config::Merge::Union,"); - } - SpecConfigMerge::Deep => { - let _ = writeln!(fields, " merge: ::usage_config::Merge::Deep,"); - } - } - match prop.scope { - SpecConfigScope::Any => {} - SpecConfigScope::Global => { - let _ = writeln!(fields, " scope: ::usage_config::Scope::Global,"); - } - SpecConfigScope::Env => { - let _ = writeln!(fields, " scope: ::usage_config::Scope::Env,"); - } - } - if let Some(parse) = prop.parse.as_deref() { - match parser(parse) { - Some(variant) => { - let _ = writeln!( - fields, - " parse: Some(::usage_config::Parser::{variant})," - ); - } - // Not carried as an extension and not dropped: a spec that names a parser nothing - // implements would resolve values by a rule that does not exist, and every layer - // handing over text would produce one item where the author wrote several. - None => problems.push(format!( - "`{key}` names the parser `{parse}`, which is not one of: {}", - PARSERS - .iter() - .map(|(name, _)| *name) - .collect::>() - .join(", ") - )), - } - } - let envs = environment(prop); - if !envs.is_empty() { - let list: Vec = envs.iter().map(|env| rust_str(env)).collect(); - let _ = writeln!(fields, " envs: &[{}],", list.join(", ")); - } - if !prop.deprecated_envs.is_empty() { - let list: Vec = prop - .deprecated_envs - .iter() - .map(|env| rust_str(env)) - .collect(); - let _ = writeln!(fields, " deprecated_envs: &[{}],", list.join(", ")); - } - if !prop.cli.is_empty() { - let flags: Vec = prop.cli.iter().map(|flag| rust_str(flag)).collect(); - let _ = writeln!(fields, " cli: &[{}],", flags.join(", ")); - } - if !prop.bindings.is_empty() { - let mut pairs = Vec::new(); - for (kind, keys) in &prop.bindings { - for bound in keys { - pairs.push(format!("({}, {})", rust_str(kind), rust_str(bound))); - } - } - let _ = writeln!(fields, " bindings: &[{}],", pairs.join(", ")); - } - if !prop.choices.is_empty() { - let values: Vec = prop.choices.iter().map(|c| value_const(&c.value)).collect(); - let _ = writeln!(fields, " choices: &[{}],", values.join(", ")); - } - if prop.hide { - let _ = writeln!(fields, " hide: true,"); - } - if let Some(why) = prop.deprecated.as_deref() { - let _ = writeln!(fields, " deprecated: Some({}),", rust_str(why)); - } - if let Some(to) = prop.renamed_to.as_deref() { - let _ = writeln!(fields, " renamed_to: Some({}),", rust_str(to)); - } - if !prop.aliases.is_empty() { - let aliases: Vec = prop.aliases.iter().map(|alias| rust_str(alias)).collect(); - let _ = writeln!(fields, " aliases: &[{}],", aliases.join(", ")); - } - if let Some(optional) = prop.optional { - let _ = writeln!(fields, " optional: Some({optional}),"); - } - if let Some(help) = prop.help.as_deref() { - let _ = writeln!(fields, " help: Some({}),", rust_str(help)); - } - if let Some(long_help) = prop.long_help.as_deref() { - let _ = writeln!(fields, " long_help: Some({}),", rust_str(long_help)); - } - if let Some(note) = prop.default_note.as_deref() { - let _ = writeln!(fields, " default_note: Some({}),", rust_str(note)); - } - if let Some(since) = prop.since.as_deref() { - let _ = writeln!(fields, " since: Some({}),", rust_str(since)); - } - if let Some(at) = prop.deprecated_warn_at.as_deref() { - let _ = writeln!( - fields, - " deprecated_warn_at: Some({}),", - rust_str(at) - ); - } - if let Some(at) = prop.deprecated_remove_at.as_deref() { - let _ = writeln!( - fields, - " deprecated_remove_at: Some({}),", - rust_str(at) - ); - } - if !prop.examples.is_empty() { - let examples: Vec = prop.examples.iter().map(|e| rust_str(e)).collect(); - let _ = writeln!(fields, " examples: &[{}],", examples.join(", ")); - } - let new = format!( - "::usage_config::PropMeta::new({}, {})", - rust_str(key), - ty(key, prop.value_type.as_ref(), problems), - ); - // A setting with nothing but a key and a type is written as the call itself: the update form - // with no fields in it is the same value spelled twice, and clippy says so. - if fields.is_empty() { - return format!(" {new},"); - } - format!(" ::usage_config::PropMeta {{\n{fields} ..{new}\n }},") -} - -/// The runtime `Ty` expression for a declared type. -/// -/// Borrowed inners are fine in a `const`: `Ty::List(&Ty::String)` promotes, which is why the -/// runtime's type is shaped this way. -fn ty(key: &str, declared: Option<&SpecConfigType>, problems: &mut Vec) -> String { - let Some(declared) = declared else { - // No `type=` at all. The spec's own default is a string, and saying so beats inventing - // something cleverer for a property whose author did not say. - return "::usage_config::Ty::String".to_string(); - }; - match declared { - SpecConfigType::Base(base) => base_ty(base).to_string(), - SpecConfigType::List(inner) => format!( - "::usage_config::Ty::List(&{})", - ty(key, Some(inner), problems) - ), - SpecConfigType::Set(inner) => format!( - "::usage_config::Ty::Set(&{})", - ty(key, Some(inner), problems) - ), - SpecConfigType::Option(inner) => { - format!( - "::usage_config::Ty::Option(&{})", - ty(key, Some(inner), problems) - ) - } - SpecConfigType::Map(k, value) => { - // A table's keys are text in every format a settings file is written in, so a map - // keyed by anything else is a promise the runtime cannot keep — and one it would keep - // *quietly*, handing the CLI string keys for a `map`. - if !matches!(k, Base::String | Base::Custom(_)) { - problems.push(format!( - "`{key}` is a `map<{k}, …>`, and a settings table's keys are text: \ - write `map`" - )); - } - format!( - "::usage_config::Ty::Map(&{})", - ty(key, Some(value), problems) - ) - } - // A union, or a name this usage does not know: the spec has said usage cannot decide what - // belongs here, and `Any` is the runtime's word for exactly that — nothing coerced, nothing - // refused, the field's own type the only judge. - SpecConfigType::Union(_) => "::usage_config::Ty::Any".to_string(), - } -} - -fn base_ty(base: &Base) -> &'static str { - match base { - Base::Bool => "::usage_config::Ty::Bool", - Base::String => "::usage_config::Ty::String", - Base::Int => "::usage_config::Ty::Int", - Base::Uint => "::usage_config::Ty::Uint", - Base::Float => "::usage_config::Ty::Float", - Base::Path => "::usage_config::Ty::Path", - Base::Url => "::usage_config::Ty::Url", - Base::Duration => "::usage_config::Ty::Duration", - Base::Object => "::usage_config::Ty::Object", - Base::Custom(_) => "::usage_config::Ty::Any", - } -} - -/// The declared default as a `Const`, or nothing when there is none. -fn default_const(key: &str, prop: &SpecConfigProp, problems: &mut Vec) -> Option { - let has_default = prop.default.is_some() || !prop.default_list.is_empty(); - // Asked before either form of default is returned, because a *list* default returned early and - // skipped this — the one shape of the mistake that was generated instead of refused. - // - // An old name that carries a default its replacement does not have would seed a value under a - // key nothing reads: the merge folds a rename to its target, whose own default is what shows - // up. Left to run time it is a warning on every run of a shipped binary for a mistake only the - // author can fix, so it is refused here. - if let (Some(to), true) = (prop.renamed_to.as_deref(), has_default) { - problems.push(format!( - "`{key}` is renamed to `{to}` and declares a default of its own: put the default on \ - the setting that replaced it" - )); - } - // Both forms at once is something the spec accepts and nothing can mean: returning the list and - // ignoring the scalar dropped a value the author wrote, and I had claimed here that the parser - // refused it — it does not. I checked. - if prop.default.is_some() && !prop.default_list.is_empty() { - problems.push(format!( - "`{key}` declares a default twice, as `default=` and as a `default` node: keep one" - )); - } - // Anything declared here that the declared type cannot read is a value nothing can ever hold: a - // `choice` nobody can pick — `choice 1` under `type="bool"`, which reads booleans and their - // spellings and not numbers — or a default that is seeded straight into the resolution and then - // read by nothing, since a seeded default passes through no coercion at all. - let scalar = scalar_ty(prop.value_type.as_ref()); - let mut unreadable = Vec::new(); - for (what, value) in prop - .default - .iter() - .chain(prop.default_list.iter()) - .map(|value| ("defaults to", value)) - .chain(prop.choices.iter().map(|c| ("has a choice", &c.value))) - { - if scalar.coerce(runtime_value(value)).is_err() { - unreadable.push(what); - problems.push(format!( - "`{key}` {what} `{}`, which is not {}", - display(value), - scalar.describe() - )); - } - } - - // A declared default the declared choices do not allow. At run time it is seeded as the bottom - // layer and read by whatever holds it, so this is the only place it can be caught at all. - // - // Compared only when every choice is one a value could be: against a choice nothing can read, - // "not one of the values it allows" is a second message for the same mistake, and it blames the - // wrong end of it. - if !prop.choices.is_empty() && unreadable.is_empty() { - // Read the way the run time will read them — through the same `Ty::coerce` — because that is - // the only comparison that means anything: a spec writes `default="1"` beside `choice 1`, or - // `choice "yes"` under `type="bool"`, and neither is a mismatch once both are the declared - // type. Comparing spec literals refused those; comparing only same-shaped literals let - // `default="3"` beside `choice 1` through, and a *seeded* default never passes the check a - // supplied value does, so it would have become the effective value with nothing said. - let allowed = |value: &SpecConfigValue| { - let target = scalar.coerce(runtime_value(value)); - prop.choices.iter().any(|choice| { - match (&target, scalar.coerce(runtime_value(&choice.value))) { - (Ok(value), Ok(coerced)) if *value == coerced => true, - // Coercion did not settle it: compare what the two are written as, exactly as the - // run time does. `any` is where that matters — it coerces nothing, so a union's - // `choice 1` and a `default="1"` stay an integer and a string, and stopping at - // the arm above would refuse a spec the run time accepts. - _ => display(value) == display(&choice.value), - } - }) - }; - // A list default under a type that declares no items is one value, not several: `any` takes - // a list perfectly well, and a scalar choice is not one however many items it has. Checked - // item by item, `default 1 2` beside `choice 1` and `choice 2` passed — and then the whole - // list is what a seeded default *is*, refused by its own choices and never checked again. - let declares_items = declares_items(prop.value_type.as_ref()); - if !declares_items && !prop.default_list.is_empty() { - let written = prop - .default_list - .iter() - .map(display) - .collect::>() - .join(","); - problems.push(format!( - "`{key}` defaults to `{written}`, which is not one of the values it allows: {}", - prop.choices - .iter() - .map(|c| display(&c.value)) - .collect::>() - .join(", ") - )); - } - let items = match declares_items { - true => prop.default_list.as_slice(), - false => &[], - }; - for value in prop.default.iter().chain(items.iter()) { - if !allowed(value) { - problems.push(format!( - "`{key}` defaults to `{}`, which is not one of the values it allows: {}", - display(value), - prop.choices - .iter() - .map(|c| display(&c.value)) - .collect::>() - .join(", ") - )); - } - } - } - // A list default is a child node rather than a value, and either may be the one that is there. - if !prop.default_list.is_empty() { - let items: Vec = prop - .default_list - .iter() - .map(|value| coerced_const(scalar, value)) - .collect(); - return Some(format!( - "::usage_config::Const::List(&[{}])", - items.join(", ") - )); - } - prop.default - .as_ref() - .map(|value| coerced_const(scalar, value)) -} - -/// A declared default as the value its type says it is. -/// -/// Emitted *coerced*, because a default is seeded straight into the resolution and passes through no -/// coercion on the way: `default "yes"` under `list` is a boolean by the time anything reads -/// it, and emitted as the string it was written as, the read fails on a registry this crate had just -/// accepted. A value the type cannot read is refused above, so this only ever converts what the run -/// time would have converted itself. -/// -/// A *scalar* default arrives already converted — the spec's own parser reads it as the declared -/// type — so for that one this is belt and braces, and only the list form is observably fixed by it. -/// Applied to both because which of them the parser happens to cover is not something this should -/// depend on. -/// -/// Choices are *not* put through this: they are documentation as much as values — the message says -/// "one of yes, no" because that is what a user may write — and the comparison coerces them at the -/// moment it needs them. -fn coerced_const(ty: usage_config::Ty, value: &SpecConfigValue) -> String { - match ty.coerce(runtime_value(value)) { - Ok(usage_config::Value::Bool(b)) => format!("::usage_config::Const::Bool({b})"), - Ok(usage_config::Value::Int(i)) => format!("::usage_config::Const::Int({i})"), - Ok(usage_config::Value::Float(f)) => format!("::usage_config::Const::Float({f:?})"), - Ok(usage_config::Value::String(s)) => { - format!("::usage_config::Const::Str({})", rust_str(&s)) - } - // A collection cannot come out of coercing a scalar, and a value the type cannot read is - // already a refusal — either way the generation does not survive to be emitted. - _ => value_const(value), - } -} - -/// Whether the type this emits has items of its own, which is what makes an item-wise check right. -/// -/// Asked of the type that is *emitted*, not of the one the spec wrote: `simplified` collapses a union -/// to its first member, so a `list|string` looked like a list — while what is emitted for any -/// union is `Ty::Any`, which compares a value whole and would refuse the very default this had just -/// accepted. `option` is looked through for the same reason the runtime looks through it. -fn declares_items(declared: Option<&SpecConfigType>) -> bool { - match declared { - Some(SpecConfigType::List(_) | SpecConfigType::Set(_) | SpecConfigType::Map(..)) => true, - Some(SpecConfigType::Option(inner)) => declares_items(Some(inner)), - _ => false, - } -} - -/// The type a *scalar* of this setting is read as. -/// -/// Defaults and choices are scalars, so a collection is asked about what is in it. Every base type is -/// `const`-constructible without a reference, which is what makes this possible at build time at all: -/// the composite `Ty`s borrow, and nothing here can hand out a `&'static Ty`. -fn scalar_ty(declared: Option<&SpecConfigType>) -> usage_config::Ty { - fn of(ty: &SpecConfigType) -> usage_config::Ty { - match ty { - SpecConfigType::Base(base) => match base { - Base::Bool => usage_config::Ty::Bool, - Base::String => usage_config::Ty::String, - Base::Int => usage_config::Ty::Int, - Base::Uint => usage_config::Ty::Uint, - Base::Float => usage_config::Ty::Float, - Base::Path => usage_config::Ty::Path, - Base::Url => usage_config::Ty::Url, - Base::Duration => usage_config::Ty::Duration, - Base::Object => usage_config::Ty::Object, - Base::Custom(_) => usage_config::Ty::Any, - }, - SpecConfigType::List(inner) | SpecConfigType::Set(inner) => of(inner), - SpecConfigType::Map(_, value) => of(value), - SpecConfigType::Option(inner) => of(inner), - // A union coerces nothing, and neither does this. - SpecConfigType::Union(_) => usage_config::Ty::Any, - } - } - declared.map_or(usage_config::Ty::String, of) -} - -/// A spec value as the runtime holds one, for the coercion above. -fn runtime_value(value: &SpecConfigValue) -> usage_config::Value { - match value { - SpecConfigValue::Bool(b) => usage_config::Value::Bool(*b), - SpecConfigValue::Int(i) => usage_config::Value::Int(*i), - SpecConfigValue::Float(f) => usage_config::Value::Float(*f), - SpecConfigValue::String(s) => usage_config::Value::String(s.clone()), - } -} - -/// A spec value as a user would have written it, for a message and for the comparison that falls -/// back to one. -/// -/// Through the *runtime's* renderer, which is the only way the two can agree about anything: written -/// here as well, they drifted the moment one of them learned that a whole-number float is `1.0` and -/// not `1` — and this side would then have accepted a default the generated registry refuses. -fn display(value: &SpecConfigValue) -> String { - runtime_value(value).display() -} - -fn value_const(value: &SpecConfigValue) -> String { - match value { - SpecConfigValue::Bool(b) => format!("::usage_config::Const::Bool({b})"), - SpecConfigValue::Int(i) => format!("::usage_config::Const::Int({i})"), - // `{:?}` rather than `{}`: `1.0` written as `1` is an integer literal, and a `f64` field - // will not take one. - SpecConfigValue::Float(f) => format!("::usage_config::Const::Float({f:?})"), - SpecConfigValue::String(s) => format!("::usage_config::Const::Str({})", rust_str(s)), - } -} - -/// Both spellings of the environment, in precedence order. -/// -/// `env=` is the singular form and `env` the child node; the spec keeps them in step, and the -/// higher-precedence one comes first. -fn environment(prop: &SpecConfigProp) -> Vec { - let mut envs: Vec = Vec::new(); - if let Some(env) = prop.env.as_deref() { - envs.push(env.to_string()); - } - for env in &prop.envs { - if !envs.iter().any(|seen| seen == env) { - envs.push(env.clone()); - } - } - envs -} - -const PARSERS: [(&str, &str); 4] = [ - ("list_by_comma", "ListByComma"), - ("list_by_colon", "ListByColon"), - ("list_by_os_path_separator", "ListByOsPathSeparator"), - ("set_by_comma", "SetByComma"), -]; - -fn parser(name: &str) -> Option<&'static str> { - PARSERS - .iter() - .find(|(spec, _)| *spec == name) - .map(|(_, variant)| *variant) -} - -/// The const name for each key, and a problem for any two that would collide. -fn identifiers(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) -> Vec { - let mut idents = Vec::with_capacity(props.len()); - let mut seen: BTreeMap = BTreeMap::new(); - for (key, _) in props { - let ident = ident_of(key); - if let Some(other) = seen.get(&ident) { - // `task.output` and `task_output` are two settings and one const. Generating both - // would not compile, which is a good outcome reported badly: the message names the - // pair rather than leaving the author to read generated code. - problems.push(format!( - "`{key}` and `{other}` both generate `prop::{ident}`: rename one of them" - )); - } else { - seen.insert(ident.clone(), (*key).clone()); - } - idents.push(ident); - } - idents -} - -fn ident_of(key: &str) -> String { - let mut out = String::with_capacity(key.len()); - for c in key.chars() { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' => out.extend(c.to_uppercase()), - // A dot, a dash, anything else: one underscore, so `task.output` and `task-output` - // read the same way a Rust const is written. - _ => out.push('_'), - } - } - // A const cannot start with a digit, and a key may. - if out.starts_with(|c: char| c.is_ascii_digit()) { - out.insert(0, '_'); - } - out -} - -/// Refuse a key that cannot become a name. -/// -/// An empty key generated `pub const : PropId = …`, and an empty part of one (`task..output`) -/// generated a field with no name: both are code the *adopter's* crate fails to compile, in a file -/// they did not write and cannot fix. -fn check_keys(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - for (key, _) in props { - if key.is_empty() { - problems.push("a setting with no name cannot be generated".to_string()); - } else if !nameable(key) { - problems.push(format!( - "`{key}` has a part with no name in it: every piece of a dotted key needs an \ - ASCII letter or digit" - )); - } - } -} - -/// Refuse aliases whose owner is ambiguous. -fn check_aliases(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - let mut owners: BTreeMap<&str, &str> = props - .iter() - .map(|(key, _)| (key.as_str(), key.as_str())) - .collect(); - for (key, prop) in props { - for alias in &prop.aliases { - if alias.is_empty() { - problems.push(format!("`{key}` has an empty alias")); - continue; - } - if let Some(owner) = owners.insert(alias, key) { - problems.push(format!( - "`{alias}` names both `{owner}` and `{key}`; every setting key and alias must be unique" - )); - } - } - } -} - -/// Whether every part of `key` can be an identifier. -/// -/// A part needs an ASCII letter or digit in it, which is the alphabet the names are actually built -/// from: `ident_of` keeps those and turns everything else into an underscore. Asking the wider -/// question — is any character of it alphanumeric — accepted `é` and then generated `_` for it, so -/// the two disagreed about the same key. Rust would take `É` as an identifier, but what an arbitrary -/// letter uppercases to is not something to bet a generated name on. -/// -/// Empty (`task..output`) a part generated a field with no name; made only of characters outside -/// that alphabet (`-`) it generated an *anonymous* const — legal Rust, and unreferenceable, so the -/// reader that names it does not compile — and a field called `_`, which is not legal at all. -pub(crate) fn nameable(key: &str) -> bool { - !key.is_empty() - && key - .split('.') - .all(|part| part.chars().any(|c| c.is_ascii_alphanumeric())) -} - -/// Refuse a `renamed_to` that cannot be followed. -fn check_renames(props: &[(&String, &SpecConfigProp)], problems: &mut Vec) { - let by_key: BTreeMap<&str, &SpecConfigProp> = props - .iter() - .map(|(key, prop)| (key.as_str(), *prop)) - .collect(); - for (key, prop) in props { - let Some(to) = prop.renamed_to.as_deref() else { - continue; - }; - if !by_key.contains_key(to) { - problems.push(format!( - "`{key}` is renamed to `{to}`, which is not a setting" - )); - continue; - } - // Walking the chain from here, bounded by how many settings there are. A cycle makes - // every lookup of these keys answer `None` at run time — the setting becomes unreachable - // rather than wrong, which is the hardest kind of bug to see. - let mut seen = vec![key.as_str()]; - let mut current = to; - for _ in 0..props.len() { - if seen.contains(¤t) { - problems.push(format!( - "the renames of `{}` form a cycle: {} → {current}", - seen[0], - seen.join(" → ") - )); - break; - } - seen.push(current); - match by_key.get(current).and_then(|p| p.renamed_to.as_deref()) { - Some(next) => current = next, - None => break, - } - } - } -} - -/// `text` as a Rust string literal. -pub(crate) fn rust_str(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 2); - out.push('"'); - for c in text.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - // A control character written raw is a literal a compiler accepts and a reader cannot - // see. `\u{…}` is the same string, spelled. - c if c.is_control() => { - let _ = write!(out, "\\u{{{:x}}}", c as u32); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -/// Help text as one line, for a doc comment on a generated const. -pub(crate) fn one_line(text: &str) -> String { - text.replace(['\n', '\r'], " ") -} diff --git a/config-build/src/lib.rs b/config-build/src/lib.rs deleted file mode 100644 index 44d0216b7..000000000 --- a/config-build/src/lib.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! A spec's `config` block, as the registry `usage-config` resolves against. -//! -//! Every CLI in the jdx fleet declares its settings in one file and resolves them in another, and -//! the two are kept in step by hand. They drift every time: hk declares eighteen `sources.cli` -//! bindings and reads five, pitchfork generates five settings that its own `settings get` cannot -//! reach, fnox's docs describe a layer that does not exist. This crate is the join. The spec is -//! read here, at build time, and what comes out is `const` — so a setting that is declared is a -//! setting that resolves, and there is no second place to forget to update. -//! -//! ```no_run -//! // build.rs -//! usage_config_build::generate("mycli.usage.kdl").expect("settings"); -//! ``` -//! -//! ```ignore -//! // src/settings.rs -//! include!(concat!(env!("OUT_DIR"), "/settings.rs")); -//! ``` -//! -//! Nothing generated here allocates or parses at run time: `PropMeta` is `const`-constructible -//! down to its defaults, so a binary that reads no settings pays nothing for having them, and one -//! that reads all of them pays a `static`. -//! -//! # What it refuses -//! -//! A build script is the right place to be strict, because the alternative is a warning on every -//! run of a shipped binary for a mistake only the author can fix. So a registry is refused when it -//! cannot mean what it says: a `renamed_to` naming a setting that is not there, renames that form a -//! cycle, an old name carrying a default its replacement lacks, a `parse` nobody implements, a -//! `map` keyed by something a config file cannot spell, or two keys whose generated names collide. -//! All of them at once, rather than the first — an author fixing a registry wants the list. - -use std::fmt; -use std::path::{Path, PathBuf}; -use std::str::FromStr as _; - -mod emit; -mod settings; - -/// Read `spec` and write the registry to `$OUT_DIR/settings.rs`. -/// -/// Returns the path written, and tells cargo to re-run when the spec changes. -pub fn generate(spec: impl AsRef) -> Result { - let out_dir = std::env::var("OUT_DIR").map_err(|_| Error::NoOutDir)?; - let out = PathBuf::from(out_dir).join("settings.rs"); - generate_to(spec, &out)?; - Ok(out) -} - -/// Read `spec` and write the registry to `out`, returning the files it told cargo to watch. -/// -/// For a caller that keeps generated code in the repository rather than in `OUT_DIR` — which is -/// how this crate tests itself, and how a CLI that wants its registry reviewable does it. The -/// returned list is what was printed, rather than a second list assembled the same way: printing is -/// not something a test in this process can see, and a watch list nothing checks is one that can -/// quietly lose a file. -pub fn generate_to(spec: impl AsRef, out: impl AsRef) -> Result, Error> { - let spec = spec.as_ref(); - // Before anything can fail, so a spec that does not parse is still watched and the next build - // is not a stale success. - println!("cargo::rerun-if-changed={}", spec.display()); - let parsed = parse(spec)?; - // Every file the spec *included*, which is where a CLI with many settings keeps them — so - // watching only the file the build script names left editing the settings rebuilding nothing. - // Printed before the registry is built, so a spec whose settings are *refused* still watches the - // file its author is about to go and edit. - for included in parsed.sources.iter().skip(1) { - println!("cargo::rerun-if-changed={}", included.display()); - } - let watching = parsed.sources.clone(); - let source = source_of(&parsed.config, &name_of(spec))?; - let out = out.as_ref(); - if let Some(parent) = out.parent() { - std::fs::create_dir_all(parent).map_err(|err| Error::Io { - path: parent.to_path_buf(), - why: err.to_string(), - })?; - } - // Only when it differs, so a checked-in registry keeps its mtime and nothing downstream - // rebuilds for a generator that produced the same bytes. - if std::fs::read_to_string(out).is_ok_and(|existing| existing == source) { - return Ok(watching); - } - std::fs::write(out, source).map_err(|err| Error::Io { - path: out.to_path_buf(), - why: err.to_string(), - })?; - Ok(watching) -} - -/// Every file a build should watch: the spec, then each `include`, recursively. -/// -/// [`generate_to`] prints these for cargo. A build script doing something more elaborate — writing -/// the registry somewhere of its own, or generating other things from the same spec — wants the list -/// rather than the printing. -pub fn watched(spec: impl AsRef) -> Result, Error> { - Ok(parse(spec.as_ref())?.sources) -} - -/// The Rust source a spec's `config` block becomes. -pub fn source(spec: impl AsRef) -> Result { - let spec = spec.as_ref(); - source_of(&parse(spec)?.config, &name_of(spec)) -} - -/// The spec, read once. -/// -/// [`generate_to`] wants two things from it — the files to watch and the registry to write — and -/// asking for them one at a time parsed the whole spec twice on every build. -fn parse(spec: &Path) -> Result { - usage::Spec::parse_file(spec).map_err(|err| Error::Spec(err.to_string())) -} - -/// What the generated header calls the spec it came from. -fn name_of(spec: &Path) -> String { - spec.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| spec.display().to_string()) -} - -/// The same, from a spec that is already in memory. -/// -/// A relative `include` resolves against the file a spec was read from, so a spec whose settings -/// live in their own file has to go through [`source`]; this is for a caller holding one text. -pub fn source_of_spec(spec: &str, name: &str) -> Result { - let parsed = usage::Spec::from_str(spec).map_err(|err| Error::Spec(err.to_string()))?; - source_of(&parsed.config, name) -} - -fn source_of(config: &usage::spec::config::SpecConfig, name: &str) -> Result { - if config.props.is_empty() { - return Err(Error::NoSettings); - } - emit::registry(config, name).map_err(Error::Registry) -} - -/// Why a registry could not be generated. -#[derive(Debug)] -pub enum Error { - /// The spec itself did not parse. - Spec(String), - /// The spec parsed and declares no settings, which is not something to generate an empty - /// registry for: a build script asking for one has been pointed at the wrong file. - NoSettings, - /// Everything wrong with the settings, rather than the first thing. - Registry(Vec), - /// [`generate`] was called outside a build script. - NoOutDir, - Io { - path: PathBuf, - why: String, - }, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Spec(why) => write!(f, "{why}"), - Self::NoSettings => f.write_str("this spec declares no `config` settings"), - Self::Registry(problems) => { - for (i, problem) in problems.iter().enumerate() { - if i > 0 { - f.write_str("\n")?; - } - f.write_str(problem)?; - } - Ok(()) - } - Self::NoOutDir => f.write_str("OUT_DIR is not set; call this from a build script"), - Self::Io { path, why } => write!(f, "{}: {why}", path.display()), - } - } -} - -impl std::error::Error for Error {} diff --git a/config-build/src/settings.rs b/config-build/src/settings.rs deleted file mode 100644 index 358de347a..000000000 --- a/config-build/src/settings.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! The settings struct a CLI actually reads. -//! -//! The registry is what resolution needs; this is what the code needs. Between them they are the -//! whole reason the fleet's hand-written key↔field match arms exist — pitchfork writes about three -//! hundred and thirty lines of them, twice, and five settings still cannot be reached from its own -//! `settings get`. -//! -//! Dotted keys become nested structs, because `settings.task.output` is how the setting reads in -//! the file and there is no reason for the code to spell it differently. - -use std::collections::BTreeMap; -use std::fmt::Write as _; - -use usage::spec::config::SpecConfigProp; -use usage::spec::config_type::{Base, SpecConfigType}; - -/// A group of settings under a common dotted prefix, which becomes one struct. -#[derive(Default)] -struct Group<'a> { - /// Settings whose key ends here, by their last segment. - leaves: BTreeMap<&'a str, Leaf<'a>>, - /// Groups below this one, by their segment. - groups: BTreeMap<&'a str, Group<'a>>, -} - -struct Leaf<'a> { - /// The whole dotted key. - key: &'a str, - /// The const in `prop`, and the local this is read into. - ident: &'a str, - prop: &'a SpecConfigProp, -} - -/// The `Settings` struct, its nested structs, and the one function that reads them. -pub(crate) fn settings( - props: &[(&String, &SpecConfigProp)], - idents: &[String], - problems: &mut Vec, -) -> String { - let mut root = Group::default(); - for ((key, prop), ident) in props.iter().zip(idents) { - // An old name is not a field: every read of it folds to the setting that replaced it, so a - // field for it would be a second name for one value — and `config set old.key` writing to a - // field nothing reads is exactly the pitchfork bug. - if prop.renamed_to.is_some() { - continue; - } - // A key with a piece that cannot be a name is already refused by name, and building a tree - // from it invents a group called nothing that then collides with its own parent — two - // messages for one mistake, one of them about something the author never wrote. - if !crate::emit::nameable(key) { - continue; - } - let segments: Vec<&str> = key.split('.').collect(); - let (last, path) = segments.split_last().expect("a key has a segment"); - let mut group = &mut root; - for segment in path { - group = group.groups.entry(segment).or_default(); - } - group.leaves.insert(last, Leaf { key, ident, prop }); - } - - let mut structs: BTreeMap = BTreeMap::new(); - check_names(&root, "", "Settings", &mut structs, problems); - - let mut structs = String::new(); - let mut reads = String::new(); - let root_body = emit(&root, "Settings", &mut structs, &mut reads); - - format!( - "\n/// Every setting, as the types a CLI holds them in.\n\ - ///\n\ - /// Read with [`Settings::read`] from a resolution, which is the only way to build one:\n\ - /// the values, and every reason one could not be read, come from the merge.\n\ - #[derive(Debug, Clone, PartialEq)]\n\ - pub struct Settings {{\n{root_body}}}\n\ - {structs}\n\ - impl Settings {{\n \ - /// This resolution's values, as their types.\n \ - ///\n \ - /// Every setting is read before anything is returned, so the error is the whole list of\n \ - /// what is wrong rather than the first thing found.\n \ - pub fn read(\n \ - resolved: &::usage_config::Resolved,\n \ - ) -> ::std::result::Result {{\n \ - let mut fold = resolved.fold();\n{reads} \ - fold.finish()?;\n \ - ::std::result::Result::Ok(Self {{\n{} }})\n }}\n}}\n", - construct(&root, "Settings", 3), - ) -} - -/// One group's fields, its descendants' structs, and the reads that fill them. -fn emit(group: &Group<'_>, name: &str, structs: &mut String, reads: &mut String) -> String { - let mut fields = String::new(); - for (segment, sub) in &group.groups { - let sub_name = format!("{name}{}", camel(segment)); - let body = emit(sub, &sub_name, structs, reads); - let _ = write!( - structs, - "\n/// The `{}` settings.\n\ - #[derive(Debug, Clone, PartialEq)]\n\ - pub struct {sub_name} {{\n{body}}}\n", - crate::emit::one_line(segment) - ); - let _ = writeln!(fields, " pub {}: {sub_name},", field(segment)); - } - for (segment, leaf) in &group.leaves { - let ty = rust_ty(leaf); - if let Some(help) = leaf.prop.help.as_deref() { - let _ = writeln!(fields, " /// {}", one_line(help)); - } - // The key as well as the help, because the field name is a translation of it and a reader - // going from code to a config file needs the name the file uses. - let _ = writeln!(fields, " /// (`{}`)", crate::emit::one_line(leaf.key)); - if optional(leaf) { - let _ = writeln!(fields, " pub {}: Option<{ty}>,", field(segment)); - let _ = writeln!( - reads, - " let {}: Option<{ty}> = fold.optional(prop::{});", - local(leaf.ident), - leaf.ident - ); - } else { - let _ = writeln!(fields, " pub {}: {ty},", field(segment)); - let _ = writeln!( - reads, - " let {}: Option<{ty}> = fold.required(prop::{});", - local(leaf.ident), - leaf.ident - ); - } - } - fields -} - -/// The expression that builds one group, once the fold has proved every value read. -fn construct(group: &Group<'_>, name: &str, depth: usize) -> String { - let pad = " ".repeat(depth); - let mut out = String::new(); - for (segment, sub) in &group.groups { - // Named, not inferred: a nested group is its own struct, and the name is the same one its - // declaration got. - let sub_name = format!("{name}{}", camel(segment)); - let _ = writeln!(out, "{pad}{}: {sub_name} {{", field(segment)); - let _ = write!(out, "{}", construct(sub, &sub_name, depth + 1)); - let _ = writeln!(out, "{pad}}},"); - } - for (segment, leaf) in &group.leaves { - if optional(leaf) { - let name = field(segment); - let read_into = local(leaf.ident); - // `ci` rather than `ci: ci` where the two agree, which they do for every setting whose - // key has one segment: clippy will not have the long form, and nor should it. - if name == read_into { - let _ = writeln!(out, "{pad}{name},"); - } else { - let _ = writeln!(out, "{pad}{name}: {read_into},"); - } - } else { - // The unwrap the fold's own contract allows: `required` returns `None` only when it has - // recorded an error, and `finish` has already turned any error into a return. - // The message is a literal in generated code, so the key goes through the same escaper - // `PropMeta` uses: a key holding a quote or a backslash — `a"b` is a nameable key — - // otherwise ended the literal early and the rest of it read as code. - let message = crate::emit::rust_str(&format!( - "`{}` has a declared default, so the fold has already reported any absence", - leaf.key - )); - let _ = writeln!( - out, - "{pad}{}: {}.expect({message}),", - field(segment), - local(leaf.ident), - ); - } - } - out -} - -/// Whether a field holds an `Option`. -/// -/// Two ways to be optional and they mean the same thing to the code: the spec said `option`, or -/// nothing declared a default, in which case the resolution can perfectly well come back with no -/// value. Writing the field as `T` would mean a failure at run time for a shape the spec allows. -fn optional(leaf: &Leaf<'_>) -> bool { - if let Some(optional) = leaf.prop.optional { - return optional; - } - leaf.prop.default.is_none() && leaf.prop.default_list.is_empty() - || leaf - .prop - .value_type - .as_ref() - .is_some_and(SpecConfigType::is_optional) -} - -/// The Rust type for one setting, without the `Option` around it. -fn rust_ty(leaf: &Leaf<'_>) -> String { - fn of(ty: &SpecConfigType) -> String { - match ty { - SpecConfigType::Base(base) => base_ty(base).to_string(), - // A `set` is a `Vec` too: the merge has already dropped duplicates, and a list keeps - // the order the files were read in — which for a `PATH`-like setting is the meaning. - SpecConfigType::List(inner) | SpecConfigType::Set(inner) => { - format!("Vec<{}>", of(inner)) - } - SpecConfigType::Map(_, value) => { - format!("::std::collections::BTreeMap", of(value)) - } - SpecConfigType::Option(inner) => of(inner), - // A union is not a Rust type. The value arrives as it was written and the CLI decides, - // which is what declaring a union asked for. - SpecConfigType::Union(_) => "::usage_config::Value".to_string(), - } - } - let Some(declared) = leaf.prop.value_type.as_ref() else { - return "String".to_string(); - }; - // Every `Value` this can produce is one the spec asked for: an `object` says its keys are not - // described, a union says usage cannot decide, and a name usage does not know says the same. So - // there is nothing to refuse here — the type is as narrow as the declaration was. - of(declared) -} - -fn base_ty(base: &Base) -> &'static str { - match base { - Base::Bool => "bool", - Base::Int => "i64", - Base::Uint => "u64", - Base::Float => "f64", - Base::String => "String", - Base::Path => "::std::path::PathBuf", - // Read as text, both of them. What makes a string a URL is what the CLI does with it, and - // the crate that owns the duration type owns its spelling — inventing one here would put a - // dependency in every adopter's binary for a value they may only ever print. - Base::Url | Base::Duration => "String", - // A table whose keys the spec does not describe, and a name usage does not know: the value - // as it was written. - Base::Object | Base::Custom(_) => "::usage_config::Value", - } -} - -/// `task` → `Task`, for a nested struct's name. -/// -/// ASCII only, like every other name here: a Rust identifier is not "any alphanumeric character" — -/// `½` is one and is not allowed in an identifier, and a Unicode digit cannot start one — and the -/// consts in `prop` are built from ASCII already, so keeping more here made one key produce names -/// from two alphabets, one of which the adopter could not compile. -fn camel(segment: &str) -> String { - let mut out = String::with_capacity(segment.len()); - let mut upper = true; - for c in segment.chars() { - if !c.is_ascii_alphanumeric() { - upper = true; - continue; - } - if upper { - out.extend(c.to_uppercase()); - upper = false; - } else { - out.push(c); - } - } - out -} - -/// A key segment as a field name. -fn field(segment: &str) -> String { - let mut name: String = segment - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - // A field cannot start with a digit any more than a const can, and `2fa` is a real name for a - // real setting. Prefixed the same way, so the two spellings of one key agree. - if name.starts_with(|c: char| c.is_ascii_digit()) { - name.insert(0, '_'); - } - raw(&name) -} - -/// The local a setting is read into. -/// -/// Prefixed, and that is the whole point: unprefixed, a setting called `fold` generated -/// `let fold = fold.optional(…)` and shadowed the reader's own fold, so every read after it — and -/// `fold.finish()` — stopped compiling. A setting called `SELF` generated `let self`. The prefix -/// goes on every local, so no name a spec can choose reaches either state, and since the consts it -/// is built from are unique, the locals are too. -fn local(ident: &str) -> String { - format!("read_{}", ident.to_lowercase()) -} - -/// `name`, spelled so it can be an identifier. -/// -/// A `let` binding needs this as much as a field does: `match` is an unremarkable name for a -/// setting, and `let match: Option` is not Rust. -fn raw(name: &str) -> String { - if RAW_ALLOWED.contains(&name) { - return format!("r#{name}"); - } - name.to_string() -} - -/// Keywords a field can still be named, spelled `r#`. -/// -/// The four that cannot — `self`, `crate`, `super` and `Self` — are refused by name in -/// [`check_names`], because there is no way to write them. -const RAW_ALLOWED: [&str; 49] = [ - "as", - "break", - "const", - "continue", - "else", - "enum", - "extern", - "false", - "fn", - "for", - "if", - "impl", - "in", - "let", - "loop", - "match", - "mod", - "move", - "mut", - "pub", - "ref", - "return", - "static", - "struct", - "trait", - "true", - "type", - "unsafe", - "use", - "where", - "while", - "async", - "await", - "dyn", - "abstract", - "become", - "box", - "do", - "final", - "macro", - "override", - "priv", - "typeof", - "unsized", - "virtual", - "yield", - "try", - "gen", - "macro_rules", -]; - -/// Keywords no identifier can be, raw or otherwise. -const NEVER_ALLOWED: [&str; 4] = ["self", "crate", "super", "Self"]; - -/// Refuse the names that cannot become fields, and the ones that would collide. -/// -/// Checked on the names that are actually *emitted*, not on the key segments they come from: two -/// segments can differ and still translate to one field (`foo-bar` beside `foo_bar`), and two groups -/// can differ and still translate to one struct (`a` beside `A`, since a type name loses the case -/// of its first letter). Comparing the segments, as this did, refused neither — and the collision -/// surfaced as duplicate items in a file the adopter did not write. The `prop::` consts do not catch -/// these either: `foo-bar.x` and `foo_bar.y` make two distinct consts and one field. -fn check_names( - group: &Group<'_>, - path: &str, - name: &str, - structs: &mut BTreeMap, - problems: &mut Vec, -) { - // Fields are per level: each group is its own struct, so two groups may both have a `timeout`. - // Every entry remembers whether it came from a setting or from a group, because a setting and a - // group wanting one name is a mistake worth its own words. - let mut fields: BTreeMap = BTreeMap::new(); - - for (segment, leaf) in &group.leaves { - check_segment(segment, leaf.key, problems); - if let Some((other, _)) = fields.insert(field(segment), (leaf.key.to_string(), true)) { - collide(&other, leaf.key, &field(segment), problems); - } - } - for (segment, sub) in &group.groups { - let key = format!("{path}{segment}"); - check_segment(segment, &key, problems); - match fields.insert(field(segment), (key.clone(), false)) { - // `python` as a setting and `python.compile` as another: one field name with two things - // to be, a value and a table. The spec can say it and no struct can hold it. - Some((setting, true)) => problems.push(format!( - "`{setting}` is a setting and a group of settings: it cannot be both a value and \ - a table" - )), - Some((other, false)) => collide(&other, &key, &field(segment), problems), - None => {} - } - // Struct names are *not* per level. Every one of them is declared in the same module and - // built by concatenation, so `http.client.x` and `http_client.y` both arrive at - // `SettingsHttpClient` from different depths — compared among siblings, as this was, neither - // was refused and the adopter's crate got two structs with one name. - let sub_name = format!("{name}{}", camel(segment)); - if let Some(other) = structs.insert(sub_name.clone(), key.clone()) { - problems.push(format!( - "`{other}` and `{key}` are both groups named `{sub_name}`: rename one of them" - )); - } - check_names(sub, &format!("{key}."), &sub_name, structs, problems); - } -} - -/// Whether one key segment can be a field at all. -fn check_segment(segment: &str, of: &str, problems: &mut Vec) { - if NEVER_ALLOWED.contains(&segment) { - problems.push(format!( - "`{of}` cannot be a field: `{segment}` is a keyword Rust has no spelling for" - )); - } -} - -fn collide(one: &str, other: &str, name: &str, problems: &mut Vec) { - problems.push(format!( - "`{one}` and `{other}` both generate the field `{name}`: rename one of them" - )); -} - -/// Help text as one line, for a doc comment. -fn one_line(text: &str) -> String { - text.replace(['\n', '\r'], " ") -} diff --git a/config-build/tests/fixtures/hk.usage.kdl b/config-build/tests/fixtures/hk.usage.kdl deleted file mode 100644 index ab6b2bc04..000000000 --- a/config-build/tests/fixtures/hk.usage.kdl +++ /dev/null @@ -1,81 +0,0 @@ -name "hk" -bin "hk" - -// A settings block with one of everything this generator has to carry, modelled on hk's own -// registry — the fleet's most featureful, and the first CLI that will adopt this. -config { - source "git" name="git config" doc_hint="git config `{key}`" - source "pkl" name="hk.pkl" - - file "/etc/hk/config.toml" scope="system" - file "~/.config/hk/config.toml" scope="global" - file "hk.toml" findup=#true - - prop "jobs" type="uint" default=4 default_note="0 = one per core" \ - help="How many jobs to run at once" { - env "HK_JOBS" "HK_JOB" - deprecated_env "HK_JOBS_OLD" - source "git" "hk.jobs" - cli "--jobs" "-j" - } - - prop "exclude" type="list" merge="union" parse="list_by_comma" \ - help="Paths to leave alone" { - env "HK_EXCLUDE" - source "pkl" "exclude" "defaults.exclude" - } - - prop "path" type="list" parse="list_by_os_path_separator" { - env "HK_PATH" - } - - prop "stash" type="string" default="git" help="How to stash before a run" { - choices { - choice "git" help="git stash" - choice "patch-file" help="A patch on disk" - choice "none" - } - } - - // Choices written as numbers under a `string` type, which is as ordinary a thing to write as - // quoting them — and which the registry has to accept once the value is coerced to text. - // The default written as a number under a `string` type, which is the shape a default has to be - // *coerced* for: it is seeded straight into the resolution and read by whatever holds it. - prop "log_format" type="string" default=1 help="Which log format to use" { - choices { - choice 1 help="The old one" - choice 2 help="The one with colour" - } - } - - prop "url_replacements" type="map" merge="deep" \ - help="Rewrite these URL prefixes" - - prop "trusted" type="bool" scope="global" default=#false \ - help="Whether this checkout may run its own hooks" - - prop "ci" type="bool" scope="env" hide=#true { - env "CI" - } - - prop "task.output" type="string" default="prefix" help="How task output is interleaved" - - // A keyword as a key, which is ordinary in a config file and needs `r#` in Rust. - prop "match" type="string" default="all" help="Which files to match" - - prop "either" type="bool|string" help="A \"union\" only hk understands" - - prop "timeout" type="option" help="How long to wait, if at all" - - prop "log_level" type="string" default="info" { - env "HK_LOG_LEVEL" - x "hk.parse_env" "lowercase" - } - - prop "concurrency" type="uint" deprecated="Use jobs instead." renamed_to="jobs" \ - deprecated_warn_at="2026.12.0" - - prop "ports" type="list" { - default 80 443 - } -} diff --git a/config-build/tests/fixtures/split-settings.usage.kdl b/config-build/tests/fixtures/split-settings.usage.kdl deleted file mode 100644 index 2b8db29a1..000000000 --- a/config-build/tests/fixtures/split-settings.usage.kdl +++ /dev/null @@ -1,5 +0,0 @@ -config { - prop "jobs" type="uint" default=2 help="How many jobs to run at once" { - env "SPLIT_JOBS" - } -} diff --git a/config-build/tests/fixtures/split.usage.kdl b/config-build/tests/fixtures/split.usage.kdl deleted file mode 100644 index dc47f1bf6..000000000 --- a/config-build/tests/fixtures/split.usage.kdl +++ /dev/null @@ -1,5 +0,0 @@ -name "split" -bin "split" - -// The shape a CLI with many settings uses: the settings live in a file of their own. -include file="./split-settings.usage.kdl" diff --git a/config-build/tests/generated.rs b/config-build/tests/generated.rs deleted file mode 100644 index 0383ab6bf..000000000 --- a/config-build/tests/generated.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! The generated registry, compiled and resolved against. -//! -//! A generator can be tested by comparing strings, and a string that looks like Rust is not Rust. -//! So the output of `usage-config-build` for [the fixture spec](fixtures/hk.usage.kdl) is checked -//! in beside this file and included here: it is compiled by `cargo test`, and every assertion -//! below is against the registry that compilation produced. - -use std::collections::BTreeMap; - -use usage_config::{ - resolve, FileLayer, FileScope, Layers, Merge, Parser, Scope, SourceKind, Ty, Value, -}; - -include!("golden/settings.rs"); - -#[test] -fn every_declared_default_resolves_as_its_type() { - // No layers at all: what a CLI gets from its registry alone, which is also the only thing a - // `const` default can be wrong about. - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - let mut fold = resolved.fold(); - - let jobs: Option = fold.required(prop::JOBS); - let stash: Option = fold.required(prop::STASH); - let trusted: Option = fold.required(prop::TRUSTED); - let output: Option = fold.required(prop::TASK_OUTPUT); - let ports: Option> = fold.required(prop::PORTS); - let timeout: Option = fold.optional(prop::TIMEOUT); - let format: Option = fold.required(prop::LOG_FORMAT); - fold.finish().expect("every default fits its type"); - - assert_eq!(jobs, Some(4)); - assert_eq!(stash, Some("git".to_string())); - // Written `default=1` under a `string` type, and seeded with no coercion on the way — so it has - // to have been *emitted* as the string it is, or this read fails on a registry the generator - // accepted. - assert_eq!(format, Some("1".to_string())); - assert_eq!(trusted, Some(false)); - assert_eq!(output, Some("prefix".to_string())); - // A list default is a child node in the spec and stays typed all the way here: three numbers, - // not three strings. - assert_eq!(ports, Some(vec![80, 443])); - // No default and an `option`: absent is a state, not a failure. - assert_eq!(timeout, None); -} - -#[test] -fn a_generated_id_is_the_setting_it_names() { - // The one thing emitting ids as indices can get wrong, and it would be silent: every read - // would answer about the wrong setting. So each const is checked against the name it claims. - for id in SETTINGS_REGISTRY.ids() { - let key = SETTINGS_REGISTRY.get(id).key; - // `lookup_exact`, not `lookup`: the folding one answers about the setting that *replaced* - // an old name, which is right for reading a value and wrong for asking where a name lives. - assert_eq!( - SETTINGS_REGISTRY.lookup_exact(key), - Some(id), - "`{key}` is not where the registry says it is" - ); - } - assert_eq!(SETTINGS_REGISTRY.get(prop::JOBS).key, "jobs"); - assert_eq!(SETTINGS_REGISTRY.get(prop::TASK_OUTPUT).key, "task.output"); - assert_eq!( - SETTINGS_REGISTRY.get(prop::URL_REPLACEMENTS).key, - "url_replacements" - ); -} - -#[test] -fn what_the_spec_said_about_each_setting_is_what_the_registry_holds() { - let meta = |id| SETTINGS_REGISTRY.get(id); - - // Types, including the two that are not a name: a union is `Any`, because the spec has said - // usage cannot decide what belongs there. - assert_eq!(meta(prop::JOBS).ty, Ty::Uint); - assert_eq!(meta(prop::EXCLUDE).ty, Ty::List(&Ty::String)); - assert_eq!(meta(prop::PATH).ty, Ty::List(&Ty::Path)); - assert_eq!(meta(prop::URL_REPLACEMENTS).ty, Ty::Map(&Ty::String)); - assert_eq!(meta(prop::TIMEOUT).ty, Ty::Option(&Ty::Duration)); - assert_eq!(meta(prop::EITHER).ty, Ty::Any); - - // Merge policy, scope, and the named parser: each of these is a `prop` attribute that a - // hand-written registry has to remember to copy, and every one of them is a real setting in - // hk's own file. - assert_eq!(meta(prop::EXCLUDE).merge, Merge::Union); - assert_eq!(meta(prop::URL_REPLACEMENTS).merge, Merge::Deep); - assert_eq!(meta(prop::JOBS).merge, Merge::Replace); - assert_eq!(meta(prop::TRUSTED).scope, Scope::Global); - assert_eq!(meta(prop::CI).scope, Scope::Env); - assert_eq!(meta(prop::JOBS).scope, Scope::Any); - assert_eq!(meta(prop::EXCLUDE).parse, Some(Parser::ListByComma)); - assert_eq!(meta(prop::PATH).parse, Some(Parser::ListByOsPathSeparator)); - assert_eq!(meta(prop::JOBS).parse, None); - assert!(meta(prop::CI).hide); - - // What the spec says a setting will take, which until now reached the docs and the schema and - // nothing that resolved a value. - assert_eq!( - meta(prop::STASH).choices, - &[ - usage_config::Const::Str("git"), - usage_config::Const::Str("patch-file"), - usage_config::Const::Str("none") - ] - ); - assert!( - meta(prop::JOBS).choices.is_empty(), - "most settings say nothing" - ); - assert!(!meta(prop::JOBS).hide); - - // The flags that set it, which the spec declares beside the environment variables and which the - // generator used to read and drop — leaving the registry unable to say a setting is settable from - // the command line at all. - assert_eq!(meta(prop::JOBS).cli, &["--jobs", "-j"]); - assert_eq!(meta(prop::JOBS).deprecated_envs, &["HK_JOBS_OLD"]); - assert!( - meta(prop::EXCLUDE).cli.is_empty(), - "most settings have no flag" - ); - - // The environment in precedence order, and help as the spec wrote it — quotes and all. - assert_eq!(meta(prop::JOBS).envs, &["HK_JOBS", "HK_JOB"]); - assert_eq!(meta(prop::CI).envs, &["CI"]); - assert_eq!(meta(prop::JOBS).help, Some("How many jobs to run at once")); - assert_eq!( - meta(prop::EITHER).help, - Some("A \"union\" only hk understands") - ); - - // Bindings, which is the whole mechanism behind hk's git and pkl layers: a custom layer asks - // the registry for its own kind and iterates what it finds. - assert_eq!( - SETTINGS_REGISTRY - .bindings(SourceKind::new("git")) - .collect::>(), - vec![(prop::JOBS, "hk.jobs")] - ); - assert_eq!( - SETTINGS_REGISTRY - .bindings(SourceKind::new("pkl")) - .collect::>(), - vec![ - (prop::EXCLUDE, "exclude"), - (prop::EXCLUDE, "defaults.exclude") - ] - ); -} - -#[test] -fn a_value_the_spec_does_not_allow_is_refused_through_the_generated_registry() { - // End to end for the choices: declared as `choice` nodes in the spec, carried into the registry - // by the generator, and enforced by the merge — the three places that used to disagree. - let dir = - std::env::temp_dir().join(format!("usage_config_build_choice_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - let path = dir.join("hk.toml"); - std::fs::write(&path, "stash = \"svn\"\n").expect("write"); - - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - // The declared default stands, because a refused value costs its own key and nothing else. - assert_eq!(resolved.get_key("stash"), Some(&Value::from("git"))); - let warnings = usage_config::explain::warnings(&resolved); - assert!( - warnings - .iter() - .any(|w| w.starts_with("stash expected one of git, patch-file, none but has `svn`")), - "{warnings:?}" - ); - - // And a choice written as a number under a `string` type is the value it names once the value - // has been coerced: comparing the shapes refused a value the spec plainly allows. - let path = dir.join("formats.toml"); - std::fs::write(&path, "log_format = 2\n").expect("write"); - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - assert_eq!(resolved.get_key("log_format"), Some(&Value::from("2"))); - assert!( - usage_config::explain::warnings(&resolved).is_empty(), - "{:?}", - usage_config::explain::warnings(&resolved) - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn a_renamed_setting_folds_into_the_one_that_replaced_it() { - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - // The old name still answers, about the setting that replaced it. - assert_eq!(resolved.get_key("concurrency"), Some(&Value::Int(4))); - assert_eq!( - SETTINGS_REGISTRY.get(prop::CONCURRENCY).renamed_to, - Some("jobs") - ); - let explained = usage_config::explain(&resolved, "concurrency").expect("declared"); - assert!( - explained.starts_with("concurrency is now jobs\n"), - "{explained}" - ); - assert!( - explained.contains("deprecated: Use jobs instead."), - "{explained}" - ); -} - -#[test] -fn a_file_read_against_the_generated_registry_means_what_the_spec_said() { - // End to end, which is the point of the whole crate: a spec declared these settings, a build - // generated the registry, and a config file is read through it with nothing hand-written in - // between. - let dir = std::env::temp_dir().join(format!("usage_config_build_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - let path = dir.join("hk.toml"); - std::fs::write( - &path, - "jobs = 8\nexclude = \"target,dist\"\nconcurrency = 2\n\ - [url_replacements]\n\"git@github.com:\" = \"https://github.com/\"\n", - ) - .expect("write"); - - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - let mut fold = resolved.fold(); - let jobs: Option = fold.required(prop::JOBS); - let exclude: Option> = fold.required(prop::EXCLUDE); - let replacements: Option> = fold.required(prop::URL_REPLACEMENTS); - fold.finish().expect("reads"); - - // The file's value beats the declared default… - assert_eq!(jobs, Some(8)); - // …the named parser splits what the file wrote as one string… - assert_eq!( - exclude, - Some(vec!["target".to_string(), "dist".to_string()]) - ); - // …a `map` arrives as a table… - assert_eq!( - replacements.as_ref().and_then(|m| m.get("git@github.com:")), - Some(&"https://github.com/".to_string()) - ); - // …and the deprecated key in the same file is folded, with something said about it. - let warnings = usage_config::explain::warnings(&resolved); - assert!( - warnings - .iter() - .any(|w| w.contains("concurrency is deprecated")), - "{warnings:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn the_settings_struct_holds_what_each_type_says() { - // The struct is generated, so this is the compiler agreeing as much as it is a test: every - // field below has to exist, with that type, or this file does not build. - let resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - let settings = Settings::read(&resolved).expect("every default fits its field"); - - // A declared default means the field is the value itself — there is nothing to unwrap for a - // setting that always has one. - assert_eq!(settings.jobs, 4u64); - assert_eq!(settings.stash, "git"); - assert!(!settings.trusted); - assert_eq!(settings.ports, vec![80u64, 443]); - // A dotted key is a nested struct, spelled the way the config file spells it. - assert_eq!(settings.task.output, "prefix"); - // No default, or `option<…>`: absent is a state the type can hold, so nothing fails at run - // time for a shape the spec allows. - assert_eq!(settings.exclude, None); - assert_eq!(settings.timeout, None); - assert_eq!(settings.ci, None); - // A keyword is an unremarkable name for a setting, and `r#` is how Rust spells it — for the - // field and for the local the generated reader binds it to, which is what `let match: …` taught - // me the hard way. - assert_eq!(settings.r#match, "all"); - // A union has no Rust type. The value arrives as it was written, which is what declaring one - // asked for. - assert_eq!(settings.either, None); - // And an old name is not a field at all: `concurrency` folds into `jobs`, so a field for it - // would be a second name for one value. - assert_eq!( - SETTINGS_REGISTRY.get(prop::CONCURRENCY).renamed_to, - Some("jobs") - ); -} - -#[test] -fn a_file_fills_the_struct_and_a_bad_value_names_itself() { - let dir = - std::env::temp_dir().join(format!("usage_config_build_struct_{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - - let path = dir.join("hk.toml"); - std::fs::write( - &path, - "jobs = 8\nexclude = \"target,dist\"\npath = \"/bin\"\n[task]\noutput = \"interleave\"\n", - ) - .expect("write"); - let layer = FileLayer::at(&path, FileScope::Project); - let resolved = resolve(SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); - let settings = Settings::read(&resolved).expect("reads"); - assert_eq!(settings.jobs, 8); - assert_eq!( - settings.exclude, - Some(vec!["target".to_string(), "dist".to_string()]) - ); - assert_eq!( - settings.path, - Some(vec![std::path::PathBuf::from("/bin")]), - "a `list` arrives as paths" - ); - assert_eq!(settings.task.output, "interleave"); - - // And the failure a struct read can still have: a hook writing past the declared type, which - // is the one place the merge did not check. The error names the setting and the hook. - let mut resolved = resolve(SETTINGS_REGISTRY, Layers::new()).expect("resolves"); - resolved.coerced(prop::JOBS, Value::Int(-1), "one job when raw"); - let err = Settings::read(&resolved).expect_err("a `uint` field cannot hold -1"); - assert_eq!( - err.to_string(), - "jobs expected a non-negative integer but has `-1` (set by one job when raw)" - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/config-build/tests/golden/settings.rs b/config-build/tests/golden/settings.rs deleted file mode 100644 index eacd76d8d..000000000 --- a/config-build/tests/golden/settings.rs +++ /dev/null @@ -1,228 +0,0 @@ -// @generated by usage-config-build from `hk.usage.kdl`. Do not edit. -// -// Every setting this CLI has, as consts: there is no second declaration of a setting to -// keep in step with this one, which is the drift this file exists to remove. - -pub static SETTINGS_PROPS: &[::usage_config::PropMeta] = &[ - ::usage_config::PropMeta { - scope: ::usage_config::Scope::Env, - envs: &["CI"], - hide: true, - ..::usage_config::PropMeta::new("ci", ::usage_config::Ty::Bool) - }, - ::usage_config::PropMeta { - deprecated: Some("Use jobs instead."), - renamed_to: Some("jobs"), - deprecated_warn_at: Some("2026.12.0"), - ..::usage_config::PropMeta::new("concurrency", ::usage_config::Ty::Uint) - }, - ::usage_config::PropMeta { - help: Some("A \"union\" only hk understands"), - ..::usage_config::PropMeta::new("either", ::usage_config::Ty::Any) - }, - ::usage_config::PropMeta { - merge: ::usage_config::Merge::Union, - parse: Some(::usage_config::Parser::ListByComma), - envs: &["HK_EXCLUDE"], - bindings: &[("pkl", "exclude"), ("pkl", "defaults.exclude")], - help: Some("Paths to leave alone"), - ..::usage_config::PropMeta::new("exclude", ::usage_config::Ty::List(&::usage_config::Ty::String)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Int(4)), - envs: &["HK_JOBS", "HK_JOB"], - deprecated_envs: &["HK_JOBS_OLD"], - cli: &["--jobs", "-j"], - bindings: &[("git", "hk.jobs")], - help: Some("How many jobs to run at once"), - default_note: Some("0 = one per core"), - ..::usage_config::PropMeta::new("jobs", ::usage_config::Ty::Uint) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("1")), - choices: &[::usage_config::Const::Int(1), ::usage_config::Const::Int(2)], - help: Some("Which log format to use"), - ..::usage_config::PropMeta::new("log_format", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("info")), - envs: &["HK_LOG_LEVEL"], - ..::usage_config::PropMeta::new("log_level", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("all")), - help: Some("Which files to match"), - ..::usage_config::PropMeta::new("match", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - parse: Some(::usage_config::Parser::ListByOsPathSeparator), - envs: &["HK_PATH"], - ..::usage_config::PropMeta::new("path", ::usage_config::Ty::List(&::usage_config::Ty::Path)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::List(&[::usage_config::Const::Int(80), ::usage_config::Const::Int(443)])), - ..::usage_config::PropMeta::new("ports", ::usage_config::Ty::List(&::usage_config::Ty::Uint)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("git")), - choices: &[::usage_config::Const::Str("git"), ::usage_config::Const::Str("patch-file"), ::usage_config::Const::Str("none")], - help: Some("How to stash before a run"), - ..::usage_config::PropMeta::new("stash", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Str("prefix")), - help: Some("How task output is interleaved"), - ..::usage_config::PropMeta::new("task.output", ::usage_config::Ty::String) - }, - ::usage_config::PropMeta { - help: Some("How long to wait, if at all"), - ..::usage_config::PropMeta::new("timeout", ::usage_config::Ty::Option(&::usage_config::Ty::Duration)) - }, - ::usage_config::PropMeta { - default: Some(::usage_config::Const::Bool(false)), - scope: ::usage_config::Scope::Global, - help: Some("Whether this checkout may run its own hooks"), - ..::usage_config::PropMeta::new("trusted", ::usage_config::Ty::Bool) - }, - ::usage_config::PropMeta { - merge: ::usage_config::Merge::Deep, - help: Some("Rewrite these URL prefixes"), - ..::usage_config::PropMeta::new("url_replacements", ::usage_config::Ty::Map(&::usage_config::Ty::String)) - }, -]; - -pub const SETTINGS_REGISTRY: ::usage_config::Registry = - ::usage_config::Registry::new(SETTINGS_PROPS); - -/// Each setting's id, which is its position in [`SETTINGS_PROPS`]. -pub mod prop { - use ::usage_config::PropId; - - /// `ci` - pub const CI: PropId = PropId(0); - /// `concurrency` - pub const CONCURRENCY: PropId = PropId(1); - /// `either` — A "union" only hk understands - pub const EITHER: PropId = PropId(2); - /// `exclude` — Paths to leave alone - pub const EXCLUDE: PropId = PropId(3); - /// `jobs` — How many jobs to run at once - pub const JOBS: PropId = PropId(4); - /// `log_format` — Which log format to use - pub const LOG_FORMAT: PropId = PropId(5); - /// `log_level` - pub const LOG_LEVEL: PropId = PropId(6); - /// `match` — Which files to match - pub const MATCH: PropId = PropId(7); - /// `path` - pub const PATH: PropId = PropId(8); - /// `ports` - pub const PORTS: PropId = PropId(9); - /// `stash` — How to stash before a run - pub const STASH: PropId = PropId(10); - /// `task.output` — How task output is interleaved - pub const TASK_OUTPUT: PropId = PropId(11); - /// `timeout` — How long to wait, if at all - pub const TIMEOUT: PropId = PropId(12); - /// `trusted` — Whether this checkout may run its own hooks - pub const TRUSTED: PropId = PropId(13); - /// `url_replacements` — Rewrite these URL prefixes - pub const URL_REPLACEMENTS: PropId = PropId(14); -} - -/// Every setting, as the types a CLI holds them in. -/// -/// Read with [`Settings::read`] from a resolution, which is the only way to build one: -/// the values, and every reason one could not be read, come from the merge. -#[derive(Debug, Clone, PartialEq)] -pub struct Settings { - pub task: SettingsTask, - /// (`ci`) - pub ci: Option, - /// A "union" only hk understands - /// (`either`) - pub either: Option<::usage_config::Value>, - /// Paths to leave alone - /// (`exclude`) - pub exclude: Option>, - /// How many jobs to run at once - /// (`jobs`) - pub jobs: u64, - /// Which log format to use - /// (`log_format`) - pub log_format: String, - /// (`log_level`) - pub log_level: String, - /// Which files to match - /// (`match`) - pub r#match: String, - /// (`path`) - pub path: Option>, - /// (`ports`) - pub ports: Vec, - /// How to stash before a run - /// (`stash`) - pub stash: String, - /// How long to wait, if at all - /// (`timeout`) - pub timeout: Option, - /// Whether this checkout may run its own hooks - /// (`trusted`) - pub trusted: bool, - /// Rewrite these URL prefixes - /// (`url_replacements`) - pub url_replacements: Option<::std::collections::BTreeMap>, -} - -/// The `task` settings. -#[derive(Debug, Clone, PartialEq)] -pub struct SettingsTask { - /// How task output is interleaved - /// (`task.output`) - pub output: String, -} - -impl Settings { - /// This resolution's values, as their types. - /// - /// Every setting is read before anything is returned, so the error is the whole list of - /// what is wrong rather than the first thing found. - pub fn read( - resolved: &::usage_config::Resolved, - ) -> ::std::result::Result { - let mut fold = resolved.fold(); - let read_task_output: Option = fold.required(prop::TASK_OUTPUT); - let read_ci: Option = fold.optional(prop::CI); - let read_either: Option<::usage_config::Value> = fold.optional(prop::EITHER); - let read_exclude: Option> = fold.optional(prop::EXCLUDE); - let read_jobs: Option = fold.required(prop::JOBS); - let read_log_format: Option = fold.required(prop::LOG_FORMAT); - let read_log_level: Option = fold.required(prop::LOG_LEVEL); - let read_match: Option = fold.required(prop::MATCH); - let read_path: Option> = fold.optional(prop::PATH); - let read_ports: Option> = fold.required(prop::PORTS); - let read_stash: Option = fold.required(prop::STASH); - let read_timeout: Option = fold.optional(prop::TIMEOUT); - let read_trusted: Option = fold.required(prop::TRUSTED); - let read_url_replacements: Option<::std::collections::BTreeMap> = fold.optional(prop::URL_REPLACEMENTS); - fold.finish()?; - ::std::result::Result::Ok(Self { - task: SettingsTask { - output: read_task_output.expect("`task.output` has a declared default, so the fold has already reported any absence"), - }, - ci: read_ci, - either: read_either, - exclude: read_exclude, - jobs: read_jobs.expect("`jobs` has a declared default, so the fold has already reported any absence"), - log_format: read_log_format.expect("`log_format` has a declared default, so the fold has already reported any absence"), - log_level: read_log_level.expect("`log_level` has a declared default, so the fold has already reported any absence"), - r#match: read_match.expect("`match` has a declared default, so the fold has already reported any absence"), - path: read_path, - ports: read_ports.expect("`ports` has a declared default, so the fold has already reported any absence"), - stash: read_stash.expect("`stash` has a declared default, so the fold has already reported any absence"), - timeout: read_timeout, - trusted: read_trusted.expect("`trusted` has a declared default, so the fold has already reported any absence"), - url_replacements: read_url_replacements, - }) - } -} diff --git a/config-build/tests/optional_aliases.rs b/config-build/tests/optional_aliases.rs deleted file mode 100644 index 9a9683b32..000000000 --- a/config-build/tests/optional_aliases.rs +++ /dev/null @@ -1,70 +0,0 @@ -use usage_config_build::source_of_spec; - -fn generated(settings: &str) -> String { - source_of_spec( - &format!("name \"ex\"\nbin \"ex\"\nconfig {{\n{settings}\n}}\n"), - "ex.usage.kdl", - ) - .expect("registry") -} - -#[test] -fn aliases_reach_the_runtime_registry() { - let source = generated( - r#"prop "jobs" type="uint" { - alias "parallelism" "threads" -}"#, - ); - assert!( - source.contains(r#"aliases: &["parallelism", "threads"]"#), - "{source}" - ); -} - -#[test] -fn explicit_optionality_overrides_default_inference() { - let source = generated( - r#"prop "required_without_default" type="string" optional=#false -prop "optional_with_default" type="string" default="value" optional=#true"#, - ); - assert!( - source.contains("pub required_without_default: String"), - "{source}" - ); - assert!( - source.contains("fold.required(prop::REQUIRED_WITHOUT_DEFAULT)"), - "{source}" - ); - assert!( - source.contains("pub optional_with_default: Option"), - "{source}" - ); - assert!( - source.contains("fold.optional(prop::OPTIONAL_WITH_DEFAULT)"), - "{source}" - ); -} - -#[test] -fn ambiguous_aliases_are_refused_together() { - let error = source_of_spec( - r#"name "ex" -bin "ex" -config { - prop "jobs" { alias "shared" } - prop "threads" { alias "shared" "jobs" } -} -"#, - "ex.usage.kdl", - ) - .expect_err("ambiguous names"); - let text = error.to_string(); - assert!( - text.contains("`shared` names both `jobs` and `threads`"), - "{text}" - ); - assert!( - text.contains("`jobs` names both `jobs` and `threads`"), - "{text}" - ); -} diff --git a/config-build/tests/refusals.rs b/config-build/tests/refusals.rs deleted file mode 100644 index 84f2e17e7..000000000 --- a/config-build/tests/refusals.rs +++ /dev/null @@ -1,701 +0,0 @@ -//! What a registry has to be refused for, and the one thing it must stay identical to. -//! -//! A build script is where strictness belongs: the alternative to refusing a declaration that -//! cannot mean what it says is a warning on every run of a shipped binary, for a mistake only the -//! author of the spec can fix. - -use usage_config_build::{source, source_of_spec, Error}; - -/// A spec with `config { … }` around the settings under test. -fn spec(settings: &str) -> String { - format!("name \"mycli\"\nbin \"mycli\"\nconfig {{\n{settings}\n}}\n") -} - -/// Every problem a spec's settings are refused for. -fn problems(settings: &str) -> Vec { - match source_of_spec(&spec(settings), "mycli.usage.kdl") { - Ok(generated) => panic!("should have been refused, generated:\n{generated}"), - Err(Error::Registry(problems)) => problems, - Err(other) => panic!("refused for the wrong reason: {other}"), - } -} - -#[test] -fn the_checked_in_registry_is_what_the_generator_produces() { - // The generated file is checked in so that `cargo test` compiles it — which is the only way to - // know the generator emits code rather than plausible text. That only works while the two - // agree, so this is the test that fails when a change to the emitter is not regenerated: - // - // cargo run -p usage-config-build --example gen > config-build/tests/golden/settings.rs - let generated = source("tests/fixtures/hk.usage.kdl").expect("the fixture is valid"); - let checked_in = std::fs::read_to_string("tests/golden/settings.rs").expect("golden"); - assert_eq!( - generated, checked_in, - "tests/golden/settings.rs is stale; regenerate it with the command in this test" - ); -} - -#[test] -fn the_name_in_the_header_stays_on_its_own_comment() { - // The header says which spec the file came from, in a *line* comment — and the name is a file - // name, or whatever a caller hands to `source_of_spec`. A newline in it ended that comment and - // everything after it read as code, in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"jobs\" type=\"uint\""), - "mycli.usage.kdl\nstruct Oops;", - ) - .expect("should generate"); - let first = generated.lines().next().unwrap_or_default(); - assert!(first.starts_with("// @generated"), "{generated}"); - assert!( - first.contains("mycli.usage.kdl struct Oops;"), - "{generated}" - ); - // Nothing escaped onto a line of its own. - assert!( - !generated - .lines() - .any(|line| line.starts_with("struct Oops;")), - "the name ended its own comment:\n{generated}" - ); -} - -#[test] -fn an_included_spec_is_watched_and_read() { - // `include` is how a CLI with many settings keeps them in a file of their own, which makes that - // file the one most likely to be edited — and watching only the file the build script names left - // editing the settings rebuilding nothing, so the generated registry went stale in silence. - let watched = usage_config_build::watched("tests/fixtures/split.usage.kdl").expect("parses"); - let names: Vec = watched - .iter() - .map(|p| { - p.file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!( - names, - vec!["split.usage.kdl", "split-settings.usage.kdl"], - "the spec and what it included" - ); - - // And the settings in the included file are the ones generated, which is the other half of it. - let generated = source("tests/fixtures/split.usage.kdl").expect("generates"); - assert!(generated.contains("PropMeta::new(\"jobs\""), "{generated}"); - assert!(generated.contains("SPLIT_JOBS"), "{generated}"); - - // The list `generate_to` *prints* is the list it returns, which is the only way a test in this - // process can see it: `println!` to cargo goes nowhere it can read. - let out = std::env::temp_dir() - .join(format!("usage_config_build_watch_{}", std::process::id())) - .join("settings.rs"); - let printed = - usage_config_build::generate_to("tests/fixtures/split.usage.kdl", &out).expect("generates"); - assert_eq!( - printed, watched, - "what it watched is what it said it watched" - ); - let _ = std::fs::remove_dir_all(out.parent().unwrap_or(&out)); -} - -#[test] -fn a_rename_to_a_setting_that_is_not_there_is_refused() { - // The runtime folds an old name into its replacement by looking the replacement up. Naming one - // that does not exist leaves every value written under the old key silently unread. - let problems = problems(" prop \"old\" renamed_to=\"new\"\n prop \"jobs\" type=\"uint\""); - assert_eq!( - problems, - vec!["`old` is renamed to `new`, which is not a setting"] - ); -} - -#[test] -fn renames_that_go_round_in_a_circle_are_refused() { - // A cycle makes `lookup` give up and answer `None`, so both settings become unreachable rather - // than wrong — the hardest kind of bug to notice, and one only the spec's author can fix. - let problems = problems( - " prop \"a\" renamed_to=\"b\"\n \ - prop \"b\" renamed_to=\"c\"\n \ - prop \"c\" renamed_to=\"a\"", - ); - assert!( - problems.iter().any(|p| p.contains("form a cycle")), - "{problems:?}" - ); - // Named from each end, because the author has to find the loop and any of its names will do. - assert_eq!(problems.len(), 3, "{problems:?}"); -} - -#[test] -fn an_old_name_with_a_default_of_its_own_is_refused() { - // The merge folds a rename to its target, whose default is the one that shows up — so a - // default on the old name is seeded where nothing reads it. Left to run time this is a - // warning on every run of a shipped binary. - let problems = problems( - " prop \"concurrency\" type=\"uint\" default=8 renamed_to=\"jobs\"\n \ - prop \"jobs\" type=\"uint\" default=4", - ); - assert_eq!( - problems, - vec![ - "`concurrency` is renamed to `jobs` and declares a default of its own: put the \ - default on the setting that replaced it" - ] - ); -} - -#[test] -fn an_old_name_with_a_list_default_is_refused_too() { - // The same mistake in the other spelling. A list default is a child node rather than a value, - // and returning it early skipped the check above — so this was the one shape of an alias - // carrying a default that was generated instead of refused. - let problems = problems( - " prop \"old_ports\" type=\"list\" renamed_to=\"ports\" {\n \ - default 80 443\n }\n \ - prop \"ports\" type=\"list\"", - ); - assert_eq!( - problems, - vec![ - "`old_ports` is renamed to `ports` and declares a default of its own: put the default \ - on the setting that replaced it" - ] - ); -} - -#[test] -fn a_default_the_choices_do_not_allow_is_refused() { - // At run time this is seeded as the bottom layer and then refused by the same check every other - // value goes through — a warning on every run of a shipped binary, for a mistake only the author - // of the spec can fix. So it is refused where the author will see it. - assert_eq!( - problems( - " prop \"stash\" type=\"string\" default=\"svn\" {\n \ - choices {\n choice \"git\"\n choice \"none\"\n }\n }" - ), - vec!["`stash` defaults to `svn`, which is not one of the values it allows: git, none"] - ); - - // And a list default is held to them item by item, the way the values themselves are. - assert_eq!( - problems( - " prop \"skip\" type=\"list\" {\n default \"lint\" \"deploy\"\n \ - choices {\n choice \"lint\"\n choice \"test\"\n }\n }" - ), - vec!["`skip` defaults to `deploy`, which is not one of the values it allows: lint, test"] - ); -} - -#[test] -fn a_value_the_declared_type_cannot_read_is_refused_wherever_it_is_declared() { - // `choice 1` under `type="bool"`, which reads booleans and their spellings and not numbers: a - // choice nobody can ever pick, so *every* value the setting is given would be refused at run - // time. It was caught only by accident, through the default not matching those choices — a - // message that blames the wrong end of the mistake. - assert_eq!( - problems( - " prop \"colour\" type=\"bool\" default=#true {\n \ - choices {\n choice 1\n choice 0\n }\n }" - ), - vec![ - "`colour` has a choice `1`, which is not a boolean", - "`colour` has a choice `0`, which is not a boolean" - ] - ); - - // And a *default* the type cannot read, which nothing else catches at all: it is seeded straight - // into the resolution, passing through no coercion, and then read by whatever holds it. - assert_eq!( - problems(" prop \"colour\" type=\"bool\" default=1"), - vec!["`colour` defaults to `1`, which is not a boolean"] - ); - - // A choice that is merely unreachable is the same mistake in a quieter form: it appears in the - // docs as a value one may set, and no value can ever be it. - assert_eq!( - problems( - " prop \"jobs\" type=\"uint\" default=1 {\n \ - choices {\n choice 1\n choice \"lots\"\n }\n }" - ), - vec!["`jobs` has a choice `lots`, which is not a non-negative integer"] - ); -} - -#[test] -fn the_spec_and_the_runtime_write_a_value_the_same_way() { - // Three crates render a value and all three have to agree, because a default is coerced by the - // *spec's* parser and its choices are read by the *runtime's* — so one character of difference - // refused a `default=1.0` beside a `choice 1.0`, written identically. usage-lib cannot depend on - // usage-config, so the rule exists in both; this is the one place that can see them at once, and - // it is here so that a change to either is a failure rather than a surprise. - for value in [ - 0.0_f64, - 1.0, - -1.0, - 0.5, - 1.25, - 1e3, - 1e300, - f64::MIN, - f64::MAX, - ] { - assert_eq!( - usage::spec::config::SpecConfigValue::Float(value).display(), - usage_config::Value::Float(value).display(), - "the two crates write {value} differently" - ); - } -} - -#[test] -fn a_list_default_under_a_type_with_no_items_is_one_value() { - // `any` takes a list perfectly well, and a scalar choice is not one however many items it has. - // Checked item by item — which is right only where the type declares items — `default 1 2` beside - // `choice 1` and `choice 2` passed, and the whole list is what a seeded default *is*: refused by - // its own choices at run time, and never checked again. - assert_eq!( - problems( - " prop \"level\" type=\"int|string\" {\n default 1 2\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `1,2`, which is not one of the values it allows: 1, 2"] - ); - - // A union whose *first arm* is a list is still a union: what is emitted for it is `Ty::Any`, - // which compares a value whole — so asking the spec's `simplified` type, which collapses a union - // to that first arm, accepted a default the generated registry would refuse. - assert_eq!( - problems( - " prop \"level\" type=\"list|uint\" {\n default 1 2\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `1,2`, which is not one of the values it allows: 1, 2"] - ); - - // Where the type *does* declare items, each of them is one of the choices, as before — including - // through an `option`, which the runtime looks through as well. - for ty in ["list", "set", "option>"] { - source_of_spec( - &spec(&format!( - " prop \"ports\" type=\"{ty}\" {{\n default 1 2\n \ - choices {{\n choice 1\n choice 2\n }}\n }}" - )), - "mycli.usage.kdl", - ) - .unwrap_or_else(|err| panic!("each item is one of them, for `{ty}`: {err}")); - } -} - -#[test] -fn a_value_reads_here_the_way_it_reads_at_run_time() { - // The generator and the runtime have to agree about what a value *is*, or this crate accepts a - // registry the registry itself refuses. They drifted the moment one of them learned that a - // whole-number float is `1.0` and not `1`: `default="1"` beside `choice 1.0` was accepted here - // and refused there — and a seeded default is never checked again, so it would simply have been - // the effective value. - assert_eq!( - problems( - " prop \"scale\" type=\"string\" default=\"1\" {\n \ - choices {\n choice 1.0\n }\n }" - ), - vec!["`scale` defaults to `1`, which is not one of the values it allows: 1.0"] - ); - // And the spelling the spec wrote is the one that is one of them. - source_of_spec( - &spec( - " prop \"scale\" type=\"string\" default=\"1.0\" {\n \ - choices {\n choice 1.0\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("`1.0` is `1.0`"); -} - -#[test] -fn a_default_no_choice_can_be_is_refused_however_it_is_written() { - // `default="3"` beside `choice 1`: written differently, and still not one of them once both are - // read as the declared type. Skipping the pair because their literals differ let this through on - // the assumption that resolution would refuse it later — and a *seeded* default never passes the - // check a supplied value does, so it would have become the effective value with nothing said. - assert_eq!( - problems( - " prop \"level\" type=\"int\" default=\"3\" {\n \ - choices {\n choice 1\n choice 2\n }\n }" - ), - vec!["`level` defaults to `3`, which is not one of the values it allows: 1, 2"] - ); -} - -#[test] -fn a_default_written_unlike_its_choices_is_still_one_of_them() { - // `choice "yes"` under `type="bool"`: read as the declared type — through the same `Ty::coerce` - // the run time uses — `#true` *is* one of them, and refusing a spec that resolves perfectly well - // would be the worse mistake of the two. - let generated = source_of_spec( - &spec( - " prop \"colour\" type=\"bool\" default=#true {\n \ - choices {\n choice \"yes\"\n choice \"no\"\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!(generated.contains("Const::Bool(true)"), "{generated}"); - assert!(generated.contains("Const::Str(\"yes\")"), "{generated}"); - - // A union coerces nothing, so its choice and its default stay an integer and a string — and only - // what they are written as can say whether they are the same. Refusing here would refuse a spec - // the run time accepts, which is the worse way round to be wrong. - source_of_spec( - &spec( - " prop \"level\" type=\"int|string\" default=\"1\" {\n \ - choices {\n choice 1\n choice 2\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("a union takes what it is given"); - - // And inside a list it is the *item's* type that reads them: read as the list, `yes` is a - // one-item list and matches nothing. - let generated = source_of_spec( - &spec( - " prop \"flags\" type=\"list\" {\n default \"yes\"\n \ - choices {\n choice #true\n choice #false\n }\n }", - ), - "mycli.usage.kdl", - ) - .expect("`yes` is `true` once an item is read as a bool"); - // And it is emitted as the boolean it *is*: a default is seeded straight into the resolution - // with no coercion, so emitting the string it was written as would fail the first read of a - // registry this crate had just accepted. - assert!( - generated.contains("Const::List(&[::usage_config::Const::Bool(true)])"), - "{generated}" - ); - // The choices keep the spelling the spec gave them: they are documentation as much as values, - // and the comparison reads them when it needs to. - assert!( - generated.contains("choices: &[::usage_config::Const::Bool(true)"), - "{generated}" - ); -} - -#[test] -fn a_default_declared_twice_is_refused() { - // `default=1` beside a `default 80 443` node. The spec takes both — I checked, rather than - // trusting the comment I had written saying it did not — and there is no reading of a property - // that has two defaults. Generated, the scalar was dropped and nothing said so. - assert_eq!( - problems( - " prop \"ports\" type=\"list\" default=1 {\n default 80 443\n }" - ), - vec!["`ports` declares a default twice, as `default=` and as a `default` node: keep one"] - ); -} - -#[test] -fn a_key_that_cannot_be_a_name_is_refused() { - // KDL takes `prop ""` perfectly happily, and it generated `pub const : PropId = …` — code the - // *adopter's* crate fails to compile, in a file they did not write. - assert_eq!( - problems(" prop \"\" type=\"string\""), - vec!["a setting with no name cannot be generated"] - ); - // And a piece of a dotted key with no name in it: empty, - assert_eq!( - problems(" prop \"task..output\" type=\"string\""), - vec![ - "`task..output` has a part with no name in it: every piece of a dotted key needs an \ - ASCII letter or digit" - ] - ); - // or nothing but separators, where every character becomes an underscore. That generates an - // *anonymous* const — legal Rust, and unreferenceable, so the code naming it does not compile. - assert_eq!( - problems(" prop \"-\" type=\"string\""), - vec![ - "`-` has a part with no name in it: every piece of a dotted key needs an ASCII letter \ - or digit" - ] - ); -} - -#[test] -fn a_key_that_holds_a_newline_stays_on_its_doc_comment() { - // A doc comment is a line. A key can hold a newline as easily as help text can, and the rest of - // it then reads as code — in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"a\\nb\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - // A doc comment needs no escapes, only a single line: the newline becomes a space. - assert!(generated.contains("/// `a b`"), "{generated}"); - for line in generated.lines() { - let trimmed = line.trim_start(); - assert!( - trimmed.starts_with("///") || trimmed.starts_with("//") || !trimmed.starts_with("b`"), - "the key ended its own comment:\n{generated}" - ); - } -} - -#[test] -fn a_key_with_no_ascii_in_it_is_refused() { - // `é` is a letter, and `ident_of` builds names from ASCII — so it became `_`, and the promised - // `prop::` const was an anonymous one nothing can refer to. Rust would take `É` as an identifier; - // what an arbitrary letter uppercases to is not something to bet a generated name on. - assert_eq!( - problems(" prop \"é\" type=\"string\""), - vec![ - "`é` has a part with no name in it: every piece of a dotted key needs an ASCII letter \ - or digit" - ] - ); -} - -#[test] -fn a_parser_nothing_implements_is_refused() { - // Not dropped and not carried: a spec naming a parser that does not exist would have its - // values split by a rule nobody wrote, so a layer handing over text produces one item where - // the author meant several. - let problems = problems(" prop \"paths\" type=\"list\" parse=\"split_on_vibes\""); - assert_eq!(problems.len(), 1, "{problems:?}"); - assert!( - problems[0].starts_with("`paths` names the parser `split_on_vibes`, which is not one of: "), - "{problems:?}" - ); - // The message lists what there is, because the author's next move is to pick one. - assert!(problems[0].contains("list_by_comma"), "{problems:?}"); -} - -#[test] -fn a_table_keyed_by_something_a_file_cannot_spell_is_refused() { - // Keys in TOML and JSON are text. A `map` would be honoured by handing the CLI string - // keys — quietly, which is the worst way to not support something. - let problems = problems(" prop \"ports\" type=\"map\""); - assert_eq!( - problems, - vec![ - "`ports` is a `map`, and a settings table's keys are text: \ - write `map`" - ] - ); -} - -#[test] -fn two_keys_that_generate_one_const_are_refused() { - // `task.output` and `task_output` are two settings and one `prop::TASK_OUTPUT`. Generating - // both would not compile — a good outcome reported badly, in a file the author did not write. - let problems = problems( - " prop \"task.output\" type=\"string\"\n prop \"task_output\" type=\"string\"", - ); - assert_eq!( - problems, - vec![ - "`task_output` and `task.output` both generate `prop::TASK_OUTPUT`: rename one of them" - ] - ); -} - -#[test] -fn everything_wrong_is_reported_at_once() { - // Three mistakes, one build. Reporting the first would make fixing a registry a sequence of - // builds, which is how the fleet's own generators behave and why nobody enjoys editing them. - let problems = problems( - " prop \"a\" parse=\"nope\"\n \ - prop \"b\" renamed_to=\"nowhere\"\n \ - prop \"c\" type=\"map\"", - ); - assert_eq!(problems.len(), 3, "{problems:?}"); - let all = problems.join("\n"); - assert!(all.contains("`a` names the parser `nope`"), "{all}"); - assert!(all.contains("`b` is renamed to `nowhere`"), "{all}"); - assert!(all.contains("`c` is a `map`"), "{all}"); -} - -#[test] -fn a_spec_with_no_settings_is_not_an_empty_registry() { - // A build script that generates an empty registry has been pointed at the wrong file, and the - // failure it would otherwise produce is "no such setting" for every read in the CLI. - let err = source_of_spec("name \"mycli\"\nbin \"mycli\"\n", "mycli.usage.kdl") - .expect_err("should be refused"); - assert!(matches!(err, Error::NoSettings), "{err}"); - assert_eq!(err.to_string(), "this spec declares no `config` settings"); -} - -#[test] -fn a_spec_that_does_not_parse_says_so_as_the_parser_put_it() { - // Nothing here re-words a KDL error: the spec's own parser has the line and column. - let err = source_of_spec("config {\n prop\n", "mycli.usage.kdl").expect_err("refused"); - assert!(matches!(err, Error::Spec(_)), "{err}"); -} - -#[test] -fn a_setting_that_is_also_a_group_of_settings_is_refused() { - // `python` as a setting and `python.compile` as another: one field name with two things to be, - // a value and a table. The spec can say it; no struct can hold it. - let problems = - problems(" prop \"python\" type=\"string\"\n prop \"python.compile\" type=\"bool\""); - assert_eq!( - problems, - vec![ - "`python` is a setting and a group of settings: it cannot be both a value and a table" - ] - ); -} - -#[test] -fn two_keys_that_generate_one_field_are_refused() { - // The `prop::` consts do not catch these: `foo-bar.x` and `foo_bar.y` are two distinct consts - // and one field, because a dash and an underscore are the same character in an identifier. The - // check has to be on the names that are emitted, not on the segments they came from. - assert_eq!( - problems(" prop \"foo-bar.x\" type=\"string\"\n prop \"foo_bar.y\" type=\"string\""), - vec![ - "`foo-bar` and `foo_bar` both generate the field `foo_bar`: rename one of them", - "`foo-bar` and `foo_bar` are both groups named `SettingsFooBar`: rename one of them" - ] - ); - - // And two groups whose *type* names collide, which a field name does not: a struct name loses - // the case of its first letter. - assert_eq!( - problems(" prop \"a.x\" type=\"string\"\n prop \"A.y\" type=\"string\""), - vec!["`A` and `a` are both groups named `SettingsA`: rename one of them"] - ); -} - -#[test] -fn two_groups_that_generate_one_struct_are_refused_however_deep_they_are() { - // Struct names are built by concatenation and all declared in one module, so `http.client` and - // `http_client` arrive at `SettingsHttpClient` from different depths. Compared among siblings - // they are not siblings at all, and the adopter's crate got two structs with one name. - assert_eq!( - problems( - " prop \"http.client.timeout\" type=\"string\"\n \ - prop \"http_client.retries\" type=\"uint\"" - ), - vec![ - "`http.client` and `http_client` are both groups named `SettingsHttpClient`: \ - rename one of them" - ] - ); -} - -#[test] -fn a_key_that_needs_escaping_is_escaped_where_it_becomes_a_literal() { - // `a"b` is a nameable key — it has letters in it — and the generated reader quotes the key into - // an `expect` message. Interpolated raw, the quote ended that literal early and the rest of the - // message read as code, in a file the adopter did not write. - let generated = source_of_spec( - &spec(" prop \"a\\\"b\" type=\"uint\" default=1"), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("`a\\\"b` has a declared default"), - "{generated}" - ); - // The same key in `PropMeta`, which was escaped all along, and in a doc comment, where it needs - // no escape and only one line. - assert!( - generated.contains("PropMeta::new(\"a\\\"b\""), - "{generated}" - ); - assert!(generated.contains("/// (`a\"b`)"), "{generated}"); -} - -#[test] -fn a_setting_named_after_the_readers_own_bindings_is_still_a_setting() { - // `fold` is what the generated reader calls its fold, and an unprefixed local shadowed it: every - // read after it, and `fold.finish()`, stopped compiling. Nothing about `fold` is special to a - // config file, so the fix is on this side — every local is prefixed. - let generated = source_of_spec( - &spec(" prop \"fold\" type=\"string\"\n prop \"resolved\" type=\"bool\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("let read_fold: Option ="), - "{generated}" - ); - assert!( - generated.contains("let read_resolved: Option ="), - "{generated}" - ); - // And the fields keep the names the config file uses. - assert!( - generated.contains("pub fold: Option,"), - "{generated}" - ); -} - -#[test] -fn a_name_is_built_from_ascii_wherever_it_is_built() { - // A Rust identifier is not "any alphanumeric character": `½` is one and cannot appear in an - // identifier, and a Unicode digit cannot start one. The `prop::` consts were ASCII already, so - // keeping more in the fields made one key produce names from two alphabets — a const the adopter - // could compile beside a field they could not. - let generated = source_of_spec( - &spec(" prop \"caf\u{e9}.si\u{bd}e\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("pub const CAF__SI_E: PropId"), - "{generated}" - ); - assert!(generated.contains("pub caf_: SettingsCaf,"), "{generated}"); - assert!( - generated.contains("pub si_e: Option,"), - "{generated}" - ); - // Every line that *declares* a name is ASCII. The key itself is not — it appears as a string - // literal in `PropMeta`, and as data it is exactly what the spec said. - for line in generated.lines() { - let declares = line.trim_start(); - let declares = declares.starts_with("pub const ") - || declares.starts_with("pub struct ") - || declares.starts_with("pub fn ") - || declares.starts_with("let ") - || (declares.starts_with("pub ") && declares.contains(':')); - assert!( - !declares || line.is_ascii(), - "a generated name kept a character no identifier can hold: {line}" - ); - } -} - -#[test] -fn a_key_that_starts_with_a_digit_is_a_field_all_the_same() { - // `2fa` is a real name for a real setting, and neither a const nor a field can start with a - // digit — so both get the same prefix rather than one of them being invalid Rust. - let generated = source_of_spec( - &spec(" prop \"2fa.token\" type=\"string\""), - "mycli.usage.kdl", - ) - .expect("should generate"); - assert!( - generated.contains("pub const _2FA_TOKEN: PropId"), - "{generated}" - ); - assert!(generated.contains("pub _2fa: Settings2fa,"), "{generated}"); -} - -#[test] -fn a_key_rust_has_no_spelling_for_is_refused() { - // Most keywords are fine as fields — `type` is written `r#type`, and settings called that are - // perfectly ordinary. Four are not: there is no `r#self`, so the field cannot be written at all. - assert_eq!( - problems(" prop \"self\" type=\"string\""), - vec!["`self` cannot be a field: `self` is a keyword Rust has no spelling for"] - ); - - // And the same for a group, whose name becomes a field too. - assert_eq!( - problems(" prop \"crate.name\" type=\"string\""), - vec!["`crate` cannot be a field: `crate` is a keyword Rust has no spelling for"] - ); -} diff --git a/config/Cargo.toml b/config/Cargo.toml index bd0e03bc5..76730fd04 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -10,9 +10,9 @@ repository = { workspace = true } authors = { workspace = true } license = { workspace = true } -# Nothing here parses KDL: a spec is read at *build* time by usage-config-build, which emits -# the registry as consts. This crate is what runs in the CLI, so it carries no spec parser -# and no format reader it was not asked for. +# Nothing here parses KDL: `#[derive(usage::Config)]` emits the registry as consts at compile +# time. This crate is what runs in the CLI, so it carries no spec parser and no format reader +# it was not asked for. [dependencies] toml = { version = "1.0", optional = true } serde_json = { version = "1", optional = true } diff --git a/config/src/lib.rs b/config/src/lib.rs index 8ac7f8f35..f4a2f6a3f 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -8,9 +8,9 @@ //! declaration of a setting and the code that resolves it are two separate things that have //! to be kept in step by hand. //! -//! Here they are one thing. `usage-config-build` reads the spec's `config` block at build -//! time and emits a [`Registry`] of consts; this crate resolves values against it. Nothing -//! here parses KDL, so a CLI carries a resolver rather than a spec parser. +//! Here they are one thing. `#[derive(usage::Config)]` reads the settings struct and emits a +//! [`Registry`] of consts beside it; this crate resolves values against it. Nothing here +//! parses KDL, so a CLI carries a resolver rather than a spec parser. //! //! # What it guarantees //! @@ -33,7 +33,7 @@ //! ``` //! use usage_config::{resolve, Const, EnvLayer, Layers, PropMeta, Registry, Ty, Value}; //! -//! // Normally generated from the spec by usage-config-build. +//! // Normally generated from the settings struct by `#[derive(usage::Config)]`. //! static PROPS: &[PropMeta] = &[PropMeta { //! envs: &["MYCLI_JOBS"], //! default: Some(Const::Int(4)), diff --git a/config/src/registry.rs b/config/src/registry.rs index 2be404b38..b553a733c 100644 --- a/config/src/registry.rs +++ b/config/src/registry.rs @@ -1,6 +1,6 @@ //! The settings a CLI has, as a generated table. //! -//! `usage-config-build` reads the spec's `config` block and emits a `static` of these, so at +//! `#[derive(usage::Config)]` reads the settings struct and emits a `static` of these, so at //! runtime a registry is a slice — no parsing, no map to build, and a [`PropId`] that indexes //! it directly. A merge over a hundred settings therefore never hashes a key, which is what //! makes resolving the whole struct at once cheap enough to do eagerly. @@ -323,7 +323,7 @@ impl Registry { /// Bounded by the number of settings there are, so a registry whose renames form a cycle stops /// rather than following them forever — the same guard [`Registry::lookup`] uses, and for the /// same reason: this is an authoring mistake, and hanging is a worse way to report one than - /// nothing at all. `usage-config-build` refuses such a registry outright. + /// nothing at all. The derive refuses such a declaration outright. pub fn deprecation(&self, key: &str) -> Option<&'static str> { let mut current = self .props diff --git a/config/src/ty.rs b/config/src/ty.rs index 7103c6b24..033c6f885 100644 --- a/config/src/ty.rs +++ b/config/src/ty.rs @@ -1,8 +1,8 @@ //! The type a setting was declared with, and reading a raw string as it. //! //! A trimmed-down runtime form of the spec's type grammar: enough to coerce and validate, -//! with none of the parsing. `usage-config-build` turns `list` into -//! `Ty::List(&Ty::String)` at build time, so the shape a value must take costs a match +//! with none of the parsing. The derive turns a `Vec` field into +//! `Ty::List(&Ty::String)` at compile time, so the shape a value must take costs a match //! rather than a parse. //! //! Every layer that reads text — the environment, an `.npmrc`, a git config — hands over a diff --git a/conformance/src/config.rs b/conformance/src/config.rs index a60edf697..dda1cf3e0 100644 --- a/conformance/src/config.rs +++ b/conformance/src/config.rs @@ -9,8 +9,9 @@ //! An argv vector carries a KDL spec, because parsing argv is a question about a spec. A resolution //! is not: it is a question about a *registry* — keys, types, defaults, merge policies — which a //! CLI's build step produces from the spec long before anything is resolved. So a vector describes -//! the registry directly in KDL, usage's canonical interchange format. How a spec becomes a -//! registry is `usage-config-build`'s question, and its own golden test answers it. +//! the registry directly in KDL, usage's canonical interchange format. How a *declaration* +//! becomes a registry is the `usage::Config` derive's question, and `derive_config.rs` answers +//! it. //! //! Nothing here touches the filesystem, the process environment, or a subprocess. A file layer is a //! description of what a file said, which is all the merge ever sees of one. diff --git a/conformance/tests/config_value_display.rs b/conformance/tests/config_value_display.rs new file mode 100644 index 000000000..10274efe0 --- /dev/null +++ b/conformance/tests/config_value_display.rs @@ -0,0 +1,29 @@ +//! One value, written the same way by the spec parser and by the resolver. +//! +//! A `config` block's default is coerced by the *spec's* parser and its choices are read by the +//! *runtime's*, so one character of difference refused a `default=1.0` beside a `choice 1.0` +//! written identically. `usage-lib` cannot depend on `usage-config` — a CLI carries a resolver, +//! not a spec parser — so the rule exists in both. This crate depends on both, which makes it +//! the one place that can see them at once, so a change to either is a failure rather than a +//! surprise found later in a fleet CLI. + +#[test] +fn the_spec_and_the_runtime_write_a_value_the_same_way() { + for value in [ + 0.0_f64, + 1.0, + -1.0, + 0.5, + 1.25, + 1e3, + 1e300, + f64::MIN, + f64::MAX, + ] { + assert_eq!( + usage::spec::config::SpecConfigValue::Float(value).display(), + usage_config::Value::Float(value).display(), + "the two crates write {value} differently" + ); + } +} diff --git a/corpus/config/README.md b/corpus/config/README.md index 07f3682ba..0ca95aa4e 100644 --- a/corpus/config/README.md +++ b/corpus/config/README.md @@ -19,8 +19,8 @@ about a spec. A resolution is not: it is a question about a **registry** — key types, defaults, merge policies — which a CLI's build step produces from its spec long before anything is resolved. -So a vector describes the registry directly in KDL. How a spec becomes a registry -is `usage-config-build`'s question, and its own tests answer it. +So a vector describes the registry directly in KDL. How a _declaration_ becomes a +registry is the `usage::Config` derive's question, and its own tests answer it. ## Format diff --git a/docs/rust/settings.md b/docs/rust/settings.md index 6278e33b2..2dd06ae5a 100644 --- a/docs/rust/settings.md +++ b/docs/rust/settings.md @@ -134,9 +134,13 @@ documented one. The adopter's whole drift test is one line: assert_eq!(Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), Vec::::new()); ``` -## Spec-first instead +## The spec block -A CLI that would rather declare settings in KDL keeps the other direction: -[usage-config-build](https://docs.rs/usage-config-build) reads the spec's `config` block at -build time and generates the registry _and_ the typed `Settings` struct. The two backends -emit the same registry shape, so nothing downstream can tell which way a CLI chose. +The struct is the only declaration. `Settings::spec_kdl()` renders it as the spec's +`config { prop … }` block, and `#[usage(config = Settings)]` on the `Cli` root puts that +block in the emitted spec — so docs, the JSON schema, and the `config_keys` / `config_values` +completers read settings declared in Rust exactly as they read ones written in KDL. + +There is no second, KDL-first backend to choose between: a `build.rs` that generated the +registry from the spec was a third description of every setting, which is the drift this +derive exists to remove. diff --git a/docs/spec/resolution.md b/docs/spec/resolution.md index 940c07af9..d41174c76 100644 --- a/docs/spec/resolution.md +++ b/docs/spec/resolution.md @@ -190,10 +190,10 @@ cargo test -p usage-conformance --test config ## Implementations [`usage-config`](https://github.com/jdx/usage/tree/main/config) resolves; it carries -no spec parser, so a CLI ships a resolver rather than a KDL reader. -[`usage-config-build`](https://github.com/jdx/usage/tree/main/config-build) reads the -spec at build time and emits the registry as consts, along with the typed `Settings` -struct a CLI reads — so a setting that is declared is a setting that resolves, with -no second declaration to keep in step. It takes nothing from `usage-lib` beyond the -spec parser: the build script that reads your settings does not compile a markdown -renderer, an expression evaluator, or a second argument parser to do it. +no spec parser, so a CLI ships a resolver rather than a KDL reader. A Rust CLI declares +its settings with [`#[derive(usage::Config)]`](/rust/settings) on the typed `Settings` +struct it already holds, and the derive emits the registry as consts beside it — so a +setting that is declared is a setting that resolves, with no second declaration to keep +in step, and the spec's `config` block is rendered from the same declaration. Nothing +reads KDL to do it: the declaration is Rust, so an adopter's build compiles a proc macro +rather than a spec parser. diff --git a/lib/Cargo.toml b/lib/Cargo.toml index ca2f47719..5f9948e05 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -39,7 +39,7 @@ thiserror = "2" versions = "7" # No `xx`: three helpers (a lazy-regex macro, `file::read_to_string`, `check_status`) were # worth 21 crates in every dependent's tree — duct, homedir, nix, rand and their libc -# subtrees — including `usage-config-build`, which runs in an adopter's build script. +# subtrees — including one that ran in an adopter's build script. # `LazyLock` and `std::fs` cover all three, so nothing here needs a process library. usage-validation = { workspace = true, optional = true } diff --git a/lib/src/spec/config.rs b/lib/src/spec/config.rs index c6adb6b67..eeba5b5ae 100644 --- a/lib/src/spec/config.rs +++ b/lib/src/spec/config.rs @@ -154,7 +154,8 @@ impl SpecConfigValue { // is also what `usage-config` writes a float as, and the two have to agree: a spec's // `default=1.0` on a string-typed prop is coerced *here* and its `choice 1.0` is read // *there*, so a difference of one character refused a default and a choice that were - // written identically. `usage-config-build` has a test that holds the two together. + // written identically. `usage-conformance` has a test that holds the two together, + // because it is the one crate that can see both. Self::Float(f) => { let text = f.to_string(); match f.is_finite() && !text.contains(['.', 'e', 'E']) { From e4a73cf13ab3fd53502b93c3e03839a612d8e563 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:44:49 +0000 Subject: [PATCH 12/28] refactor(conformance): build a corpus prop on PropMeta::new The corpus restated twelve metadata defaults to say "unset", so every field the registry's metadata gained had to be added here to change nothing. It states what a vector states and leaves the rest to `PropMeta::new`. Co-Authored-By: Claude Opus 5 --- conformance/src/config.rs | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/conformance/src/config.rs b/conformance/src/config.rs index dda1cf3e0..aae700ac0 100644 --- a/conformance/src/config.rs +++ b/conformance/src/config.rs @@ -663,9 +663,13 @@ fn registry_of(settings: &[Setting]) -> Result { let mut props = Vec::with_capacity(settings.len()); for setting in settings { let ty = ty_of(&setting.r#type)?; + // Only what a vector states; everything else is `PropMeta::new`'s default, so a field + // added to the registry's metadata does not have to be restated here to say "unset". + // + // No flags in particular: a vector describes what reaches a *resolution*, and which flag + // declares a setting does not change one. That is documentation, and the drift test that + // holds it against a CLI's own bindings is that CLI's test rather than this corpus's. props.push(PropMeta { - key: leak(&setting.key), - ty, default: setting.default.as_ref().map(const_of).transpose()?, merge: match setting.merge { MergePolicy::Replace => Merge::Replace, @@ -684,13 +688,6 @@ fn registry_of(settings: &[Setting]) -> Result { ), None => None, }, - envs: &[], - deprecated_envs: &[], - // No flags: a corpus vector describes what reaches a *resolution*, and which flag a - // setting declares does not change one. It is documentation, and the drift test that - // holds it against a CLI's own binding is that CLI's test rather than this corpus's. - cli: &[], - bindings: &[], choices: Box::leak( setting .choices @@ -699,18 +696,9 @@ fn registry_of(settings: &[Setting]) -> Result { .collect::, _>>()? .into_boxed_slice(), ), - hide: false, deprecated: setting.deprecated.as_deref().map(leak), renamed_to: setting.renamed_to.as_deref().map(leak), - aliases: &[], - optional: None, - help: None, - long_help: None, - default_note: None, - since: None, - deprecated_warn_at: None, - deprecated_remove_at: None, - examples: &[], + ..PropMeta::new(leak(&setting.key), ty) }); } Ok(Registry::new(Box::leak(props.into_boxed_slice()))) From 9212e2eb67361366bce0363827fd26325b82d239 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:04:33 +0000 Subject: [PATCH 13/28] fix(derive): refuse a default that only the merge would have accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Ty::admits` was permissive wherever coercion is, but a declared default is the one value nothing coerces: the resolver seeds it with `Const::to_value` and the reader is handed it as it stands. So `default = 1` on a `String` field and `default = 80` on a `Vec` compiled and then failed *every* `Settings::read` with a type error nothing at run time could fix. The check now knows where the constant stands. A choice keeps the merge's reading — the registry coerces both sides before comparing, so a `list` setting's choices name what one item may be, and a string setting may name a bare number. A default has to already be a value of the field's type, and the message says why, because "not a value `string` can hold" reads like a lie next to a spec's `default=1`, which the merge does coerce. Nothing is lost: `default(80)` is how the attribute already spells a list of one, distinct from a bare `default = 80`. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 132 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index f0c4938f9..3ec629c2d 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -194,39 +194,57 @@ impl Ty { ) } - /// Whether `value` could be a value of this type, read the way the merge reads one. + /// Whether `value` could be a value of this type, where the declaration puts it. /// - /// Permissive exactly where coercion is: a string default under an `int` type would be - /// coerced at runtime, but a declaration is the place to write the value as what it is. - fn admits(&self, value: &Const) -> bool { + /// Permissive exactly where something coerces, which is why the position matters: the + /// merge coerces what a *layer* supplies, and `Registry`'s choice comparison coerces + /// before it compares — but nothing coerces a declared default. + fn admits(&self, value: &Const, position: Position) -> bool { + let coerced = position == Position::Choice; match (self, value) { (Self::Any, _) => true, (Self::Bool, Const::Bool(_)) => true, (Self::Int | Self::Float, Const::Int(_)) => true, - // A `uint` names a non-negative number, and the resolver seeds a declared default - // with no coercion, so `default = -1` on a `u64` field would compile and then fail - // every `read` with a type error the author cannot fix at run time. The span is - // here, so refuse it here. + // A `uint` names a non-negative number, so `default = -1` on a `u64` field + // compiled and then failed every `read` with a type error the author could do + // nothing about. The span is here, so refuse it here. (Self::Uint, Const::Int(i)) => *i >= 0, (Self::Float, Const::Float(_)) => true, (Self::String | Self::Path | Self::Url | Self::Duration, Const::Str(_)) => true, - // The coercion rule the registry itself follows: a string type reads a bare - // number or boolean as its text. + // A string type reads a bare number or boolean as its text — where something + // reads it. A default is handed to the field as `Value::Int(1)`, and `String` + // refuses that, so `default = 1` on a `String` field is a mistake and not a + // shorthand. ( Self::String | Self::Path | Self::Url | Self::Duration, Const::Bool(_) | Const::Int(_) | Const::Float(_), - ) => true, + ) => coerced, (Self::List(item) | Self::Set(item), Const::List(items)) => { - items.iter().all(|value| item.admits(value)) + items.iter().all(|value| item.admits(value, position)) + } + // A choice on a list setting names what one *item* may be, which is how the + // registry compares it. A default is the whole value, and `default(80)` is how + // the attribute already spells a list of one — so a bare `default = 80` on a + // `Vec` is refused rather than quietly meaning something else. + (Self::List(item) | Self::Set(item), scalar) => { + coerced && item.admits(scalar, position) } - // One bare value where a list belongs is a list of one, the same rule - // `Ty::coerce` applies. - (Self::List(item) | Self::Set(item), scalar) => item.admits(scalar), _ => false, } } } +/// Where in a declaration a constant stands, which decides how strictly it is read. +#[derive(Clone, Copy, PartialEq)] +enum Position { + /// A declared default. The resolver seeds it with `Const::to_value` and hands it to the + /// field as it stands, so it has to already be a value of the field's type. + Default, + /// One of the values a setting allows, compared against a resolved value *after* the + /// merge has coerced both. + Choice, +} + /// A literal a registry can hold as a `const`. #[derive(Clone, PartialEq)] enum Const { @@ -555,11 +573,13 @@ impl Field { )); } if let Some(default) = &prop.default { - if !prop.ty.admits(default) { + if !prop.ty.admits(default, Position::Default) { return Err(syn::Error::new( ident.span(), format!( - "the default is not a value `{}` can hold", + "the default is not a value `{}` can hold. Nothing coerces a \ + default — the resolver seeds it as written — so write it as the \ + type the field holds", type_name(&prop.ty) ), )); @@ -578,7 +598,7 @@ impl Field { )); } for choice in &prop.choices { - if !prop.ty.admits(choice) { + if !prop.ty.admits(choice, Position::Choice) { return Err(syn::Error::new( ident.span(), format!("a choice is not a value `{}` can hold", type_name(&prop.ty)), @@ -1162,6 +1182,82 @@ mod tests { ); } + #[test] + fn a_default_is_refused_unless_it_is_already_the_field_s_type() { + // Nothing coerces a declared default: the resolver seeds it with `Const::to_value` + // and the reader is handed it as it stands. So the permissiveness the merge has — + // a string type reading a bare number as its text, a scalar standing in for a list + // of one — is not permissiveness a default gets. Each of these compiled and then + // failed *every* `Settings::read` with a type error nothing at run time could fix. + for (field, default) in [ + ("name: String", "default = 1"), + ("name: String", "default = true"), + ("home: std::path::PathBuf", "default = 1"), + ("ports: Vec", "default = 80"), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage({default})] + {field}, + }} + "# + )); + assert!( + err.contains("the default is not a value"), + "`{default}` was accepted on `{field}`: {err}" + ); + // And the message says why, because "not a value `string` can hold" reads like a + // lie next to a spec's `default=1`, which the merge does coerce. + assert!( + err.contains("Nothing coerces a default"), + "unhelpful for `{default}` on `{field}`: {err}" + ); + } + + // Written as the type the field holds, each is fine — including a list of one, which + // the attribute already spells apart from a bare scalar. + for (field, default) in [ + ("name: String", r#"default = "1""#), + ("home: std::path::PathBuf", r#"default = "/tmp""#), + ("ports: Vec", "default(80)"), + ("ports: Vec", "default(80, 443)"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage({default})] + {field}, + }} + "# + )); + } + } + + #[test] + fn a_choice_still_reads_the_way_the_merge_reads_one() { + // The other side of the same rule. A choice is compared against a resolved value + // *after* coercion, so a `list` setting's choices name what one item may be, + // and a string setting may name a bare number. Tightening the default check must not + // tighten this one: the registry's own comparison coerces before it compares. + accepted( + r#" + struct Settings { + #[usage(choices("a", "b"))] + tags: Vec, + } + "#, + ); + accepted( + r#" + struct Settings { + #[usage(choices(1, 2))] + level: String, + } + "#, + ); + } + #[test] fn every_setting_attribute_on_a_flattened_field_is_refused() { // `flatten` says the field is a group of settings, so anything describing *one* From f1d30fb29ea107cfa7a75510e9afbf9f1b294c02 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:11:04 +0000 Subject: [PATCH 14/28] fix(derive): hold a list default against its choices item by item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choices on a list setting name what one *item* may be — that is how `Registry` compares a resolved value against them — so a default made of them is a default made of declared values. Comparing the whole list against each choice refused `default("a")` beside `choices("a", "b")` outright. The opposite failure to the two type checks beside it: this one refused a valid declaration rather than accepting a broken one, so it fails at compile time and is visible. It would have met hk first, whose list settings are exactly this shape. An item nothing declared is still refused, and a scalar setting still compares whole, which is all it can do. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 82 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index 3ec629c2d..bf9a983e6 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -584,11 +584,31 @@ impl Field { ), )); } - if !prop.choices.is_empty() && !prop.choices.iter().any(|choice| choice == default) { - return Err(syn::Error::new( - ident.span(), - "the default is not one of the declared choices", - )); + // Which values have to be one of the choices depends on what the choices name. + // On a list setting they name what one *item* may be — that is how the registry + // compares a resolved value against them — so it is the items that are checked. + // Comparing the whole list refused `default("a")` beside `choices("a", "b")`, a + // declaration whose every value is one of them. + if !prop.choices.is_empty() { + let declared: Vec<&Const> = match (&prop.ty, default) { + (Ty::List(_) | Ty::Set(_), Const::List(items)) => items.iter().collect(), + _ => vec![default], + }; + if !declared + .iter() + .all(|value| prop.choices.iter().any(|choice| choice == *value)) + { + return Err(syn::Error::new( + ident.span(), + match declared.len() > 1 { + true => { + "the default has a value that is not one of the declared \ + choices" + } + false => "the default is not one of the declared choices", + }, + )); + } } } if !prop.choices.is_empty() && prop.ty == Ty::Bool { @@ -1234,6 +1254,58 @@ mod tests { } } + #[test] + fn a_list_default_is_held_against_the_choices_item_by_item() { + // The choices on a list setting name what one item may be, so a default made of them + // is a default made of declared values. Comparing the whole list against each choice + // refused this outright — the opposite failure to the two above, and the one that + // would have hit hk first: its list settings are exactly this shape. + accepted( + r#" + struct Settings { + #[usage(default("a"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + accepted( + r#" + struct Settings { + #[usage(default("a", "b"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + + // An item nothing declared is still refused, which is the point of checking at all. + let err = rejection( + r#" + struct Settings { + #[usage(default("a", "z"), choices("a", "b"))] + tags: Vec, + } + "#, + ); + assert!( + err.contains("not one of the declared choices"), + "unhelpful: {err}" + ); + + // And a scalar setting still compares whole, which is all it can do. + let err = rejection( + r#" + struct Settings { + #[usage(default = "z", choices("a", "b"))] + mode: String, + } + "#, + ); + assert!( + err.contains("the default is not one of the declared choices"), + "unhelpful: {err}" + ); + } + #[test] fn a_choice_still_reads_the_way_the_merge_reads_one() { // The other side of the same rule. A choice is compared against a resolved value From 7db63b3110e3d63fdf4e2a9c17849bf91d5759ce Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:19:01 +0000 Subject: [PATCH 15/28] fix(derive): one spelling for a string setting's values, so a default can be held against them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default-versus-choices check compared literals while the registry compares after coercing the choice, so `default = "1"` beside `choices(1)` on a `String` field was refused although the runtime allows it. Coercing in the derive to match would mean a third implementation of how a float is written — `1` or `1.0` — which is exactly the drift `conformance/tests/config_value_display.rs` exists to catch, and it is a character of difference that silently refuses a default and a choice written identically. So the declaration side gets one spelling instead. A spec's `type="string"` does read a bare number as its text, but a declaration written in Rust has no reason to spell a string as anything but a string, and `choices("1")` says exactly what `choices(1)` did. With both sides spelled as the type they are, comparing them as written is correct by construction, and no coercion rule is copied anywhere. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 56 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index bf9a983e6..c9cc76e17 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -211,14 +211,16 @@ impl Ty { (Self::Uint, Const::Int(i)) => *i >= 0, (Self::Float, Const::Float(_)) => true, (Self::String | Self::Path | Self::Url | Self::Duration, Const::Str(_)) => true, - // A string type reads a bare number or boolean as its text — where something - // reads it. A default is handed to the field as `Value::Int(1)`, and `String` - // refuses that, so `default = 1` on a `String` field is a mistake and not a - // shorthand. + // A spec's `type="string"` does read a bare number as its text, and the merge + // coerces one — but a *declaration written in Rust* has no reason to spell a + // string as anything but a string. Holding both a default and a choice to that + // means one spelling for both, which is what lets them be compared as written: + // the alternative is a third implementation of "how a float is written", the + // drift `config_value_display.rs` exists to catch. ( Self::String | Self::Path | Self::Url | Self::Duration, Const::Bool(_) | Const::Int(_) | Const::Float(_), - ) => coerced, + ) => false, (Self::List(item) | Self::Set(item), Const::List(items)) => { items.iter().all(|value| item.admits(value, position)) } @@ -621,7 +623,11 @@ impl Field { if !prop.ty.admits(choice, Position::Choice) { return Err(syn::Error::new( ident.span(), - format!("a choice is not a value `{}` can hold", type_name(&prop.ty)), + format!( + "a choice is not a value `{}` can hold. Write it as the type the \ + setting is — a `string` setting's choices are strings, quoted", + type_name(&prop.ty) + ), )); } } @@ -1307,11 +1313,10 @@ mod tests { } #[test] - fn a_choice_still_reads_the_way_the_merge_reads_one() { - // The other side of the same rule. A choice is compared against a resolved value - // *after* coercion, so a `list` setting's choices name what one item may be, - // and a string setting may name a bare number. Tightening the default check must not - // tighten this one: the registry's own comparison coerces before it compares. + fn a_choice_on_a_list_setting_names_one_item() { + // A choice is compared against a resolved value *after* coercion, and a `list` + // setting's choices name what one item may be — that is what the registry compares. + // Tightening the default check must not tighten this one. accepted( r#" struct Settings { @@ -1320,10 +1325,37 @@ mod tests { } "#, ); + } + + #[test] + fn a_string_setting_spells_its_values_as_strings() { + // A spec's `type="string"` does read a bare number as its text, and the merge coerces + // one — so the registry would accept `choice 1` here. A declaration written in Rust + // still has no reason to spell a string as anything else, and requiring the quotes is + // what lets a default and a choice be compared as written. The alternative is a third + // implementation of how a float is written, which is the drift + // `conformance/tests/config_value_display.rs` exists to catch. + for attribute in ["choices(1, 2)", "default = 1", "default = true"] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage({attribute})] + level: String, + }} + "# + )); + assert!( + err.contains("`string` can hold"), + "`{attribute}` was accepted on a String field: {err}" + ); + } + + // Quoted, they are the same declaration and they compile — including the pairing that + // the literal comparison would otherwise have refused. accepted( r#" struct Settings { - #[usage(choices(1, 2))] + #[usage(default = "1", choices("1", "2"))] level: String, } "#, From c787f8a0df34eda46f1354ad7ee87de2693ad2cb Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:19:01 +0000 Subject: [PATCH 16/28] fix(derive): report deprecations from the settings entry too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse` collects the deprecated declarations an invocation used and prints them; `parse_with_settings` is the same kind of thing — an entry point that *is* the process — and it read the same partial and threw them away. A CLI that adopted settings therefore went quiet about every deprecation it had, which is the sort of difference nobody notices until a release removes the flag. The settings entries gain the `Option<&mut Vec>` thread that `parse`'s already have, with the same public shape: `parse_from_with_settings` and `parse_from_argv_with_settings` unchanged and collecting nothing, and `_and_warnings` counterparts for a caller that wants them. A settings adopter is the caller that most wants the collecting form — it renders deprecations through its own logging rather than to raw stderr. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 38 ++++++++++++ derive/src/codegen.rs | 94 ++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 9eabfc5c5..f5fd76465 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -242,6 +242,44 @@ fn the_emitted_config_block_is_the_spec_grammar() { assert_eq!(cache_dir.optional, Some(true)); } +/// A CLI whose settings-bound flag is deprecated. +#[derive(Cli, Debug)] +#[usage(bin = "dep", version = "2.0.0", config = Settings)] +struct Deprecated { + /// How many jobs to run at once + #[usage(long, setting = "jobs", deprecated = "use --parallel")] + jobs: Option, +} + +#[test] +fn the_settings_entry_reports_the_deprecations_a_parse_used() { + // `parse` collects deprecations and prints them; `parse_with_settings` is the same kind of + // thing — an entry point that *is* the process — so it has to say the same. It read the + // partial and threw the warnings away, so a CLI that adopted settings went quiet about + // every deprecation it had, which is the sort of difference nobody notices until a + // release removes the flag. + let argv = ["dep", "--jobs", "4"].map(OsStr::new); + let mut warnings = Vec::new(); + let (parsed, layer) = + Deprecated::parse_from_argv_with_settings_and_warnings(&argv, &mut warnings) + .expect("parses"); + assert_eq!(parsed.jobs, Some(4)); + assert_eq!( + warnings.len(), + 1, + "the deprecated flag this parse used should be reported: {warnings:?}" + ); + + // The layer is still the layer: collecting warnings does not change what argv bound. + let resolved = + resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).expect("resolves"); + assert_eq!(Settings::read(&resolved).expect("reads").jobs, 4); + + // And a caller that does not ask walks no tree and gets no warnings, which is why the + // collecting form is a separate entry rather than the only one. + Deprecated::parse_from_argv_with_settings(&argv).expect("parses"); +} + /// A setting nothing declares a value for: not an `Option`, and no default. #[derive(Config, Debug, PartialEq)] struct Required { diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 0eb5bb55d..85e768ea7 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -395,6 +395,36 @@ pub fn emit(cli: &Cli) -> TokenStream { ) -> ::std::result::Result< (Self, #config::CliLayer), usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_with_settings(argv, ::std::option::Option::None) + } + + /// [`Self::parse_from_with_settings`], collecting the deprecations it used. + /// + /// A settings adopter renders these through its own logging rather than to raw + /// stderr, which is the whole reason the collecting form exists. + pub fn parse_from_with_settings_and_warnings<'v>( + argv: &[&'v ::std::ffi::OsStr], + warnings: &mut ::std::vec::Vec>, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_with_settings( + argv, + ::std::option::Option::Some(warnings), + ) + } + + #[doc(hidden)] + fn __usage_parse_from_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + __usage_warnings: ::std::option::Option< + &mut ::std::vec::Vec>, + >, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, > { // The layer from what argv left, and only then the rest: `check` fills a field // from its `env` and marks it given, and a variable's value contributed here @@ -405,6 +435,9 @@ pub fn emit(cli: &Cli) -> TokenStream { read_argv_into(Self::command(), argv, &mut partial)?; let __usage_settings = settings_layer(&partial); check(&mut partial)?; + if let ::std::option::Option::Some(__usage_out) = __usage_warnings { + Self::__usage_deprecations(&partial, __usage_out); + } let __usage_built = #built; ::std::result::Result::Ok((__usage_built, __usage_settings)) } @@ -469,11 +502,41 @@ pub fn emit(cli: &Cli) -> TokenStream { ) -> ::std::result::Result< (Self, #config::CliLayer), usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_argv_with_settings( + argv, + ::std::option::Option::None, + ) + } + + /// [`Self::parse_from_argv_with_settings`], collecting the deprecations it used. + pub fn parse_from_argv_with_settings_and_warnings<'v>( + argv: &[&'v ::std::ffi::OsStr], + warnings: &mut ::std::vec::Vec>, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, + > { + Self::__usage_parse_from_argv_with_settings( + argv, + ::std::option::Option::Some(warnings), + ) + } + + #[doc(hidden)] + fn __usage_parse_from_argv_with_settings<'v>( + argv: &[&'v ::std::ffi::OsStr], + __usage_warnings: ::std::option::Option< + &mut ::std::vec::Vec>, + >, + ) -> ::std::result::Result< + (Self, #config::CliLayer), + usage_argv::Error<'static, 'v>, > { let ::std::option::Option::Some((__usage_argv0, __usage_words)) = argv.split_first() else { - return Self::parse_from_with_settings(&[]); + return Self::__usage_parse_from_with_settings(&[], __usage_warnings); }; if let ::std::option::Option::Some(__usage_view) = usage_argv::spec::view_for_program(&SPEC, __usage_argv0) @@ -502,6 +565,9 @@ pub fn emit(cli: &Cli) -> TokenStream { &mut partial, ::std::option::Option::Some(__usage_view), )?; + if let ::std::option::Option::Some(__usage_out) = __usage_warnings { + Self::__usage_deprecations(&partial, __usage_out); + } return ::std::result::Result::Ok((#built_for_view, __usage_settings)); } if SPEC.multicall { @@ -514,10 +580,13 @@ pub fn emit(cli: &Cli) -> TokenStream { ::std::vec::Vec::with_capacity(argv.len()); __usage_rewritten.push(::std::ffi::OsStr::new(__usage_word)); __usage_rewritten.extend_from_slice(__usage_words); - return Self::parse_from_with_settings(&__usage_rewritten); + return Self::__usage_parse_from_with_settings( + &__usage_rewritten, + __usage_warnings, + ); } } - Self::parse_from_with_settings(__usage_words) + Self::__usage_parse_from_with_settings(__usage_words, __usage_warnings) } /// Parse the process's own arguments, and the settings they gave values for. @@ -527,8 +596,23 @@ pub fn emit(cli: &Cli) -> TokenStream { pub fn parse_with_settings() -> (Self, #config::CliLayer) { #completion_intercept #parse_preamble - match Self::parse_from_argv_with_settings(&__usage_all_refs) { - ::std::result::Result::Ok(__usage_parsed) => __usage_parsed, + // The same as `parse`: this is an entry point that *is* the process, so it is + // one of the two that may write to stderr, and a failure prints nothing about + // deprecations because what the user typed did not run. + let mut __usage_warnings = ::std::vec::Vec::new(); + match Self::__usage_parse_from_argv_with_settings( + &__usage_all_refs, + ::std::option::Option::Some(&mut __usage_warnings), + ) { + ::std::result::Result::Ok(__usage_parsed) => { + if !__usage_warnings.is_empty() { + ::std::eprint!( + "{}", + usage_argv::render_warnings(&__usage_warnings), + ); + } + __usage_parsed + } ::std::result::Result::Err(e) => Self::__usage_exit_on_error( e, &__usage_all_refs, From 230c793bed57be1899cdf8a8c320460445c6da8e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:21:24 +0000 Subject: [PATCH 17/28] docs(rust): state how a default and a choice are spelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derive enforces this now, so an author who trips the compile error should have been able to read the rule first: a default is written as the type the field holds because nothing coerces it, and choices follow the same spelling — except on a list setting, where they name what one item may be. Co-Authored-By: Claude Opus 5 --- docs/rust/settings.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/rust/settings.md b/docs/rust/settings.md index 2dd06ae5a..47ec04524 100644 --- a/docs/rust/settings.md +++ b/docs/rust/settings.md @@ -80,6 +80,14 @@ Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): | `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | | `flatten` | Splice another `Config` struct's settings in at this position | +A default and a choice are written as the type the field holds. Nothing coerces a declared +default — the resolver seeds it as written and hands it to the field — so `default = 1` on a +`String` field is a compile error rather than the text `1`, and `default(80)` is how a list of +one is spelled apart from a bare `default = 80`. Choices follow the same spelling so the two +can be compared, with one exception that is not a coercion: on a list setting the choices name +what a single _item_ may be, so `choices("a", "b")` beside `default("a", "b")` is a `Vec` +whose every value is one of them. + A flattened group declares its own dotted keys under its own `#[usage(prefix = "…")]`, and the parent joins the slices at compile time — two groups declaring the same key are a compile error, not a shadowed setting. From c6cca4c125a0f4f47e0cb977f1c1613e20bee685 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:26:37 +0000 Subject: [PATCH 18/28] test(derive): assert the docs-only metadata survives the round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PropMeta` gained `long_help`, `since`, `examples`, and the two deprecation versions in this PR, and the golden pins how they are *rendered*. Nothing pinned that usage-lib reads them back — and these fields exist only for docs, the JSON schema and the completers, all of which reach them through the spec. A field the renderer emits and the parser ignores would be metadata that silently does not exist. It does read them all back. Written while confirming that, along with the doc comment's two halves: the first paragraph is the short help and the whole comment is the long one, through the same helper the `Cli` derive uses, so a setting's prose and a flag's are split the same way. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index f5fd76465..9da4dadd6 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -242,6 +242,62 @@ fn the_emitted_config_block_is_the_spec_grammar() { assert_eq!(cache_dir.optional, Some(true)); } +/// Every metadata attribute that only reaches the registry, declared at once. +#[derive(Config, Debug, PartialEq)] +struct Documented { + /// Stop at the first failure + /// + /// Whether a failing job stops the rest of them. + #[usage( + alias("fail-fast.legacy", "failfast"), + example("true", "false"), + since = "5.2.0", + deprecated = "use `stop-on-error`", + deprecated_warn_at = "6.0.0", + deprecated_remove_at = "7.0.0" + )] + fail_fast: Option, +} + +#[test] +fn the_metadata_only_docs_read_survives_the_round_trip() { + // These fields never reach `read` — they exist for docs, the JSON schema and the + // completers, all of which go through the *spec*. So rendering them is only half a claim: + // the other half is that usage-lib reads back what the derive wrote. A field the renderer + // emits and the parser ignores would be metadata that silently does not exist. + let props = Documented::SETTINGS_PROPS; + assert_eq!(props.len(), 1); + let declared = &props[0]; + + let kdl = format!("name \"ex\"\nbin \"ex\"\n{}", Documented::spec_kdl()); + let spec: usage::Spec = kdl.parse().expect("usage-lib reads the emitted block"); + let parsed = spec.config.props.get("fail_fast").expect("declared"); + + assert_eq!(parsed.aliases, declared.aliases.to_vec()); + assert_eq!(parsed.examples, declared.examples.to_vec()); + assert_eq!(parsed.since.as_deref(), declared.since); + assert_eq!(parsed.deprecated.as_deref(), declared.deprecated); + assert_eq!( + parsed.deprecated_warn_at.as_deref(), + declared.deprecated_warn_at + ); + assert_eq!( + parsed.deprecated_remove_at.as_deref(), + declared.deprecated_remove_at + ); + assert_eq!(parsed.optional, declared.optional); + + // A doc comment becomes both: the first paragraph is the short help, and the *whole* + // comment is the long one — the same `doc_comment` helper the `Cli` derive uses, so a + // setting's prose and a flag's are split the same way. + assert_eq!(parsed.help.as_deref(), Some("Stop at the first failure")); + assert_eq!( + parsed.long_help.as_deref(), + Some("Stop at the first failure\n\nWhether a failing job stops the rest of them.") + ); + assert_eq!(parsed.long_help.as_deref(), declared.long_help); +} + /// A CLI whose settings-bound flag is deprecated. #[derive(Cli, Debug)] #[usage(bin = "dep", version = "2.0.0", config = Settings)] From c36b7ee662521c32bb344fa5fce003576add1c03 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:35:09 +0000 Subject: [PATCH 19/28] fix(derive): refuse a default the field's own type cannot hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `infer_ty` collapses every unsigned width to `uint` and every signed one to `int`, so the spec-level check cannot see that a `u8` refuses 256. Uncaught, this is the negative-`uint` trap one level down: seeded uncoerced, refused by `FromValue`, and so a type error on every `Settings::read` that nothing at run time could fix. `default = 256` on a `u8`, `default = 128` on an `i8`, and an `f32` default of `1e300` all compiled. The field's own type is now measured too, separately from the spec type because they answer different questions — and permissively where it cannot measure, since the spec-level check has already had its say. A list's items are held against the item type rather than the list. Choices go through the same check: one that does not fit is one nothing could ever supply. The `f32` rule matches `FromValue for f32`: rounding a value that fits is ordinary precision loss, and turning a finite one into an infinity is not. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 289 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 268 insertions(+), 21 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index c9cc76e17..8be520b97 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -319,19 +319,46 @@ impl Config { // A duplicate within one struct is visible here, so it is refused here, with spans. // Duplicates across flattened structs are refused by `concat_props` at compile time. - let mut keys: Vec<(&String, &syn::Ident)> = Vec::new(); + // + // Aliases are names too: `Registry::lookup` checks keys and aliases together and takes + // the first match, so an alias that collides with another setting's key — or with its + // alias — makes one of the two unreachable by that name, silently. Every name a + // setting answers to therefore has to be unique among all of them, not just the keys. + let mut names: Vec<(&str, &syn::Ident, bool)> = Vec::new(); for field in &fields { - if let Field::Prop(prop) = field { - if let Some((_, first)) = keys.iter().find(|(key, _)| **key == prop.key) { + let Field::Prop(prop) = field else { continue }; + for (name, is_alias) in std::iter::once((prop.key.as_str(), false)) + .chain(prop.aliases.iter().map(|alias| (alias.as_str(), true))) + { + if let Some((_, first, first_alias)) = + names.iter().find(|(taken, _, _)| *taken == name) + { + let what = |alias: bool| match alias { + true => "an alias", + false => "the key", + }; return Err(syn::Error::new( prop.ident.span(), - format!( - "`{}` and `{first}` declare the same setting key `{}`", - prop.ident, prop.key - ), + match std::ptr::eq(*first, &prop.ident) { + // Its own key, which is not a collision between two settings but + // an alias that can never be reached: the key is found first. + true => format!( + "`{}` lists `{name}` as an alias of its own key, which \ + nothing would ever reach", + prop.ident + ), + false => format!( + "`{name}` is {} of `{}` and {} of `{first}`, and a lookup \ + takes the first of them: one of the two could never be \ + reached by that name", + what(is_alias), + prop.ident, + what(*first_alias), + ), + }, )); } - keys.push((&prop.key, &prop.ident)); + names.push((name, &prop.ident, is_alias)); } } @@ -586,6 +613,18 @@ impl Field { ), )); } + // And what the *field* can hold, which the spec type does not say: `uint` covers + // every unsigned width, so 256 passed the check above and then failed every + // `read` on a `u8`. + if !field_holds(&prop.read_ty, default) { + return Err(syn::Error::new( + ident.span(), + format!( + "the default does not fit `{}`, which is what this field holds", + rust_type_name(&prop.read_ty) + ), + )); + } // Which values have to be one of the choices depends on what the choices name. // On a list setting they name what one *item* may be — that is how the registry // compares a resolved value against them — so it is the items that are checked. @@ -620,6 +659,15 @@ impl Field { )); } for choice in &prop.choices { + if !field_holds(&prop.read_ty, choice) { + return Err(syn::Error::new( + ident.span(), + format!( + "a choice does not fit `{}`, so nothing could ever supply it", + rust_type_name(&prop.read_ty) + ), + )); + } if !prop.ty.admits(choice, Position::Choice) { return Err(syn::Error::new( ident.span(), @@ -683,25 +731,81 @@ fn peel_option(ty: &syn::Type) -> (bool, syn::Type) { } /// The spec type a Rust type names on its own, or `None` for one that needs `ty = "..."`. +/// A field's Rust type as written, for a message that has to name it. +fn rust_type_name(ty: &syn::Type) -> String { + quote::ToTokens::to_token_stream(ty) + .to_string() + .replace(" ", "") +} + +/// The `index`th type argument of a path segment, as in the `u64` of `Vec`. +fn generic_arg(segment: &syn::PathSegment, index: usize) -> Option { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args + .iter() + .filter_map(|arg| match arg { + syn::GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) + .nth(index) +} + +/// Whether the field's own Rust type can hold `value`. +/// +/// A narrower check than [`Ty::admits`], and it has to be separate: `infer_ty` collapses every +/// unsigned width to `uint`, so the spec-level type cannot see that a `u8` refuses 256. The +/// resolver seeds a default uncoerced and `FromValue` is strict about width, so a default the +/// field cannot hold fails every `read` — the same trap as a negative `uint`, one level down. +/// +/// `true` for anything this does not recognize: the spec-level check has already had its say, +/// and a type it allows that this cannot measure is not this function's to refuse. +fn field_holds(ty: &syn::Type, value: &Const) -> bool { + let syn::Type::Path(path) = ty else { + return true; + }; + let Some(last) = path.path.segments.last() else { + return true; + }; + macro_rules! fits { + ($int:ty) => { + match value { + Const::Int(i) => <$int>::try_from(*i).is_ok(), + _ => true, + } + }; + } + match last.ident.to_string().as_str() { + "u8" => fits!(u8), + "u16" => fits!(u16), + "u32" => fits!(u32), + "usize" => fits!(usize), + "i8" => fits!(i8), + "i16" => fits!(i16), + "i32" => fits!(i32), + "isize" => fits!(isize), + // The rule `FromValue for f32` follows: rounding a value that fits is ordinary + // precision loss, and turning a finite one into an infinity is not. + "f32" => match value { + Const::Float(f) => !(*f as f32).is_infinite() || f.is_infinite(), + _ => true, + }, + "Vec" | "BTreeSet" | "HashSet" => match (generic_arg(last, 0), value) { + (Some(inner), Const::List(items)) => items.iter().all(|item| field_holds(&inner, item)), + _ => true, + }, + _ => true, + } +} + fn infer_ty(ty: &syn::Type) -> Option { let syn::Type::Path(path) = ty else { return None; }; let last = path.path.segments.last()?; let name = last.ident.to_string(); - let generic = |index: usize| -> Option { - if let syn::PathArguments::AngleBracketed(args) = &last.arguments { - args.args - .iter() - .filter_map(|arg| match arg { - syn::GenericArgument::Type(inner) => Some(inner.clone()), - _ => None, - }) - .nth(index) - } else { - None - } - }; + let generic = |index: usize| -> Option { generic_arg(last, index) }; Some(match name.as_str() { "bool" => Ty::Bool, "u8" | "u16" | "u32" | "u64" | "usize" => Ty::Uint, @@ -1362,6 +1466,149 @@ mod tests { ); } + #[test] + fn a_default_that_does_not_fit_the_field_is_refused() { + // `infer_ty` collapses every unsigned width to `uint`, so the spec-level check cannot + // see that a `u8` refuses 256. Uncaught, this is the negative-`uint` trap one level + // down: seeded uncoerced, refused by `FromValue`, and so a type error on every + // `Settings::read` that nothing at run time could fix. + for (field, value) in [ + ("small: u8", "256"), + ("small: u8", "-1"), + ("signed: i8", "128"), + ("medium: u16", "70000"), + ("wide: u32", "5000000000"), + ("ratio: f32", "1e300"), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(default = {value})] + {field}, + }} + "# + )); + assert!( + err.contains("does not fit") || err.contains("can hold"), + "`default = {value}` was accepted on `{field}`: {err}" + ); + } + + // A choice nothing could supply is the same mistake, and the items of a list are + // measured against the item type rather than the list. + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, 256))] + small: u8, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + let err = rejection( + r#" + struct Settings { + #[usage(default(80, 70000))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + + // What does fit still compiles, at every width and through a list. + for (field, value) in [ + ("small: u8", "255"), + ("signed: i8", "-128"), + ("medium: u16", "65535"), + ("ratio: f32", "1.5"), + ("jobs: u64", "4"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage(default = {value})] + {field}, + }} + "# + )); + } + accepted( + r#" + struct Settings { + #[usage(default(80, 443))] + ports: Vec, + } + "#, + ); + } + + #[test] + fn two_settings_cannot_answer_to_one_name() { + // `Registry::lookup` checks keys and aliases together and takes the first match, so a + // collision does not fail — it makes one of the two settings unreachable by that name, + // which is the quietest possible way to lose a setting. Every name has to be unique + // among all of them, not just the keys among the keys. + let cases = [ + // An alias over another setting's key. + r#" + struct Settings { + #[usage(alias("other"))] + jobs: u64, + other: u64, + } + "#, + // The same alias twice. + r#" + struct Settings { + #[usage(alias("shared"))] + jobs: u64, + #[usage(alias("shared"))] + threads: u64, + } + "#, + // A key over an earlier setting's alias, which is the same collision found in the + // other order. + r#" + struct Settings { + #[usage(alias("threads"))] + jobs: u64, + threads: u64, + } + "#, + ]; + for body in cases { + let err = rejection(body); + assert!( + err.contains("could never be reached"), + "a colliding name was accepted: {err}" + ); + } + + // Its own key as an alias is not two settings colliding, but it is still a name + // nothing reaches, so it gets a message that says which mistake it is. + let err = rejection( + r#" + struct Settings { + #[usage(alias("jobs"))] + jobs: u64, + } + "#, + ); + assert!(err.contains("alias of its own key"), "unhelpful: {err}"); + + // Distinct names are fine, including an alias that looks like a prefix of another key. + accepted( + r#" + struct Settings { + #[usage(alias("concurrency", "parallelism"))] + jobs: u64, + #[usage(alias("task-jobs"))] + threads: u64, + } + "#, + ); + } + #[test] fn every_setting_attribute_on_a_flattened_field_is_refused() { // `flatten` says the field is a group of settings, so anything describing *one* From ac682c64fb50fac6c09f2c7792c53cc5890c0649 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:35:09 +0000 Subject: [PATCH 20/28] fix(config)!: refuse two settings that answer to one name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The duplicate checks covered keys against keys. `Registry::lookup` checks keys *and* aliases together and takes the first match, so an alias colliding with another setting's key — or with its alias — does not fail: it makes one of the two unreachable by that name. That is the quietest possible way to lose a setting, and `#[usage(alias("other"))]` beside a field called `other` compiled. Both halves of the declaration are covered, where each can see the collision: the derive refuses it within one struct, with spans and a message naming both fields, and `concat_props` refuses it across flattened groups at compile time, where a duplicate key was already refused. A name is a name — a lookup does not care which kind it matched, so neither can these. An alias equal to its own key gets its own message: not two settings colliding, but a name nothing would ever reach, since the key is found first. Marked `!`: `concat_props` is public, and a registry that had one of these collisions built before and does not now. Co-Authored-By: Claude Opus 5 --- config/src/props.rs | 111 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/config/src/props.rs b/config/src/props.rs index d6fcb234f..6ae2d8ead 100644 --- a/config/src/props.rs +++ b/config/src/props.rs @@ -36,9 +36,13 @@ pub trait Props: Sized { /// flattened struct's props belong in the parent's registry, and the parent's macro expansion /// has only a type to reach them through. /// -/// `N` must be the summed length of `groups`. Two groups declaring the same key are refused +/// `N` must be the summed length of `groups`. Two groups claiming the same *name* are refused /// here, at compile time — the parent and the struct it flattens each declared it, a collision -/// neither expansion can see. +/// neither expansion can see. A name is a key or an alias, because [`Registry::lookup`] checks +/// both and takes the first match: an alias colliding with another group's key makes one of +/// the two unreachable by that name, which is the same bug as a duplicate key and quieter. +/// +/// [`Registry::lookup`]: crate::Registry::lookup pub const fn concat_props(groups: &[&[PropMeta]]) -> [PropMeta; N] { let mut out = [PropMeta::new("", crate::ty::Ty::Any); N]; let mut at = 0; @@ -67,6 +71,27 @@ pub const fn concat_props(groups: &[&[PropMeta]]) -> [PropMeta; "two flattened groups declare the same setting key, so one of them could \ never be reached: give one of them another key or prefix" ); + // Each one's key against the other's aliases, and then alias against alias. A + // lookup does not care which kind of name it matched, so neither can this. + assert!( + !names_any(out[a].key, out[b].aliases), + "one flattened group's setting key is another's alias, so a lookup for that \ + name could only ever reach one of them: rename one of the two" + ); + assert!( + !names_any(out[b].key, out[a].aliases), + "one flattened group's setting key is another's alias, so a lookup for that \ + name could only ever reach one of them: rename one of the two" + ); + let mut i = 0; + while i < out[a].aliases.len() { + assert!( + !names_any(out[a].aliases[i], out[b].aliases), + "two flattened groups declare the same alias, so a lookup for it could \ + only ever reach one of them: rename one of the two" + ); + i += 1; + } b += 1; } a += 1; @@ -74,6 +99,18 @@ pub const fn concat_props(groups: &[&[PropMeta]]) -> [PropMeta; out } +/// Whether `name` is one of `names`, in a const context. +const fn names_any(name: &str, names: &[&str]) -> bool { + let mut i = 0; + while i < names.len() { + if str_eq(name, names[i]) { + return true; + } + i += 1; + } + false +} + /// Whether two strings are equal, in a const context. const fn str_eq(a: &str, b: &str) -> bool { let a = a.as_bytes(); @@ -96,6 +133,76 @@ mod tests { use super::*; use crate::ty::Ty; + /// The refusals, exercised at run time. + /// + /// `concat_props` is a `const fn`, so in its real use — a `static` initializer — these + /// assertions are a compile error, which a test cannot observe. Called at run time the + /// same assertion panics, which one can. + mod one_name_reaches_one_setting { + use super::*; + + #[test] + #[should_panic(expected = "same setting key")] + fn two_groups_cannot_declare_one_key() { + static A: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + static B: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; + let _ = concat_props::<2>(&[A, B]); + } + + #[test] + #[should_panic(expected = "is another's alias")] + fn an_alias_cannot_shadow_another_groups_key() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["threads"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta::new("threads", Ty::Uint)]; + let _ = concat_props::<2>(&[A, B]); + } + + /// The same collision found from the other side, which is a separate comparison. + #[test] + #[should_panic(expected = "is another's alias")] + fn a_key_cannot_be_shadowed_by_a_later_groups_alias() { + static A: &[PropMeta] = &[PropMeta::new("threads", Ty::Uint)]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["threads"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + let _ = concat_props::<2>(&[A, B]); + } + + #[test] + #[should_panic(expected = "same alias")] + fn two_groups_cannot_declare_one_alias() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["shared"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["shared"], + ..PropMeta::new("threads", Ty::Uint) + }]; + let _ = concat_props::<2>(&[A, B]); + } + + /// And distinct names join, which is the case that has to keep working. + #[test] + fn distinct_names_join() { + static A: &[PropMeta] = &[PropMeta { + aliases: &["concurrency"], + ..PropMeta::new("jobs", Ty::Uint) + }]; + static B: &[PropMeta] = &[PropMeta { + aliases: &["task.concurrency"], + ..PropMeta::new("task.jobs", Ty::Uint) + }]; + let joined = concat_props::<2>(&[A, B]); + assert_eq!(joined[0].key, "jobs"); + assert_eq!(joined[1].key, "task.jobs"); + } + } + #[test] fn groups_join_in_order_and_ids_are_positions() { static OWN: &[PropMeta] = &[PropMeta::new("jobs", Ty::Uint)]; From e95de4308ee25526cdbe074c60cf0c2f6bb942f7 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:41:32 +0000 Subject: [PATCH 21/28] fix(derive): measure a list setting's choice against its item type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `field_holds` was handed the whole field type for a choice, so on a `Vec` a bare `70000` reached the container arm — which only knows how to walk a list — and was called fitting. `Ty::admits` allows a scalar as an item by design, so nothing else stopped it: a choice was declared that no `u16` could ever be. It takes the position now, for the reason `Ty::admits` does. On a list or set a choice names one item, so the item type is what has to hold it; a default is the whole value and never reaches that arm, because `admits` refuses a bare scalar default on a list first. A regression in the width check one commit earlier, which had the container case backwards. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index 8be520b97..8a620a8f6 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -616,7 +616,7 @@ impl Field { // And what the *field* can hold, which the spec type does not say: `uint` covers // every unsigned width, so 256 passed the check above and then failed every // `read` on a `u8`. - if !field_holds(&prop.read_ty, default) { + if !field_holds(&prop.read_ty, default, Position::Default) { return Err(syn::Error::new( ident.span(), format!( @@ -659,7 +659,7 @@ impl Field { )); } for choice in &prop.choices { - if !field_holds(&prop.read_ty, choice) { + if !field_holds(&prop.read_ty, choice, Position::Choice) { return Err(syn::Error::new( ident.span(), format!( @@ -759,9 +759,13 @@ fn generic_arg(segment: &syn::PathSegment, index: usize) -> Option { /// resolver seeds a default uncoerced and `FromValue` is strict about width, so a default the /// field cannot hold fails every `read` — the same trap as a negative `uint`, one level down. /// +/// Takes the `position` for the same reason [`Ty::admits`] does: on a list or set a *choice* +/// names one item rather than the whole value, so it is the item type that has to hold it. +/// Measuring a scalar choice against the container measured nothing at all. +/// /// `true` for anything this does not recognize: the spec-level check has already had its say, /// and a type it allows that this cannot measure is not this function's to refuse. -fn field_holds(ty: &syn::Type, value: &Const) -> bool { +fn field_holds(ty: &syn::Type, value: &Const, position: Position) -> bool { let syn::Type::Path(path) = ty else { return true; }; @@ -791,9 +795,15 @@ fn field_holds(ty: &syn::Type, value: &Const) -> bool { Const::Float(f) => !(*f as f32).is_infinite() || f.is_infinite(), _ => true, }, - "Vec" | "BTreeSet" | "HashSet" => match (generic_arg(last, 0), value) { - (Some(inner), Const::List(items)) => items.iter().all(|item| field_holds(&inner, item)), - _ => true, + "Vec" | "BTreeSet" | "HashSet" => match generic_arg(last, 0) { + None => true, + Some(inner) => match value { + Const::List(items) => items.iter().all(|item| field_holds(&inner, item, position)), + // One value where the container is: a choice naming an item, which the item + // type is the thing that has to hold it. A default is not that — `admits` has + // already refused a bare scalar there — so it does not reach this arm. + _ => position == Position::Choice && field_holds(&inner, value, position), + }, }, _ => true, } @@ -1505,6 +1515,28 @@ mod tests { "#, ); assert!(err.contains("does not fit"), "unhelpful: {err}"); + + // A choice on a *list* setting names one item, so it is the item type that has to + // hold it. Measured against `Vec` instead, a bare `70000` reached the container + // arm and was called fitting — and `Ty::admits` allows a scalar as an item, so this + // declared a choice no `u16` could ever be. + let err = rejection( + r#" + struct Settings { + #[usage(choices(1, 70000))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + accepted( + r#" + struct Settings { + #[usage(choices(1, 65535))] + ports: Vec, + } + "#, + ); let err = rejection( r#" struct Settings { From fe17366d374689cbc0a8c633ebc523d60235a81f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:44:25 +0000 Subject: [PATCH 22/28] test(derive): pin how the width check and a `ty` override interact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written while auditing `field_holds` after getting its container case wrong: the two properties it rests on were both unpinned. `ty` renames what the *spec* calls a setting and does not change what the struct holds, so it must not be a way around the width check — which is why the spec type and the field type are checked by separate functions rather than one. And a type the check cannot measure stays permissive: an alias for a container is the ordinary reason `ty` is written at all, so refusing what it cannot see would make the escape hatch useless. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/derive/src/config.rs b/derive/src/config.rs index 8a620a8f6..fefd097ba 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -1574,6 +1574,34 @@ mod tests { ); } + #[test] + fn a_ty_override_does_not_widen_what_the_field_holds() { + // `ty` renames what the *spec* calls a setting; it does not change what the struct + // holds, so it must not be a way around the width check. The two checks are separate + // functions for exactly this reason — one reads `prop.ty`, the other `prop.read_ty`. + let err = rejection( + r#" + struct Settings { + #[usage(ty = "int", default = 256)] + small: u8, + } + "#, + ); + assert!(err.contains("does not fit `u8`"), "unhelpful: {err}"); + + // And a type this cannot measure stays permissive rather than refused: an alias for a + // container is the ordinary reason `ty` is written at all, and refusing what it cannot + // see would make the escape hatch useless. + accepted( + r#" + struct Settings { + #[usage(ty = "list", default(80, 443))] + ports: Ports, + } + "#, + ); + } + #[test] fn two_settings_cannot_answer_to_one_name() { // `Registry::lookup` checks keys and aliases together and takes the first match, so a From fd71aa46eb444647fdbb34261ff528190eeaaa3b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:08:27 +0000 Subject: [PATCH 23/28] fix(derive): name the right mistake when one field collides with itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision check told a field that lists the same alias twice that it had listed "an alias of its own key", which is a different mistake and not the one made. Three cases now, each with its own message: two settings fighting over a name, an alias shadowed by its own key, and the same alias written twice. Also pins what `prefix` does and does not reach, which was neither tested nor documented: it is applied to `key` alone, so an alias inside a flattened group is written out in full. An author who reads `prefix` as "everything this group names lives under `task.`" would expect otherwise, so the test says plainly that it is pinned rather than endorsed — a change to it should be deliberate, and the new collision checks are what keep the current reading from quietly taking a name off another group. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 18 +++++++++++++++- derive/src/config.rs | 32 +++++++++++++++++++++++----- docs/rust/settings.md | 34 +++++++++++++++--------------- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 9da4dadd6..d48796413 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -22,7 +22,7 @@ fn default_cache_dir() -> PathBuf { #[usage(prefix = "task")] struct TaskSettings { /// How task output is interleaved - #[usage(default = "prefix", choices("prefix", "interleave"))] + #[usage(default = "prefix", choices("prefix", "interleave"), alias("task.out"))] output: String, /// Jobs for tasks alone @@ -242,6 +242,22 @@ fn the_emitted_config_block_is_the_spec_grammar() { assert_eq!(cache_dir.optional, Some(true)); } +#[test] +fn a_groups_prefix_reaches_its_keys_and_not_its_aliases() { + // `prefix` is applied to `key` alone, so an alias inside a flattened group is written + // out in full. Pinned rather than asserted to be right: an author who reads `prefix` as + // "everything this group names lives under `task.`" would expect `alias("out")` to mean + // `task.out`, and today it means a bare top-level `out`. Whichever way that should go, a + // change to it should be a deliberate one rather than a surprise, and the collision + // checks in `Config::from_input` and `concat_props` are what keep the current reading + // from quietly stealing a name off another group. + let output = Settings::SETTINGS_PROPS + .iter() + .find(|meta| meta.key == "task.output") + .expect("declared under its group's prefix"); + assert_eq!(output.aliases, &["task.out"]); +} + /// Every metadata attribute that only reaches the registry, declared at once. #[derive(Config, Debug, PartialEq)] struct Documented { diff --git a/derive/src/config.rs b/derive/src/config.rs index fefd097ba..12d3fde87 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -337,17 +337,24 @@ impl Config { true => "an alias", false => "the key", }; + // Three different mistakes, and the message has to name the right one: + // one field colliding with itself is not two settings fighting over a + // name, and which of its own names came first says which mistake it is. + let same_field = std::ptr::eq(*first, &prop.ident); return Err(syn::Error::new( prop.ident.span(), - match std::ptr::eq(*first, &prop.ident) { + match (same_field, *first_alias) { // Its own key, which is not a collision between two settings but // an alias that can never be reached: the key is found first. - true => format!( + (true, false) => format!( "`{}` lists `{name}` as an alias of its own key, which \ nothing would ever reach", prop.ident ), - false => format!( + (true, true) => { + format!("`{}` lists the alias `{name}` twice", prop.ident) + } + (false, _) => format!( "`{name}` is {} of `{}` and {} of `{first}`, and a lookup \ takes the first of them: one of the two could never be \ reached by that name", @@ -1644,8 +1651,10 @@ mod tests { ); } - // Its own key as an alias is not two settings colliding, but it is still a name - // nothing reaches, so it gets a message that says which mistake it is. + // One field colliding with itself is not two settings fighting over a name, and + // which of its own names came first says which mistake it is — so each gets its own + // message rather than the two-settings one, which would name `jobs` twice and read + // like nonsense. let err = rejection( r#" struct Settings { @@ -1656,6 +1665,19 @@ mod tests { ); assert!(err.contains("alias of its own key"), "unhelpful: {err}"); + let err = rejection( + r#" + struct Settings { + #[usage(alias("concurrency", "concurrency"))] + jobs: u64, + } + "#, + ); + assert!( + err.contains("lists the alias `concurrency` twice"), + "unhelpful: {err}" + ); + // Distinct names are fine, including an alias that looks like a prefix of another key. accepted( r#" diff --git a/docs/rust/settings.md b/docs/rust/settings.md index 47ec04524..39d1c31ac 100644 --- a/docs/rust/settings.md +++ b/docs/rust/settings.md @@ -62,23 +62,23 @@ exactly as they do for flags. Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): -| Attribute | Effect | -| -------------------------------------------------------- | --------------------------------------------------------------------- | -| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | -| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | -| `default = 4` / `default(80, 443)` | The value when no layer supplies one | -| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | -| `default_note = "…"` | Prose beside the default, for docs | -| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | -| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | -| `choices("a", "b")` | The only values it accepts | -| `merge = "union"` / `"deep"` | How a collection combines across layers | -| `scope = "global"` / `"env"` | Where a value is accepted from | -| `parse = "list_by_comma"` | How one string becomes several values | -| `alias("other")` | Equivalent keys accepted without a warning | -| `key = "match"` | The dotted key, when the field name is not it | -| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | -| `flatten` | Splice another `Config` struct's settings in at this position | +| Attribute | Effect | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | +| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | +| `default = 4` / `default(80, 443)` | The value when no layer supplies one | +| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | +| `default_note = "…"` | Prose beside the default, for docs | +| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | +| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | +| `choices("a", "b")` | The only values it accepts | +| `merge = "union"` / `"deep"` | How a collection combines across layers | +| `scope = "global"` / `"env"` | Where a value is accepted from | +| `parse = "list_by_comma"` | How one string becomes several values | +| `alias("other")` | Equivalent keys accepted without a warning — written in full, not under the group's `prefix` | +| `key = "match"` | The dotted key, when the field name is not it | +| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | +| `flatten` | Splice another `Config` struct's settings in at this position | A default and a choice are written as the type the field holds. Nothing coerces a declared default — the resolver seeds it as written and hands it to the field — so `default = 1` on a From 3e8a7c50f4868d3242c32ab670049c669d0e11b0 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:20:07 +0000 Subject: [PATCH 24/28] fix(derive): refuse a `ty` the field could never read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ty` was accepted whenever it named a valid spec type, with nothing checking that the field could read what the merge would hand it. Since `Ty::coerce` decides the shape from the *declared* type rather than from what a layer supplied, a mismatch is not conditional: `ty = "uint"` on a `String` field failed every `read`, for every input, however the CLI was configured. The check is a shape comparison — which `Value` variant a spec type produces, against which variants the field's `FromValue` accepts — and it refuses only a pairing that can *never* read. `ty = "int"` on a `u8` still compiles: it reads whenever the value fits, so it is a widening the author may mean rather than this check's to refuse. `int` on a float field likewise, because a whole number is a perfectly good float and `FromValue for f64` says so. Permissive about types it does not know — a type alias, or a type whose `FromValue` an adopter wrote — since those are exactly what the attribute exists for, and refusing what it cannot measure would make the escape hatch useless. `duration` on a `String` is the motivating case and still compiles. This is the limit noted in the audit two commits ago, which I had left as design work; a shape comparison turns out to be the whole of it, without the compatibility table I thought it needed. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 158 +++++++++++++++++++++++++++++++++++++++++- docs/rust/settings.md | 6 ++ 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index 12d3fde87..82b7faf5f 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -568,8 +568,31 @@ impl Field { prop.optional_field = optional_field; prop.read_ty = inner.clone(); prop.ty = match ty_attr { - Some((name, span)) => parse_ty_name(&name) - .ok_or_else(|| syn::Error::new(span, format!("`{name}` is not a spec type")))?, + Some((name, span)) => { + let declared = parse_ty_name(&name) + .ok_or_else(|| syn::Error::new(span, format!("`{name}` is not a spec type")))?; + // A spec type the field could never read. The merge coerces to the *declared* + // type, so the shape it hands over is decided here and not by what a layer + // supplied — `ty = "uint"` on a `String` field therefore failed every single + // `read`, whatever anyone configured. Only an always-broken pairing is refused: + // `ty = "int"` on a `u8` is a widening the author may mean, and it reads + // whenever the value fits. + if let Some(shape) = declared.shape() { + if reads_shape(&inner, shape) == Some(false) { + return Err(syn::Error::new( + span, + format!( + "a `{name}` setting reaches this field as {}, which `{}` \ + cannot read: `ty` renames what the spec calls a setting, and \ + cannot change what the field holds", + describe_shape(shape), + rust_type_name(&inner), + ), + )); + } + } + declared + } None => infer_ty(&inner).ok_or_else(|| { syn::Error::new( inner.span(), @@ -738,6 +761,76 @@ fn peel_option(ty: &syn::Type) -> (bool, syn::Type) { } /// The spec type a Rust type names on its own, or `None` for one that needs `ty = "..."`. +/// The `Value` variant the merge hands a setting of a given spec type. +/// +/// `Ty::coerce` decides the shape from the *declared* type, not from what a layer supplied, so +/// this is what the field's `FromValue` will actually be given. +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Bool, + Int, + Float, + Str, + List, + Map, +} + +impl Ty { + /// `None` for `any`, which is the one type the merge does not coerce: it hands over + /// whatever arrived, so no shape can be promised or refused. + fn shape(&self) -> Option { + Some(match self { + Self::Any => return None, + Self::Bool => Shape::Bool, + // A `uint` is an `int` the merge additionally refuses when negative. Same shape. + Self::Int | Self::Uint => Shape::Int, + Self::Float => Shape::Float, + Self::String | Self::Path | Self::Url | Self::Duration => Shape::Str, + Self::Object | Self::Map(_) => Shape::Map, + Self::List(_) | Self::Set(_) => Shape::List, + }) + } +} + +/// Whether the field's `FromValue` can read a value of `shape`. +/// +/// `None` where the type is not one this knows — a type alias, or a type whose `FromValue` an +/// adopter wrote — because a `ty` override exists for exactly the types this cannot measure, +/// and refusing what it cannot see would make the escape hatch useless. +fn reads_shape(ty: &syn::Type, shape: Shape) -> Option { + let syn::Type::Path(path) = ty else { + return None; + }; + let last = path.path.segments.last()?; + Some(match last.ident.to_string().as_str() { + // `Value` is the escape hatch for `any`: it reads whatever it is handed. + "Value" => true, + "bool" => shape == Shape::Bool, + "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => { + shape == Shape::Int + } + // A whole number is a perfectly good float, which is the rule `FromValue for f64` + // states and this has to agree with. + "f32" | "f64" => shape == Shape::Float || shape == Shape::Int, + "String" | "PathBuf" => shape == Shape::Str, + "Vec" => shape == Shape::List, + "BTreeMap" => shape == Shape::Map, + _ => return None, + }) +} + +/// A shape as a message names it: what the field will actually be handed. +fn describe_shape(shape: Shape) -> &'static str { + match shape { + Shape::Bool => "a boolean", + Shape::Int => "an integer", + Shape::Float => "a number", + Shape::Str => "text", + Shape::List => "a list", + Shape::Map => "a table", + } +} + /// A field's Rust type as written, for a message that has to name it. fn rust_type_name(ty: &syn::Type) -> String { quote::ToTokens::to_token_stream(ty) @@ -1581,6 +1674,67 @@ mod tests { ); } + #[test] + fn a_ty_the_field_could_never_read_is_refused() { + // The merge coerces to the *declared* type, so the shape the field is handed is + // decided by `ty` and not by what a layer supplied. A pairing whose shapes disagree + // therefore fails every `read` for every input — the same "accepted declaration, + // broken at every read" trap as a default the field cannot hold. + for (field, ty) in [ + ("name: String", "uint"), + ("name: String", "bool"), + ("jobs: u64", "string"), + ("jobs: u64", "duration"), + ("flag: bool", "string"), + // A container declared over a scalar field, and the reverse. + ("jobs: u64", "list"), + ("ports: Vec", "uint"), + ( + "table: std::collections::BTreeMap", + "string", + ), + ] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}")] + {field}, + }} + "# + )); + assert!( + err.contains("cannot read"), + "`ty = \"{ty}\"` was accepted on `{field}`: {err}" + ); + } + + // The pairings that do read. `duration` on a `String` is the reason the attribute + // exists — a span of time is carried as its text, and the crate that owns the duration + // type owns its spelling. + for (field, ty) in [ + ("timeout: String", "duration"), + ("home: std::path::PathBuf", "path"), + ("home: std::path::PathBuf", "url"), + // An integer is a perfectly good float, which `FromValue for f64` states. + ("ratio: f64", "int"), + ("ratio: f32", "float"), + // `any` promises no shape, so it refuses none. + ("whatever: String", "any"), + // A widening the author may mean: it reads whenever the value fits, so it is + // theirs to make rather than this check's to refuse. + ("small: u8", "int"), + ] { + accepted(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}")] + {field}, + }} + "# + )); + } + } + #[test] fn a_ty_override_does_not_widen_what_the_field_holds() { // `ty` renames what the *spec* calls a setting; it does not change what the struct diff --git a/docs/rust/settings.md b/docs/rust/settings.md index 39d1c31ac..27bb53b08 100644 --- a/docs/rust/settings.md +++ b/docs/rust/settings.md @@ -80,6 +80,12 @@ Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): | `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | | `flatten` | Splice another `Config` struct's settings in at this position | +`ty` renames what the spec calls a setting; it cannot change what the field holds. The merge +coerces to the _declared_ type, so that is what decides the shape the field is handed — a +`ty = "uint"` on a `String` field would be given an integer the field cannot read, whatever +anyone configured, and is refused. A pairing that can read is left alone: `ty = "int"` on a +`u8` reads whenever the value fits, which is the author's call to make. + A default and a choice are written as the type the field holds. Nothing coerces a declared default — the resolver seeds it as written and hands it to the field — so `default = 1` on a `String` field is a compile error rather than the text `1`, and `default(80)` is how a list of From 7416f41e4a08ecca363d4718c939d9791c81fbb3 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:27:27 +0000 Subject: [PATCH 25/28] fix(derive): compare a `ty` override's item types, not just its outer kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Shape` flattened every container, so the check I added one commit ago compared `list` against `Vec` and stopped there: `ty = "list"` on a `Vec` and `ty = "map"` on a `BTreeMap` both compiled. The merge coerces a list's *items* to the declared item type, so those fail on the first item of every read — the always-broken case the check exists to refuse. It walks the two types together now, so a mismatch at any depth is caught, and the message names the pairing rather than a shape so it reads correctly wherever the disagreement is. `object` stays exempt at the value position: it describes a table whose value types the spec deliberately does not, so there is nothing to hold the field's own to. An item type the check cannot measure keeps the escape hatch open at depth, as it does at the top. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 119 ++++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 41 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index 82b7faf5f..21314833f 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -577,19 +577,15 @@ impl Field { // `read`, whatever anyone configured. Only an always-broken pairing is refused: // `ty = "int"` on a `u8` is a widening the author may mean, and it reads // whenever the value fits. - if let Some(shape) = declared.shape() { - if reads_shape(&inner, shape) == Some(false) { - return Err(syn::Error::new( - span, - format!( - "a `{name}` setting reaches this field as {}, which `{}` \ - cannot read: `ty` renames what the spec calls a setting, and \ - cannot change what the field holds", - describe_shape(shape), - rust_type_name(&inner), - ), - )); - } + if reads(&inner, &declared) == Some(false) { + return Err(syn::Error::new( + span, + format!( + "a `{name}` setting cannot be read into `{}`: `ty` renames what \ + the spec calls a setting, and cannot change what the field holds", + rust_type_name(&inner), + ), + )); } declared } @@ -792,42 +788,49 @@ impl Ty { } } -/// Whether the field's `FromValue` can read a value of `shape`. +/// Whether the field's `FromValue` can read what a setting of type `declared` is handed. /// -/// `None` where the type is not one this knows — a type alias, or a type whose `FromValue` an -/// adopter wrote — because a `ty` override exists for exactly the types this cannot measure, -/// and refusing what it cannot see would make the escape hatch useless. -fn reads_shape(ty: &syn::Type, shape: Shape) -> Option { - let syn::Type::Path(path) = ty else { +/// Structural, not a top-level shape comparison: the merge coerces a list's *items* to the +/// declared item type, so `ty = "list"` on a `Vec` is handed a list of integers +/// and fails on the first one. Comparing only the outer kind called that a match. +/// +/// `None` where the answer is not knowable — a type alias, a type whose `FromValue` an adopter +/// wrote, or a declared type that promises no shape — because a `ty` override exists for +/// exactly the types this cannot measure, and refusing what it cannot see would make the +/// escape hatch useless. +fn reads(field: &syn::Type, declared: &Ty) -> Option { + // `any` is the one type the merge does not coerce: it hands over whatever arrived, so + // there is no shape to promise or refuse. + let shape = declared.shape()?; + let syn::Type::Path(path) = field else { return None; }; let last = path.path.segments.last()?; - Some(match last.ident.to_string().as_str() { + match last.ident.to_string().as_str() { // `Value` is the escape hatch for `any`: it reads whatever it is handed. - "Value" => true, - "bool" => shape == Shape::Bool, + "Value" => Some(true), + "bool" => Some(shape == Shape::Bool), "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" => { - shape == Shape::Int + Some(shape == Shape::Int) } // A whole number is a perfectly good float, which is the rule `FromValue for f64` // states and this has to agree with. - "f32" | "f64" => shape == Shape::Float || shape == Shape::Int, - "String" | "PathBuf" => shape == Shape::Str, - "Vec" => shape == Shape::List, - "BTreeMap" => shape == Shape::Map, - _ => return None, - }) -} - -/// A shape as a message names it: what the field will actually be handed. -fn describe_shape(shape: Shape) -> &'static str { - match shape { - Shape::Bool => "a boolean", - Shape::Int => "an integer", - Shape::Float => "a number", - Shape::Str => "text", - Shape::List => "a list", - Shape::Map => "a table", + "f32" | "f64" => Some(shape == Shape::Float || shape == Shape::Int), + "String" | "PathBuf" => Some(shape == Shape::Str), + "Vec" => match declared { + Ty::List(item) | Ty::Set(item) => { + generic_arg(last, 0).and_then(|inner| reads(&inner, item)) + } + _ => Some(false), + }, + "BTreeMap" => match declared { + Ty::Map(value) => generic_arg(last, 1).and_then(|inner| reads(&inner, value)), + // `object` is a table whose value types the spec deliberately does not describe, + // so there is nothing to hold the field's own to. + Ty::Object => None, + _ => Some(false), + }, + _ => None, } } @@ -1693,6 +1696,22 @@ mod tests { "table: std::collections::BTreeMap", "string", ), + // And the *item* types, not only the outer kind: the merge coerces a list's items + // to the declared item type, so a list of the wrong thing fails on the first one. + ("names: Vec", "list"), + ("ports: Vec", "list"), + ("ports: Vec", "set"), + ("nested: Vec>", "list>"), + ( + "table: std::collections::BTreeMap", + "map", + ), + // A list declared where a map is held, and the reverse. + ( + "table: std::collections::BTreeMap", + "list", + ), + ("names: Vec", "map"), ] { let err = rejection(&format!( r#" @@ -1703,7 +1722,7 @@ mod tests { "# )); assert!( - err.contains("cannot read"), + err.contains("cannot be read into"), "`ty = \"{ty}\"` was accepted on `{field}`: {err}" ); } @@ -1723,6 +1742,24 @@ mod tests { // A widening the author may mean: it reads whenever the value fits, so it is // theirs to make rather than this check's to refuse. ("small: u8", "int"), + // Item types that agree, at depth, and through a set — which the merge hands over + // as a list like any other. + ("names: Vec", "list"), + ("paths: Vec", "set"), + ("spans: Vec", "list"), + ("nested: Vec>", "list>"), + ( + "table: std::collections::BTreeMap", + "map", + ), + // `object` is a table whose value types the spec deliberately does not describe, + // so there is nothing to hold the field's own to. + ( + "table: std::collections::BTreeMap", + "object", + ), + // An item type this cannot measure keeps the escape hatch open at depth too. + ("items: Vec", "list"), ] { accepted(&format!( r#" From 2d42470774016bce83879b7cbb9ff8b3731b086f Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:32:40 +0000 Subject: [PATCH 26/28] test(derive): assert the spec endpoint answers with the settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `to_kdl` now has two independent appenders writing to the same string — the `config` block from this PR and `spec_extra` from #1183 — and it is also what the `__usage_spec__` endpoint answers with. The point of that endpoint is that a tool can ask *any* usage binary what it is, so what it answers has to include what the binary can be configured with. Written while resolving the rebase that brought the two together, since nothing covered the composition. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index d48796413..59c8ca6b6 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -426,6 +426,30 @@ fn the_clis_spec_carries_its_settings() { ); } +#[test] +fn the_spec_endpoint_answers_with_the_settings_too() { + // `to_kdl` is where the config block is appended and also what the `__usage_spec__` + // endpoint answers with, so the point of the endpoint — that a tool can ask *any* usage + // binary what it is — has to include what it can be configured with. Worth its own test + // because `to_kdl` now has two independent appenders, this one and `spec_extra`, and they + // write to the same string. + let request = [OsStr::new(usage_argv::SPEC_REQUEST)]; + let answered = Ex::spec_request(&request).expect("the endpoint answers"); + let spec: usage::Spec = answered.parse().expect("usage-lib reads what it answered"); + assert!( + spec.config.props.contains_key("task.output"), + "the endpoint's answer carries the settings: {answered}" + ); + assert_eq!( + answered, + Ex::to_kdl(), + "the endpoint answers the whole spec" + ); + + // And an ordinary command line is not a request, so the endpoint does not shadow a parse. + assert!(Ex::spec_request(&[OsStr::new("--jobs"), OsStr::new("4")]).is_none()); +} + #[test] fn the_command_line_outranks_every_other_layer() { let argv = [OsStr::new("-j"), OsStr::new("12")]; From b656c74df9320b26ca2d4d86c96acad098f0ac66 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:42:11 +0000 Subject: [PATCH 27/28] docs(rust): settle that an alias is written in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prefix` applies to `key` alone, and that is the rule rather than an accident waiting on a decision. An alias is usually a name a setting used to have, and one that moved into a group often wants the unprefixed spelling it had before the move — `task.jobs` keeping plain `jobs`. Prefixing aliases would make that unsayable; writing them in full costs a repeated word and can say either. Stated in the guide and in the test that pins it, which previously described it as an open question. Co-Authored-By: Claude Opus 5 --- conformance/tests/derive_config.rs | 15 ++++++------ docs/rust/settings.md | 39 +++++++++++++++++------------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 59c8ca6b6..274386ec4 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -244,13 +244,14 @@ fn the_emitted_config_block_is_the_spec_grammar() { #[test] fn a_groups_prefix_reaches_its_keys_and_not_its_aliases() { - // `prefix` is applied to `key` alone, so an alias inside a flattened group is written - // out in full. Pinned rather than asserted to be right: an author who reads `prefix` as - // "everything this group names lives under `task.`" would expect `alias("out")` to mean - // `task.out`, and today it means a bare top-level `out`. Whichever way that should go, a - // change to it should be a deliberate one rather than a surprise, and the collision - // checks in `Config::from_input` and `concat_props` are what keep the current reading - // from quietly stealing a name off another group. + // `prefix` is applied to `key` alone: an alias is written out in full, prefix and all. + // + // Deliberate, because an alias is usually a *legacy* name and a setting that moved into a + // group often wants the spelling it had before the move — `task.jobs` keeping plain + // `jobs`. Prefixing aliases would make that unsayable, while writing them in full costs + // one repeated word and can say either. The collision checks in `Config::from_input` and + // `concat_props` are what keep a full-form alias from quietly taking a name off another + // group, which is the risk this reading carries. let output = Settings::SETTINGS_PROPS .iter() .find(|meta| meta.key == "task.output") diff --git a/docs/rust/settings.md b/docs/rust/settings.md index 27bb53b08..4f2ccdc8f 100644 --- a/docs/rust/settings.md +++ b/docs/rust/settings.md @@ -62,23 +62,23 @@ exactly as they do for flags. Field attributes mirror the spec's [`prop` vocabulary](/spec/reference/config): -| Attribute | Effect | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | -| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | -| `default = 4` / `default(80, 443)` | The value when no layer supplies one | -| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | -| `default_note = "…"` | Prose beside the default, for docs | -| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | -| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | -| `choices("a", "b")` | The only values it accepts | -| `merge = "union"` / `"deep"` | How a collection combines across layers | -| `scope = "global"` / `"env"` | Where a value is accepted from | -| `parse = "list_by_comma"` | How one string becomes several values | -| `alias("other")` | Equivalent keys accepted without a warning — written in full, not under the group's `prefix` | -| `key = "match"` | The dotted key, when the field name is not it | -| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | -| `flatten` | Splice another `Config` struct's settings in at this position | +| Attribute | Effect | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `env = "X"` / `env("A", "B")` | Environment variables, highest precedence first | +| `deprecated_env("OLD")` | Deprecated aliases, consulted afterwards and warned about | +| `default = 4` / `default(80, 443)` | The value when no layer supplies one | +| `default_fn = path` | A computed default (`fn() -> T`), applied after the resolution | +| `default_note = "…"` | Prose beside the default, for docs | +| `cli("--jobs", "-j")` | The flags that set it — what `Registry::drift` holds bindings against | +| `source("git", "hk.jobs")` | Its keys in sources usage does not know about | +| `choices("a", "b")` | The only values it accepts | +| `merge = "union"` / `"deep"` | How a collection combines across layers | +| `scope = "global"` / `"env"` | Where a value is accepted from | +| `parse = "list_by_comma"` | How one string becomes several values | +| `alias("other")` | Equivalent keys accepted without a warning — written in full, so a group's `prefix` is repeated rather than implied | +| `key = "match"` | The dotted key, when the field name is not it | +| `hide`, `deprecated = "…"`, `since = "…"`, `examples(…)` | Documentation and lifecycle metadata | +| `flatten` | Splice another `Config` struct's settings in at this position | `ty` renames what the spec calls a setting; it cannot change what the field holds. The merge coerces to the _declared_ type, so that is what decides the shape the field is handed — a @@ -86,6 +86,11 @@ coerces to the _declared_ type, so that is what decides the shape the field is h anyone configured, and is refused. A pairing that can read is left alone: `ty = "int"` on a `u8` reads whenever the value fits, which is the author's call to make. +An `alias` is written in full, including a group's `prefix` — `alias("task.out")` inside a +`#[usage(prefix = "task")]` group, not `alias("out")`. An alias is usually a name a setting +used to have, and one that moved into a group often wants its old unprefixed spelling, so the +full form is the one that can say either. + A default and a choice are written as the type the field holds. Nothing coerces a declared default — the resolver seeds it as written and hands it to the field — so `default = 1` on a `String` field is a compile error rather than the text `1`, and `default(80)` is how a list of From 1e831bb422697d7dff5a9532d6fe0c530a5bad6c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:08:15 +0000 Subject: [PATCH 28/28] fix(derive): measure `u64` too, which a `ty` override can leave unguarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `field_holds` covered every unsigned width except `u64`, on the reasoning that `Ty::Uint` already refuses a negative constant. A `ty` override replaces the spec type that was doing the refusing: `ty = "int"` or `ty = "any"` on a `u64` field let `default = -1` through, and `u64::from_value` then failed every read. Item choices on a `Vec` had the same hole. The list is exhaustive now rather than curated — every integer the reader has a `FromValue` for, including `i64`, whose check is a formality — because that reasoning is exactly what an omission hides behind. Also puts the spec-type check before the field-type one in the choices loop, the order the default checks already use. A plain `u64` should hear that `uint` cannot hold a negative, which is the contract the registry enforces; the field-level message is for when the spec type is fine and an override moved the problem. Co-Authored-By: Claude Opus 5 --- derive/src/config.rs | 54 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/derive/src/config.rs b/derive/src/config.rs index 21314833f..1ccb364e8 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -685,22 +685,26 @@ impl Field { )); } for choice in &prop.choices { - if !field_holds(&prop.read_ty, choice, Position::Choice) { + // The spec type first, then the field's own — the same order the default checks + // run in. A plain `u64` should hear that `uint` cannot hold a negative, which is + // the contract the registry will enforce; the field-level complaint is for when + // the spec type is fine and a `ty` override moved the problem. + if !prop.ty.admits(choice, Position::Choice) { return Err(syn::Error::new( ident.span(), format!( - "a choice does not fit `{}`, so nothing could ever supply it", - rust_type_name(&prop.read_ty) + "a choice is not a value `{}` can hold. Write it as the type the \ + setting is — a `string` setting's choices are strings, quoted", + type_name(&prop.ty) ), )); } - if !prop.ty.admits(choice, Position::Choice) { + if !field_holds(&prop.read_ty, choice, Position::Choice) { return Err(syn::Error::new( ident.span(), format!( - "a choice is not a value `{}` can hold. Write it as the type the \ - setting is — a `string` setting's choices are strings, quoted", - type_name(&prop.ty) + "a choice does not fit `{}`, so nothing could ever supply it", + rust_type_name(&prop.read_ty) ), )); } @@ -884,13 +888,20 @@ fn field_holds(ty: &syn::Type, value: &Const, position: Position) -> bool { }; } match last.ident.to_string().as_str() { + // Every integer the reader has a `FromValue` for, including the two whose check is a + // formality: `i64` holds anything a `Const::Int` can be, and `u64` refuses only a + // negative. `u64` was left out on the reasoning that `Ty::Uint` already refuses those + // — which a `ty = "int"` or `ty = "any"` override replaces, so the reasoning did not + // hold and the list is exhaustive rather than curated now. "u8" => fits!(u8), "u16" => fits!(u16), "u32" => fits!(u32), + "u64" => fits!(u64), "usize" => fits!(usize), "i8" => fits!(i8), "i16" => fits!(i16), "i32" => fits!(i32), + "i64" => fits!(i64), "isize" => fits!(isize), // The rule `FromValue for f32` follows: rounding a value that fits is ordinary // precision loss, and turning a finite one into an infinity is not. @@ -1619,6 +1630,35 @@ mod tests { ); assert!(err.contains("does not fit"), "unhelpful: {err}"); + // A `ty` override can replace the spec type that was doing the refusing, so the + // field's own type has to be the thing checked. `Ty::Uint` refuses a negative, but + // `ty = "int"` and `ty = "any"` do not — and a `u64` still cannot hold one, so every + // read failed on a default the derive had accepted. + for ty in ["int", "any"] { + let err = rejection(&format!( + r#" + struct Settings {{ + #[usage(ty = "{ty}", default = -1)] + jobs: u64, + }} + "# + )); + assert!( + err.contains("does not fit `u64`"), + "`ty = \"{ty}\"` let a negative default past: {err}" + ); + } + // Through a list too, where the item is what cannot hold it. + let err = rejection( + r#" + struct Settings { + #[usage(ty = "list", choices(1, -1))] + ports: Vec, + } + "#, + ); + assert!(err.contains("does not fit"), "unhelpful: {err}"); + // A choice on a *list* setting names one item, so it is the item type that has to // hold it. Measured against `Vec` instead, a bare `70000` reached the container // arm and was called fitting — and `Ty::admits` allows a scalar as an item, so this