Visitar URL original
feat(spec): let a flag with setting declare its setting (#1610) · jdx/usage@af36eb3 · GitHub
Skip to content

Commit af36eb3

Browse files
jdxclaude
andauthored
feat(spec): let a flag with setting declare its setting (#1610)
A setting that a flag sets had to be declared twice: once on the `#[derive(Args)]`/`#[derive(Cli)]` field the program parses, and again on a `#[derive(Config)]` field so it reached `SETTINGS_REGISTRY`. `Registry::drift` caught the two copies disagreeing, but every flag-backed setting was still written and reviewed twice. [moq](https://github.com/moq-dev/moq), for example, keeps 646 lines of settings registries that restate about 110 flags. Now the flag is the declaration. A field with `setting = "key"` provides everything the registry entry needs: | Registry entry | Comes from | | --- | --- | | type | the field's type (`Vec<T>` is a list, a counter is `uint`, an unrecognised type is `string`) | | environment names | `env`, `env_fallback`, `deprecated_env` | | how one string becomes a list | `delimiter = ','` or `':'` | | help | the doc comment | | flags | every flag bound to the key, including a negation, and the same key bound on several subcommands | ```rust #[derive(usage_rs::Args)] struct ListenTls { /// Load the given certificate from disk. #[usage(long = "listen-tls-cert", env = "EX_LISTEN_TLS_CERT", delimiter = ',', setting = "listen.tls.cert")] cert: Vec<std::path::PathBuf>, } ``` The root `Cli` now exposes `SETTINGS_PROPS` and `SETTINGS_REGISTRY`. In the example above, the registry holds `listen.tls.cert` as a `list<path>` that `EX_LISTEN_TLS_CERT=a.pem,b.pem` or `--listen-tls-cert` sets. Its `cli` is exactly the bound flags, so `Ex::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS)` is always empty. When the root names `config = Settings`, that struct's settings are joined in, and the spec's `config` block documents both kinds. A root with no `config` type now gets a `config` block too, listing the settings its flags declare, so docs, the JSON schema and config-key completion can see them. When several subcommands bind the same key, they become one setting. Their field types must match, and so must their `env` and `deprecated_env` lists and their `delimiter` when more than one gives one. A list setting must split on `,` or `:`, the delimiters a registry parser also splits the environment on. Breaking any of these rules is a compile error that points to a `Config` declaration instead, unless a `Config` field already declares the key. **Compatibility.** When a key is declared both on a flag and on the `config` struct, the struct's declaration wins. A CLI that declares every setting there today gets the same registry and compiles unchanged. A setting that needs a default, `merge`, `scope` or `choices` stays a `Config` field, and so does one no flag sets. Flag-declared settings are always optional and have no registry default. A hand-written `CommandArgs` or `Subcommands` implementation that binds a key without declaring it still compiles. Its key is left to whatever registry it already uses. **Layering.** Groups describe their settings in a new `usage_argv::spec::SettingDecl`, which doesn't mention `usage-config`, through a `SETTING_DECLS` constant on `CommandArgs` and `Subcommands` (empty by default). A library of flags still doesn't depend on `usage-config`. The root converts these declarations at compile time through `usage-rs`, the only crate that depends on both `usage-argv` and `usage-config`. The code it generates is only emitted for CLIs that resolve settings. **Validation.** `cargo test --all --all-features` passes. The new `conformance/tests/derive_settings_declared.rs` covers these cases: - types, environment names, splitting and help reach the registry - one key bound on two subcommands with different spellings becomes one setting - the `Config` declaration wins a shared key - the flag and the environment reach the same setting through `resolve` - the spec round-trips through usage-lib, with and without a `config` type - a binding with no declaration behind it is skipped - mismatched environment names or delimiters for one key are refused, and so is a delimiter the registry can't split on `mise run lint` fails only on a clippy `duplicated_attributes` error in `benches/shadows/pitchfork`, which fails the same way on `main`. I haven't measured perf or binary size locally. Closes #1608 *AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5-5; version: 2.1.316.* 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * CLI flags can now declare settings that appear in the generated settings registry and documentation, including their types, environment variables, and help text. * Settings declared by flags are optional and have no registry default. When a configuration type declares the same setting, its definition takes precedence. * **Documentation** * Updated the configuration guide with details on flag-declared settings and how they interact with configuration types. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes compile-time registry and spec generation for every settings-resolving CLI, but preserves behavior when `Config` owns keys and adds broad conformance tests. > > **Overview** > **Flag-backed settings no longer need a duplicate `Config` field** for registry metadata. Fields with `setting = "key"` now drive `SettingDecl` tables (via new `SETTING_DECLS` on `CommandArgs` / `Subcommands`), and the root `#[derive(Cli)]` joins them into **`SETTINGS_PROPS`** and **`SETTINGS_REGISTRY`**, with CLI spellings taken from **`SETTINGS_BINDINGS`** so drift stays aligned by construction. > > The derive layer emits declarations from each bound field (type, `env` / deprecated env, list `delimiter`, doc help). **`usage-rs`** converts those tables to `PropMeta` at compile time. When `config = Type` is set, **`Config` still wins** on shared keys (defaults, merge, scope); flag-only settings are optional with no registry default. KDL **`config`** blocks now document the **joined** registry, including CLIs with no `Config` type. > > **`concat_setting_decls`** and compile-time checks fold duplicate keys across subcommands and reject mismatched types, env names, or delimiters. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c9e2b64. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e7dcfd4 commit af36eb3

8 files changed

Lines changed: 949 additions & 18 deletions

File tree

‎argv/src/spec.rs‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4895,6 +4895,100 @@ pub const fn concat_bindings<const N: usize>(
48954895
joined
48964896
}
48974897

4898+
/// What a flag says about the setting it sets, so the flag can be the setting's declaration.
4899+
///
4900+
/// The type and the environment names a registry needs are already on the field that parses the
4901+
/// flag, and writing them a second time on a `usage::Config` field is a second description to
4902+
/// drift. So every command describes its bound settings in these terms, and the root — the one
4903+
/// place that knows what a registry is — turns the joined table into `usage_config::PropMeta`.
4904+
/// Like [`SettingGiven`], it names nothing from the config crate, so a group of flags declared in a
4905+
/// library still never depends on it.
4906+
///
4907+
/// No flags here: the root reads those from the binding table, which already lists every spelling
4908+
/// that sets each key, so the registry's `cli` and `SETTINGS_BINDINGS` cannot disagree.
4909+
#[derive(Debug, Clone, Copy, PartialEq)]
4910+
pub struct SettingDecl {
4911+
/// The dotted key, from `setting = "..."`.
4912+
pub key: &'static str,
4913+
/// What one value is.
4914+
pub ty: SettingTy,
4915+
/// Whether the field collects several values, making the setting a list of `ty`.
4916+
pub list: bool,
4917+
/// Environment variables that set it, highest precedence first.
4918+
pub envs: &'static [&'static str],
4919+
/// Deprecated environment aliases, consulted after every current name.
4920+
pub deprecated_envs: &'static [&'static str],
4921+
/// How one string becomes a list, from the field's `delimiter`.
4922+
pub split: Option<SettingSplit>,
4923+
pub help: Option<&'static str>,
4924+
pub long_help: Option<&'static str>,
4925+
}
4926+
4927+
impl SettingDecl {
4928+
/// A declaration with nothing but a key and a type.
4929+
pub const fn new(key: &'static str, ty: SettingTy) -> Self {
4930+
Self {
4931+
key,
4932+
ty,
4933+
list: false,
4934+
envs: &[],
4935+
deprecated_envs: &[],
4936+
split: None,
4937+
help: None,
4938+
long_help: None,
4939+
}
4940+
}
4941+
}
4942+
4943+
/// The type of one value of a flag-declared setting, read from the field.
4944+
///
4945+
/// A field whose type is not one of these — an enum, or anything else parsed with `FromStr` — is
4946+
/// a `String` to the registry, which is what the flag hands it anyway.
4947+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4948+
pub enum SettingTy {
4949+
Bool,
4950+
Int,
4951+
Uint,
4952+
Float,
4953+
String,
4954+
Path,
4955+
}
4956+
4957+
/// How a flag-declared list setting splits one string, from the field's `delimiter`.
4958+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4959+
pub enum SettingSplit {
4960+
/// `delimiter = ','`.
4961+
Comma,
4962+
/// `delimiter = ':'`.
4963+
Colon,
4964+
/// Any other delimiter, which no registry parser splits on. The root refuses it unless a
4965+
/// `usage::Config` field declares the setting, so the environment never splits differently
4966+
/// from the flag.
4967+
Other,
4968+
}
4969+
4970+
/// The setting declarations of several commands, joined into one array.
4971+
///
4972+
/// The [`concat_bindings`] counterpart. A key may appear more than once — two subcommands can each
4973+
/// have a `--jobs` that sets `jobs` — and the root folds those into one setting.
4974+
pub const fn concat_setting_decls<const N: usize>(
4975+
parts: &[&'static [SettingDecl]],
4976+
) -> [SettingDecl; N] {
4977+
let mut joined = [SettingDecl::new("", SettingTy::String); N];
4978+
let mut at = 0;
4979+
let mut part = 0;
4980+
while part < parts.len() {
4981+
let mut i = 0;
4982+
while i < parts[part].len() {
4983+
joined[at] = parts[part][i];
4984+
at += 1;
4985+
i += 1;
4986+
}
4987+
part += 1;
4988+
}
4989+
joined
4990+
}
4991+
48984992
/// Presence information exposed across a flattened [`CommandArgs`] boundary.
48994993
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49004994
pub struct ArgumentState {
@@ -5023,6 +5117,11 @@ pub trait CommandArgs: Sized {
50235117
/// what `usage_config::Registry::drift` is held against.
50245118
const SETTINGS_BINDINGS: &'static [(&'static str, &'static str)] = &[];
50255119

5120+
/// What this command's bound flags say about the settings they set.
5121+
///
5122+
/// Empty by default, for the same reason as [`CommandArgs::SETTINGS_BINDINGS`].
5123+
const SETTING_DECLS: &'static [SettingDecl] = &[];
5124+
50265125
/// The settings this command line gave values for.
50275126
///
50285127
/// From the partial rather than from the built struct, and only for flags that were actually
@@ -5467,6 +5566,9 @@ pub trait Subcommands: Sized {
54675566
/// is compared against a spec that documents all of them.
54685567
const SETTINGS_BINDINGS: &'static [(&'static str, &'static str)] = &[];
54695568

5569+
/// What every one of these commands' bound flags says about the settings they set.
5570+
const SETTING_DECLS: &'static [SettingDecl] = &[];
5571+
54705572
/// The settings the selected command was given values for, and no other command's.
54715573
///
54725574
/// `None` selected is nothing given, which is also what a CLI that reached no subcommand
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! A flag with `setting = "key"` as the declaration of that setting.
2+
//!
3+
//! Everything a registry entry says about a flag-backed setting is already on the field that parses
4+
//! the flag: the key, the type, the environment names, the help, and the flags themselves. So the
5+
//! root joins what its flags declare into `SETTINGS_REGISTRY`, and a `usage::Config` struct is left
6+
//! for the settings no flag sets — files only, or ones that need a default, a merge or a scope.
7+
//! A key both declare is the `Config` struct's.
8+
9+
use std::ffi::OsStr;
10+
use std::path::PathBuf;
11+
12+
use usage_config::{EnvLayer, Layers, Parser, Ty, Value, resolve};
13+
use usage_derive::{Args, Cli, Config, Subcommands};
14+
15+
/// How the relay listens for TLS
16+
#[derive(Args, Debug)]
17+
struct ListenTls {
18+
/// Load the given certificate from disk.
19+
#[usage(
20+
long = "listen-tls-cert",
21+
env = "EX_LISTEN_TLS_CERT",
22+
deprecated_env = "EX_TLS_CERT",
23+
delimiter = ',',
24+
setting = "listen.tls.cert"
25+
)]
26+
cert: Vec<PathBuf>,
27+
}
28+
29+
/// Run the relay
30+
#[derive(Args, Debug)]
31+
struct Serve {
32+
/// How many jobs to run at once
33+
#[usage(long, short = 'j', setting = "jobs")]
34+
jobs: Option<u16>,
35+
}
36+
37+
/// Check the relay's configuration
38+
#[derive(Args, Debug)]
39+
struct Check {
40+
/// How many jobs to run at once
41+
#[usage(long, setting = "jobs")]
42+
jobs: Option<u16>,
43+
}
44+
45+
#[derive(Subcommands, Debug)]
46+
enum Cmd {
47+
Serve(Serve),
48+
Check(Check),
49+
}
50+
51+
/// The settings no flag sets.
52+
#[derive(Config)]
53+
struct Settings {
54+
/// Where the relay announces itself
55+
#[usage(env = "EX_NAME")]
56+
name: Option<String>,
57+
58+
/// Whether to colour the output
59+
#[usage(default = true, cli("--colour", "--no-colour"))]
60+
colour: bool,
61+
}
62+
63+
/// A relay
64+
#[derive(Cli, Debug)]
65+
#[usage(bin = "ex", config = Settings)]
66+
struct Ex {
67+
/// Whether to colour the output
68+
#[usage(long, negate = "no-colour", setting = "colour")]
69+
colour: bool,
70+
71+
/// How chatty to be
72+
#[usage(short, count, setting = "log.verbose")]
73+
verbose: u8,
74+
75+
#[usage(flatten)]
76+
tls: ListenTls,
77+
78+
#[usage(subcommand)]
79+
cmd: Cmd,
80+
}
81+
82+
fn parse(tokens: &[&str]) -> (Ex, usage_config::CliLayer) {
83+
let argv: Vec<&OsStr> = tokens.iter().map(OsStr::new).collect();
84+
Ex::parse_from_with_settings(&argv).expect("should parse")
85+
}
86+
87+
fn prop(key: &str) -> usage_config::PropMeta {
88+
*Ex::SETTINGS_PROPS
89+
.iter()
90+
.find(|p| p.key == key)
91+
.unwrap_or_else(|| panic!("`{key}` is declared"))
92+
}
93+
94+
#[test]
95+
fn the_registry_declares_exactly_the_flags_the_cli_binds() {
96+
// The adopter's drift test, with nothing hand-written on either side.
97+
assert_eq!(
98+
Ex::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS),
99+
Vec::<String>::new()
100+
);
101+
}
102+
103+
#[test]
104+
fn a_flag_says_what_its_setting_is() {
105+
let cert = prop("listen.tls.cert");
106+
assert_eq!(cert.ty, Ty::List(&Ty::Path));
107+
assert_eq!(cert.envs, ["EX_LISTEN_TLS_CERT"]);
108+
assert_eq!(cert.deprecated_envs, ["EX_TLS_CERT"]);
109+
assert_eq!(cert.parse, Some(Parser::ListByComma));
110+
assert_eq!(cert.cli, ["--listen-tls-cert"]);
111+
assert_eq!(cert.help, Some("Load the given certificate from disk."));
112+
// No registry default, so absence is a state the reader handles.
113+
assert_eq!(cert.optional, Some(true));
114+
115+
let verbose = prop("log.verbose");
116+
assert_eq!(verbose.ty, Ty::Uint);
117+
assert_eq!(verbose.cli, ["-v"]);
118+
}
119+
120+
#[test]
121+
fn a_key_several_commands_bind_is_one_setting_with_all_their_flags() {
122+
let jobs: Vec<_> = Ex::SETTINGS_PROPS
123+
.iter()
124+
.filter(|p| p.key == "jobs")
125+
.collect();
126+
assert_eq!(jobs.len(), 1);
127+
assert_eq!(jobs[0].ty, Ty::Uint);
128+
assert_eq!(jobs[0].cli, ["--jobs", "-j"]);
129+
}
130+
131+
#[test]
132+
fn the_config_type_keeps_the_keys_it_declares_and_adds_the_rest() {
133+
// `colour` is declared by both, and the `Config` struct's declaration — with its default —
134+
// is the one the registry holds.
135+
let colour = prop("colour");
136+
assert_eq!(colour.default, Some(usage_config::Const::Bool(true)));
137+
assert_eq!(
138+
Ex::SETTINGS_PROPS
139+
.iter()
140+
.filter(|p| p.key == "colour")
141+
.count(),
142+
1
143+
);
144+
assert_eq!(prop("name").envs, ["EX_NAME"]);
145+
}
146+
147+
#[test]
148+
fn the_flag_and_the_environment_reach_the_same_setting() {
149+
let env = EnvLayer::new([("EX_LISTEN_TLS_CERT".to_string(), "a.pem,b.pem".to_string())]);
150+
let (_, layer) = parse(&["serve"]);
151+
let resolved =
152+
resolve(Ex::SETTINGS_REGISTRY, Layers::new().then(&layer).then(&env)).expect("resolves");
153+
assert_eq!(
154+
resolved.get_key("listen.tls.cert"),
155+
Some(&Value::List(vec![
156+
Value::from("a.pem"),
157+
Value::from("b.pem")
158+
]))
159+
);
160+
161+
let (cli, layer) = parse(&["--listen-tls-cert", "c.pem", "check", "--jobs", "3"]);
162+
assert_eq!(cli.tls.cert, [PathBuf::from("c.pem")]);
163+
let resolved =
164+
resolve(Ex::SETTINGS_REGISTRY, Layers::new().then(&layer).then(&env)).expect("resolves");
165+
assert_eq!(
166+
resolved.get_key("listen.tls.cert"),
167+
Some(&Value::List(vec![Value::from("c.pem")]))
168+
);
169+
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(3)));
170+
}
171+
172+
#[test]
173+
fn the_spec_documents_the_settings_the_flags_declare() {
174+
let kdl = Ex::to_kdl();
175+
assert!(kdl.contains(r#"prop "listen.tls.cert""#), "{kdl}");
176+
assert!(kdl.contains(r#"prop "name""#), "{kdl}");
177+
let _: usage::Spec = kdl.parse().expect("usage-lib reads the whole spec");
178+
}
179+
180+
/// A tool whose only settings are the ones its flags declare
181+
#[derive(Cli, Debug)]
182+
#[usage(bin = "bare")]
183+
struct Bare {
184+
/// How many jobs to run at once
185+
#[usage(long, env = "BARE_JOBS", setting = "jobs")]
186+
jobs: Option<u16>,
187+
}
188+
189+
#[test]
190+
fn a_cli_with_no_config_type_still_documents_its_settings() {
191+
let kdl = Bare::to_kdl();
192+
assert!(kdl.contains(r#"prop "jobs""#), "{kdl}");
193+
let _: usage::Spec = kdl.parse().expect("usage-lib reads the whole spec");
194+
}
195+
196+
#[test]
197+
fn the_config_type_reads_its_own_settings_from_the_joined_registry() {
198+
// The joined registry keeps the `Config` type's props first and in order, so its reader still
199+
// finds them where it expects.
200+
let env = EnvLayer::new([("EX_NAME".to_string(), "relay".to_string())]);
201+
let (_, layer) = parse(&["--no-colour", "serve"]);
202+
let resolved =
203+
resolve(Ex::SETTINGS_REGISTRY, Layers::new().then(&layer).then(&env)).expect("resolves");
204+
let settings = Settings::read(&resolved).expect("reads");
205+
assert_eq!(settings.name.as_deref(), Some("relay"));
206+
assert!(!settings.colour, "the flag outranks the default");
207+
}

0 commit comments

Comments
 (0)