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 registry, the reader that fills the struct from a resolution, and the spec config block that documents it.
Enable the config feature:
[dependencies]
usage = { package = "usage-rs", version = "5.1", features = ["config"] }Declaring
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<Vec<String>>,
/// 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<T>, and BTreeMap<String, T> name their spec types on their own; Option<T> 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:
| 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 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.
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 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<String> 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.
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:
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 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:
#[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<u64>,
}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:
assert_eq!(Settings::SETTINGS_REGISTRY.drift(Ex::SETTINGS_BINDINGS), Vec::<String>::new());The spec block
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.