usage-config 6.0.0

Layered configuration resolution for usage specs, with provenance
Documentation

Layered configuration resolution for CLIs that describe their settings in a usage spec.

Every CLI in the jdx fleet has written this by hand, and every copy has rotted differently: hk declares eighteen sources.cli bindings and reads five, pitchfork documents a CLI layer it does not have, fnox's module doc describes a config-file layer that does not exist, and mise hand-copies thirteen flags into its settings in a forty-nine-line function. The drift is not carelessness — it is what happens when the 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. #[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

  • One merge. Provenance is the output of the only merge there is, so config explain cannot describe a resolution that did not happen — which a second, parallel merge function written for the purpose can.
  • Fixed precedence. cli > env > files, nearest first > user > machine > declared defaults. Which layers a CLI has is its own business; their order is not.
  • Scope is enforced, not remembered. A scope="global" setting refuses an untrusted place in the merge, not in each layer, because a check every layer has to make is one a new layer will forget. The question is [Trust], not "was it a file": a pkl file or a git config inside a checkout is exactly as much a thing a repository carries as hk.toml is, and a kind usage does not recognize gets the least trusting answer until its layer says otherwise.
  • Warnings, not output. Nothing here prints. An unknown key, a value of the wrong type, a deprecated setting: all returned, for the CLI to render when its logging is up.
  • Lifecycle gates are explicit. deprecated_warn_at and deprecated_remove_at act against the running CLI version supplied to [resolve_with_context]. This crate's own package version is never assumed.

Example

use usage_config::{resolve, Const, EnvLayer, Layers, PropMeta, Registry, Ty, Value};

// Normally generated from the settings struct by `#[derive(usage::Config)]`.
static PROPS: &[PropMeta] = &[PropMeta {
    envs: &["MYCLI_JOBS"],
    default: Some(Const::Int(4)),
    ..PropMeta::new("jobs", Ty::Uint)
}];
const REGISTRY: Registry = Registry::new(PROPS);

// The environment is described rather than reached for, so a test never touches the process.
// `EnvLayer::from_process` is what a CLI uses.
let env = EnvLayer::new([("MYCLI_JOBS".to_string(), "8".to_string())]);
let resolved = resolve(REGISTRY, Layers::new().then(&env))?;

assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
// And where it came from is the variable the user set, not "the environment".
assert_eq!(
    resolved.origin_key("jobs").unwrap().describe(),
    "MYCLI_JOBS",
);
# Ok::<(), usage_config::LayerError>(())