From 82096dcc36151b8605885ef5155cc5a0ac5497ce Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:25:13 +0000 Subject: [PATCH] feat(config): resolve settings from layers, with provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new crate, `usage-config`: the runtime half of the config feature. The spec declares the settings; `usage-config-build` will turn that declaration into a registry of consts at build time; this crate resolves values against it. No KDL parser and no dependencies at all, because it is what runs in the CLI. Every CLI in the 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, mise hand-copies thirteen flags in a forty-nine-line function. That is not carelessness; it is what happens when the declaration of a setting and the code resolving it are two things kept in step by hand. What this PR contains: - `Value` and `Const` — the runtime value and the `const`-constructible default, separate because a generated registry must cost nothing to load. - `Ty` and `Parser` — the declared type as a match rather than a parse, and the named splitters (`list_by_comma` and friends) that are spec vocabulary so another language's runtime can honour them identically. - `Registry`/`PropId` — interned keys, so a merge over a hundred settings never hashes one. Rename chains resolve to their end while reporting the name the user actually wrote. - `Layer`/`LayerCtx` — the interface a CLI writes its own sources against, with `Registry::bindings(kind)` as the mechanism behind hk's git and pkl layers and aube's `.npmrc`. usage knows nothing about git; it just hands back the keys. - `resolve` — one merge, `replace`/`union`/`deep`, and provenance as its output rather than a second pass. hk grew a parallel merge just to answer "where did this come from", and the two could disagree; here `config explain` cannot describe a resolution that did not happen. Two things are deliberately in the merge rather than left to layers. Scope (`global`, `env`) is enforced there because mise calls it a security property, and a check every layer has to remember is one a new layer will forget. And a post-merge rewrite goes through `coerced()`, which re-labels the origin, so a value mise derived from `raw` never claims to have come from a file that never said it. Warnings are returned, never printed: an unknown key, a bad value, a deprecated setting. mise queues these until its logging is up, and a library with an opinion about stderr cannot be used by anything that has its own. 24 tests including the crate-level doc example; eight mutations of the merge semantics — inverted precedence, scope unenforced, global scope over-refusing, union and deep degraded to replace, sets not deduplicating, a rewrite keeping its old origin — each verified to fail without its fix. Not in this PR: file layers (TOML/JSON, find-up), the `explain` renderer, the build-time codegen and the typed `Settings` struct, and the config conformance corpus. --- Cargo.lock | 4 + Cargo.toml | 2 + config/Cargo.toml | 20 + config/src/layer.rs | 332 ++++++++++++ config/src/lib.rs | 84 +++ config/src/registry.rs | 305 +++++++++++ config/src/resolve.rs | 1102 ++++++++++++++++++++++++++++++++++++++++ config/src/source.rs | 195 +++++++ config/src/ty.rs | 365 +++++++++++++ config/src/value.rs | 165 ++++++ 10 files changed, 2574 insertions(+) create mode 100644 config/Cargo.toml create mode 100644 config/src/layer.rs create mode 100644 config/src/lib.rs create mode 100644 config/src/registry.rs create mode 100644 config/src/resolve.rs create mode 100644 config/src/source.rs create mode 100644 config/src/ty.rs create mode 100644 config/src/value.rs diff --git a/Cargo.lock b/Cargo.lock index b645958f..fba7a811 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2035,6 +2035,10 @@ dependencies = [ "xx", ] +[[package]] +name = "usage-config" +version = "5.1.0" + [[package]] name = "usage-conformance" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index d4ac16f3..ca5feab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "argv", + "config", "derive", "clap_usage", "cli", @@ -28,6 +29,7 @@ license = "MIT" clap_usage = { path = "./clap_usage", version = "5.0.0" } usage-cli = { path = "./cli" } usage-argv = { path = "./argv", version = "5.1.0" } +usage-config = { path = "./config", version = "5.1.0" } usage-derive = { path = "./derive", version = "5.1.0" } usage-lib = { path = "./lib", version = "5.1.0", features = ["clap"] } diff --git a/config/Cargo.toml b/config/Cargo.toml new file mode 100644 index 00000000..220d88e1 --- /dev/null +++ b/config/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "usage-config" +description = "Layered configuration resolution for usage specs, with provenance" +version = "5.1.0" +edition = "2021" +rust-version = "1.80.0" +homepage = { workspace = true } +documentation = { workspace = true } +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, +# no serde_json, and no format reader it was not asked for. +[dependencies] + +[package.metadata.release] +shared-version = true +release = true diff --git a/config/src/layer.rs b/config/src/layer.rs new file mode 100644 index 00000000..2f8c5d30 --- /dev/null +++ b/config/src/layer.rs @@ -0,0 +1,332 @@ +//! Where values come from, as an interface. +//! +//! usage supplies the command line, the environment and declared defaults. Everything else a +//! CLI reads — a git config, a pkl file, an `.npmrc`, a keyring — is a layer the CLI writes, +//! and it writes it against this trait plus [`Registry::bindings`], which is why hk's git +//! layer is about twenty lines rather than a second resolution system. +//! +//! [`Registry::bindings`]: crate::Registry::bindings + +use crate::registry::{PropId, Registry}; +use crate::source::{Origin, SourceKind}; +use crate::value::Value; + +/// One value a layer supplies. +#[derive(Debug, Clone, PartialEq)] +pub struct Entry { + pub prop: PropId, + pub value: Value, + /// The exact place it came from — the variable's name, the file's path. + pub origin: Origin, + /// The key the user actually wrote, when it was an old name for `prop`. + /// + /// A layer that looks a key up gets the id of the setting that *replaced* it, so without + /// this the resolver cannot tell that anybody used the old name — and the warning that a + /// deprecated key is in somebody's config file never fires. + pub renamed_from: Option<&'static str>, +} + +impl Entry { + pub fn new(prop: PropId, value: Value, origin: Origin) -> Self { + Self { + prop, + value, + origin, + renamed_from: None, + } + } +} + +/// Something a user should know about, which is not bad enough to stop for. +/// +/// Returned rather than printed. mise queues these until its logging is up, and a library +/// that writes to stderr on its own cannot be used by anything that has an opinion about +/// output. +#[derive(Debug, Clone, PartialEq)] +pub struct Warning { + pub message: String, + /// Where the value that caused it came from, when there was one. + pub origin: Option, +} + +impl Warning { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + origin: None, + } + } + + pub fn at(message: impl Into, origin: Origin) -> Self { + Self { + message: message.into(), + origin: Some(origin), + } + } +} + +/// What a layer found. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct LayerOutput { + pub entries: Vec, + pub warnings: Vec, +} + +impl LayerOutput { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, entry: Entry) { + self.entries.push(entry); + } + + pub fn warn(&mut self, warning: Warning) { + self.warnings.push(warning); + } +} + +/// Anything that can fail while a layer reads. +#[derive(Debug, Clone, PartialEq)] +pub enum LayerError { + /// The layer could not read its source at all — a malformed file, a subprocess that + /// failed. Unlike an unknown key, this is not something to degrade past. + Unreadable { source: String, why: String }, +} + +impl std::fmt::Display for LayerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unreadable { source, why } => write!(f, "could not read {source}: {why}"), + } + } +} + +impl std::error::Error for LayerError {} + +/// What a layer is given while it reads. +/// +/// The registry, and the helpers that keep every layer honest about the same two things: a +/// key it does not recognize is a warning rather than an error, and a raw string becomes a +/// value the way the *spec* says rather than the way the layer guesses. +pub struct LayerCtx { + registry: Registry, +} + +impl LayerCtx { + pub fn new(registry: Registry) -> Self { + Self { registry } + } + + pub fn registry(&self) -> Registry { + self.registry + } + + /// The setting a dotted key names, following renames. + pub fn prop(&self, key: &str) -> Option { + self.registry.lookup(key) + } + + /// The setting an id ends up on, and the name it was declared under if that differs. + /// + /// `Registry::bindings` yields the *pre*-rename id, so a git or pkl layer hands over an + /// alias — and the alias's own metadata is usually bare. Reading `parse` and `ty` from it + /// meant a value that should have been split by a declared parser arrived as one unsplit + /// string on the replacement's list. What governs a value is the setting it lands on. + fn folded(&self, id: PropId) -> (PropId, Option<&'static str>) { + let meta = self.registry.get(id); + match meta.renamed_to.and_then(|key| self.registry.lookup(key)) { + Some(target) if target.id != id => (target.id, Some(meta.key)), + _ => (id, None), + } + } + + /// A raw string read as the setting's declared type, applying its named parser first. + /// + /// Every layer that reads text should come through here. A layer that decides for itself + /// how to split a list is how two sources of the same setting end up disagreeing about + /// what a comma means. + pub fn parse(&self, id: PropId, raw: &str) -> Result { + let (id, _) = self.folded(id); + let meta = self.registry.get(id); + let value = match meta.parse { + Some(parser) => parser.split(raw), + None => Value::String(raw.to_string()), + }; + meta.ty.coerce(value) + } + + /// An entry for `id`, with `raw` read as the declared type. + /// + /// The shape almost every layer wants: on a value that cannot be the declared type, the + /// entry is dropped and a warning takes its place, naming the origin. A bad value in a + /// system-wide file must not stop a CLI from starting. + pub fn entry(&self, id: PropId, raw: &str, origin: Origin) -> Result { + // Folded here too, and the name that was written kept, so an entry built from a + // binding carries the same information as one built from a key. + let (id, renamed_from) = self.folded(id); + match self.parse(id, raw) { + Ok(value) => Ok(Entry { + renamed_from, + ..Entry::new(id, value, origin) + }), + Err(err) => { + // The name that was written, not the one it folded to: a message about a key + // the user cannot find in their own file is no help. + let key = renamed_from.unwrap_or(self.registry.get(id).key); + Err(Warning::at( + format!( + "{key} expected {} but {} has `{}`", + err.expected, + origin.describe(), + err.found + ), + origin, + )) + } + } + } +} + +impl LayerCtx { + /// An entry for a dotted key, which is what a layer reading a file has in hand. + /// + /// The path worth taking: it looks the key up, follows a rename while remembering the name + /// that was written, reads the value as the declared type, and turns an unknown key into a + /// warning rather than an error — everything a layer would otherwise have to remember to + /// do, and the reason a deprecated key in somebody's config file gets reported at all. + pub fn entry_for_key(&self, key: &str, raw: &str, origin: Origin) -> Result { + let Some(found) = self.prop(key) else { + return Err(Warning::at(format!("unknown setting `{key}`"), origin)); + }; + let mut entry = self.entry(found.id, raw, origin)?; + // `lookup` already folded, so `entry` had nothing left to fold and nothing to report; + // the name the *user* wrote is the one to keep. + entry.renamed_from = found.renamed_from.or(entry.renamed_from); + Ok(entry) + } +} + +/// A source of configuration values. +pub trait Layer { + /// Which kind of place this reads. Used by the scope check and reported by `explain`. + fn source(&self) -> SourceKind; + + /// Everything this layer has to say, in one pass. + fn load(&self, ctx: &LayerCtx) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::PropMeta; + use crate::ty::{Parser, Ty}; + + static PROPS: &[PropMeta] = &[ + PropMeta::new("jobs", Ty::Uint), + PropMeta { + parse: Some(Parser::ListByComma), + ..PropMeta::new("exclude", Ty::List(&Ty::String)) + }, + ]; + const REGISTRY: Registry = Registry::new(PROPS); + + #[test] + fn a_raw_string_is_read_the_way_the_spec_says() { + let ctx = LayerCtx::new(REGISTRY); + let jobs = ctx.prop("jobs").expect("declared").id; + assert_eq!(ctx.parse(jobs, "4"), Ok(Value::Int(4))); + + // The declared parser runs before the type does, so one string becomes a list and + // no layer has to know that this setting is comma-separated. + let exclude = ctx.prop("exclude").expect("declared").id; + assert_eq!( + ctx.parse(exclude, "target,node_modules"), + Ok(Value::List(vec![ + Value::from("target"), + Value::from("node_modules") + ])) + ); + } + + #[test] + fn an_alias_is_read_with_the_metadata_of_the_setting_it_became() { + // `Registry::bindings` yields the pre-rename id, so a git or pkl layer hands over an + // alias — whose own metadata is usually bare. Reading `parse` and `ty` from it meant a + // comma-separated value arrived as one unsplit string on the replacement's list. + static PROPS: &[PropMeta] = &[ + PropMeta { + parse: Some(Parser::ListByComma), + ..PropMeta::new("exclude", Ty::List(&Ty::String)) + }, + // The alias: no parser, no list type of its own. + PropMeta { + renamed_to: Some("exclude"), + ..PropMeta::new("excludes", Ty::String) + }, + ]; + const REGISTRY: Registry = Registry::new(PROPS); + let ctx = LayerCtx::new(REGISTRY); + let alias = PropId(1); + + assert_eq!( + ctx.parse(alias, "target,vendor"), + Ok(Value::List(vec![ + Value::from("target"), + Value::from("vendor") + ])) + ); + // And the entry lands on the replacement while remembering the name it came in under, + // so the deprecation warning still has something to say. + let entry = ctx + .entry( + alias, + "target", + Origin::new(SourceKind::new("git"), "hk.excludes"), + ) + .expect("should parse"); + assert_eq!(entry.prop, PropId(0)); + assert_eq!(entry.renamed_from, Some("excludes")); + } + + #[test] + fn a_bad_value_becomes_a_warning_that_names_where_it_came_from() { + // A CLI has to start even when a file it does not own has nonsense in it, and the + // warning has to say which file, because otherwise the user cannot find it. + let ctx = LayerCtx::new(REGISTRY); + let jobs = ctx.prop("jobs").expect("declared").id; + let origin = Origin::new(SourceKind::ENV, "HK_JOBS"); + let warning = ctx + .entry(jobs, "lots", origin.clone()) + .expect_err("should not be an entry"); + assert_eq!( + warning.message, + "jobs expected a positive integer but HK_JOBS has `lots`" + ); + assert_eq!(warning.origin, Some(origin)); + + // And under an old name, the message says the name that was written — a complaint about + // a key the user cannot find in their own file is no help at all. + static RENAMED: &[PropMeta] = &[ + PropMeta::new("jobs", Ty::Uint), + PropMeta { + renamed_to: Some("jobs"), + ..PropMeta::new("concurrency", Ty::Uint) + }, + ]; + const WITH_ALIAS: Registry = Registry::new(RENAMED); + let ctx = LayerCtx::new(WITH_ALIAS); + let warning = ctx + .entry( + PropId(1), + "lots", + Origin::new(SourceKind::ENV, "HK_CONCURRENCY"), + ) + .expect_err("should not be an entry"); + assert!( + warning.message.starts_with("concurrency expected"), + "{}", + warning.message + ); + } +} diff --git a/config/src/lib.rs b/config/src/lib.rs new file mode 100644 index 00000000..21c7033d --- /dev/null +++ b/config/src/lib.rs @@ -0,0 +1,84 @@ +//! 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. `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. +//! +//! # 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. +//! +//! # Example +//! +//! ``` +//! use usage_config::{resolve, Layers, Origin, PropMeta, Registry, SourceKind, Ty, Value}; +//! use usage_config::{Layer, LayerCtx, LayerError, LayerOutput}; +//! +//! // Normally generated from the spec by usage-config-build. +//! static PROPS: &[PropMeta] = &[PropMeta { +//! envs: &["MYCLI_JOBS"], +//! default: Some(usage_config::Const::Int(4)), +//! ..PropMeta::new("jobs", Ty::Uint) +//! }]; +//! const REGISTRY: Registry = Registry::new(PROPS); +//! +//! // A layer reads one kind of place. This one stands in for the environment. +//! struct Env; +//! impl Layer for Env { +//! fn source(&self) -> SourceKind { +//! SourceKind::ENV +//! } +//! fn load(&self, ctx: &LayerCtx) -> Result { +//! let mut out = LayerOutput::new(); +//! let id = ctx.prop("jobs").expect("declared").id; +//! match ctx.entry(id, "8", Origin::new(SourceKind::ENV, "MYCLI_JOBS")) { +//! Ok(entry) => out.push(entry), +//! Err(warning) => out.warn(warning), +//! } +//! Ok(out) +//! } +//! } +//! +//! let env = Env; +//! let resolved = resolve(REGISTRY, Layers::new().then(&env))?; +//! assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8))); +//! assert_eq!( +//! resolved.origin(REGISTRY.lookup("jobs").unwrap().id).unwrap().describe(), +//! "MYCLI_JOBS", +//! ); +//! # Ok::<(), LayerError>(()) +//! ``` + +pub mod layer; +pub mod registry; +pub mod resolve; +pub mod source; +pub mod ty; +pub mod value; + +pub use layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput, Warning}; +pub use registry::{Lookup, Merge, PropId, PropMeta, Registry, Scope}; +pub use resolve::{resolve, Layers, Resolved}; +pub use source::{FileScope, Origin, SourceKind, Trust}; +pub use ty::{Parser, Ty, TypeError}; +pub use value::{Const, Value}; diff --git a/config/src/registry.rs b/config/src/registry.rs new file mode 100644 index 00000000..129516c7 --- /dev/null +++ b/config/src/registry.rs @@ -0,0 +1,305 @@ +//! 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 +//! 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. +//! +//! Keys are the dotted paths the spec declares. Nesting is a *file's* concern, reconstructed +//! by whatever reads the file; here a key is one string. + +use crate::source::SourceKind; +use crate::ty::{Parser, Ty}; +use crate::value::Const; + +/// A setting's index in its registry. +/// +/// Interned so the merge is array indexing rather than string comparison. `u16` because a +/// registry of 65,000 settings is not a thing that exists — mise, the largest in the fleet, +/// has 280. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PropId(pub u16); + +impl PropId { + /// This id as a slice index. + pub fn index(self) -> usize { + self.0 as usize + } +} + +/// How the values for one setting combine when several layers supply them. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub enum Merge { + /// The highest-precedence value wins outright. + #[default] + Replace, + /// Every layer contributes; a list is the concatenation, lowest precedence first. + Union, + /// Tables merge key by key, the higher precedence winning each key. + Deep, +} + +/// Where a setting will accept a value from. +/// +/// Enforced by the merge rather than left to each layer, because mise calls this a security +/// property and a check every layer has to remember is not one. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub enum Scope { + /// Anywhere. + #[default] + Any, + /// Never from a file a repository can carry — only the user's own configuration, the + /// machine's, the environment, or the command line. + Global, + /// Never from a file at all. + Env, +} + +/// What one setting is. +/// +/// Every field is `const`-constructible, so a generated registry is a `static` with no +/// initializer to run. +#[derive(Debug, Copy, Clone)] +pub struct PropMeta { + /// The dotted key, which is also what a config file and `config set` call it. + pub key: &'static str, + pub ty: Ty, + /// The value when no layer supplies one. + pub default: Option, + pub merge: Merge, + pub scope: Scope, + /// How to split a single string into several values, when a layer hands over text. + pub parse: Option, + /// Environment variables that set it, highest precedence first. + pub envs: &'static [&'static str], + /// Its keys in sources usage does not know about: `[("git", "hk.jobs")]`. A custom layer + /// asks the registry for its own kind and iterates what it finds, which is the whole + /// mechanism behind hk's git and pkl layers and aube's `.npmrc`. + pub bindings: &'static [(&'static str, &'static str)], + /// Kept out of documentation and completions. Still settable. + pub hide: bool, + /// Why not to use this any more. + pub deprecated: Option<&'static str>, + /// The setting that replaces this one. A value found under the old key is folded into the + /// new one at the same precedence, with a warning. + pub renamed_to: Option<&'static str>, + pub help: Option<&'static str>, +} + +impl PropMeta { + /// A setting with nothing but a key and a type, for a generator or a test to build on. + pub const fn new(key: &'static str, ty: Ty) -> Self { + Self { + key, + ty, + default: None, + merge: Merge::Replace, + scope: Scope::Any, + parse: None, + envs: &[], + bindings: &[], + hide: false, + deprecated: None, + renamed_to: None, + help: None, + } + } +} + +/// Every setting a CLI has. +#[derive(Debug, Copy, Clone)] +pub struct Registry { + pub props: &'static [PropMeta], +} + +/// What looking a key up found. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Lookup { + pub id: PropId, + /// The key that was asked for, when it is not the key that was found — an old name still + /// in somebody's config file. Carried so a warning can name it. + pub renamed_from: Option<&'static str>, +} + +impl Registry { + pub const fn new(props: &'static [PropMeta]) -> Self { + Self { props } + } + + pub fn get(&self, id: PropId) -> &'static PropMeta { + &self.props[id.index()] + } + + /// The id of a dotted key, following a rename to the setting that replaced it. + /// + /// Linear, because a registry is small and a lookup happens once per key a layer + /// supplies — not once per key that exists. A binary search over a sorted table would be + /// a fine optimization and is not yet worth the invariant it demands of the generator. + pub fn lookup(&self, key: &str) -> Option { + let (index, meta) = self + .props + .iter() + .enumerate() + .find(|(_, meta)| meta.key == key)?; + let mut id = PropId(index as u16); + let written = meta.key; + // A chain of renames resolves to its end, so two releases of renaming do not leave the + // second one unreachable — walked rather than recursed, and bounded by the number of + // settings there are. A cycle, which is one mistyped field away in a registry somebody + // wrote by hand, overflowed the stack: an abort with no message rather than a lookup + // that fails. A chain longer than the registry is a cycle by definition. + for _ in 0..self.props.len() { + let Some(new_key) = self.props[id.index()].renamed_to else { + return Some(Lookup { + id, + renamed_from: (id.index() != index).then_some(written), + }); + }; + id = self.lookup_exact(new_key)?; + } + None + } + + /// The id of a dotted key, *without* following a rename. + /// + /// [`Registry::lookup`] answers "which setting does this key mean", which is what a reader + /// wants. This answers "which declaration is this key", which is what a warning wants: the + /// deprecation message lives on the old name's own declaration. + pub fn lookup_exact(&self, key: &str) -> Option { + self.props + .iter() + .position(|meta| meta.key == key) + .map(|index| PropId(index as u16)) + } + + /// The settings an environment variable sets, and the variable that set them. + /// + /// Several names per setting are aliases in descending precedence, which the env layer + /// honours by taking the first one that is present. + pub fn ids(&self) -> impl Iterator { + (0..self.props.len()).map(|i| PropId(i as u16)) + } + + /// Every setting bound to `kind`, with its key in that source. + /// + /// The generic mechanism a custom layer is written against: a git layer asks for `"git"` + /// and reads the keys it gets back, without usage knowing anything about git. + pub fn bindings( + &self, + kind: SourceKind, + ) -> impl Iterator + use<'_> { + self.ids().flat_map(move |id| { + self.get(id) + .bindings + .iter() + .filter(move |(k, _)| *k == kind.name()) + .map(move |(_, key)| (id, *key)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + static PROPS: &[PropMeta] = &[ + PropMeta { + key: "jobs", + envs: &["HK_JOBS", "HK_JOB"], + bindings: &[("git", "hk.jobs"), ("pkl", "jobs")], + ..PropMeta::new("jobs", Ty::Uint) + }, + PropMeta { + renamed_to: Some("jobs"), + deprecated: Some("Use jobs instead."), + ..PropMeta::new("concurrency", Ty::Uint) + }, + PropMeta { + // Two renames deep: this is what a second release of renaming looks like. + renamed_to: Some("concurrency"), + ..PropMeta::new("threads", Ty::Uint) + }, + PropMeta { + bindings: &[("git", "hk.check")], + ..PropMeta::new("check", Ty::Bool) + }, + ]; + const REGISTRY: Registry = Registry::new(PROPS); + + #[test] + fn a_key_resolves_to_its_own_index() { + let found = REGISTRY.lookup("jobs").expect("declared"); + assert_eq!(found.id, PropId(0)); + assert_eq!(found.renamed_from, None); + assert_eq!(REGISTRY.get(found.id).key, "jobs"); + assert_eq!(REGISTRY.lookup("nonesuch"), None); + } + + #[test] + fn an_old_name_resolves_to_the_setting_that_replaced_it() { + // What a config file written a year ago needs, and the reason a rename does not have + // to be a breaking change. + let found = REGISTRY.lookup("concurrency").expect("declared"); + assert_eq!(found.id, PropId(0), "should land on jobs"); + assert_eq!(found.renamed_from, Some("concurrency")); + + // Through two renames, reporting the name the user actually wrote rather than the + // intermediate one they have never heard of. + let chained = REGISTRY.lookup("threads").expect("declared"); + assert_eq!(chained.id, PropId(0)); + assert_eq!(chained.renamed_from, Some("threads")); + } + + #[test] + fn a_rename_cycle_fails_the_lookup_rather_than_the_process() { + // One mistyped field in a registry somebody wrote by hand. Recursing on `renamed_to` + // overflowed the stack, which is an abort with no message — a lookup that cannot answer + // should return `None` and let the caller warn about an unknown key. + static CYCLE: &[PropMeta] = &[ + PropMeta { + renamed_to: Some("b"), + ..PropMeta::new("a", Ty::Bool) + }, + PropMeta { + renamed_to: Some("a"), + ..PropMeta::new("b", Ty::Bool) + }, + // The simplest form: a setting renamed to itself. + PropMeta { + renamed_to: Some("self"), + ..PropMeta::new("self", Ty::Bool) + }, + ]; + const REGISTRY: Registry = Registry::new(CYCLE); + assert_eq!(REGISTRY.lookup("a"), None); + assert_eq!(REGISTRY.lookup("b"), None); + assert_eq!(REGISTRY.lookup("self"), None); + // And a chain that does end still resolves, so the bound is not simply refusing chains. + static CHAIN: &[PropMeta] = &[ + PropMeta::new("new", Ty::Bool), + PropMeta { + renamed_to: Some("new"), + ..PropMeta::new("middle", Ty::Bool) + }, + PropMeta { + renamed_to: Some("middle"), + ..PropMeta::new("old", Ty::Bool) + }, + ]; + const CHAINED: Registry = Registry::new(CHAIN); + let found = CHAINED.lookup("old").expect("declared"); + assert_eq!(found.id, PropId(0)); + assert_eq!(found.renamed_from, Some("old")); + } + + #[test] + fn a_custom_layer_finds_its_own_keys() { + // The whole interface a git or pkl or npmrc layer is written against. + let git: Vec<_> = REGISTRY.bindings(SourceKind::new("git")).collect(); + assert_eq!(git, vec![(PropId(0), "hk.jobs"), (PropId(3), "hk.check")]); + let pkl: Vec<_> = REGISTRY.bindings(SourceKind::new("pkl")).collect(); + assert_eq!(pkl, vec![(PropId(0), "jobs")]); + // A kind nothing is bound to yields nothing rather than everything. + assert_eq!(REGISTRY.bindings(SourceKind::new("npmrc")).count(), 0); + } +} diff --git a/config/src/resolve.rs b/config/src/resolve.rs new file mode 100644 index 00000000..4cbccf78 --- /dev/null +++ b/config/src/resolve.rs @@ -0,0 +1,1102 @@ +//! One merge, and the provenance is its output. +//! +//! Precedence is fixed and universal: the command line beats the environment, which beats +//! files, nearest first, which beat the user's own configuration, which beats the machine's, +//! which beat the declared defaults. *Which* layers a CLI has is its own business; their +//! relative order is not negotiable, because a fleet where two CLIs disagree about whether +//! `--jobs` beats `JOBS` is the thing this crate exists to end. +//! +//! Layers are given highest precedence first — the order they read in the builder and the +//! order `--help` describes them — and folded lowest first, so the last writer wins. + +use std::collections::BTreeMap; + +use crate::layer::{Layer, LayerCtx, LayerError, Warning}; +use crate::registry::{Merge, PropId, Registry, Scope}; +use crate::source::{Origin, SourceKind, Trust}; +use crate::value::Value; + +/// Everything a resolution produced. +#[derive(Debug, Clone)] +pub struct Resolved { + /// Indexed by [`PropId`]: the winning value, or `None` where nothing supplied one and no + /// default was declared. + values: Vec>, + /// Indexed by [`PropId`], alongside the values so the two cannot come apart. + provenance: Vec>, + /// Contributors, in the order they were merged, for a setting that took several. + contributors: BTreeMap>, + /// Everything a user should be told, in the order it was found. + pub warnings: Vec, + registry: Registry, +} + +impl Resolved { + /// The winning value for a setting. + pub fn get(&self, id: PropId) -> Option<&Value> { + self.values.get(id.index()).and_then(Option::as_ref) + } + + /// The winning value for a dotted key, following renames. + pub fn get_key(&self, key: &str) -> Option<&Value> { + self.get(self.registry.lookup(key)?.id) + } + + /// Where the winning value came from. + pub fn origin(&self, id: PropId) -> Option<&Origin> { + self.provenance.get(id.index()).and_then(Option::as_ref) + } + + /// Every place that contributed to this setting, in merge order. + /// + /// One entry for a `replace` setting, several for a `union` or `deep` one — which is + /// what makes per-item provenance possible for a list assembled from four files. + pub fn contributors(&self, id: PropId) -> &[Origin] { + self.contributors + .get(&id) + .map(Vec::as_slice) + .unwrap_or_default() + } + + pub fn registry(&self) -> Registry { + self.registry + } + + /// Record that the CLI rewrote a value after merging. + /// + /// The typed post-merge hook is where a CLI's own rules live — mise's `raw` implying + /// `jobs = 1`, its `ci` implying `yes`. Going through here rather than assigning to the + /// struct keeps `explain` honest: the origin becomes [`SourceKind::COERCED`] with the + /// reason, instead of continuing to name a file that never said it. + pub fn coerced(&mut self, id: PropId, value: Value, why: impl Into) { + let index = id.index(); + if index >= self.values.len() { + return; + } + let origin = Origin::new(SourceKind::COERCED, why); + self.values[index] = Some(value); + self.provenance[index] = Some(origin.clone()); + // On the contributor list too, or `origin()` would name the rewrite while + // `contributors().last()` still named whatever the rewrite replaced — the same split + // between the two that the merge itself is written to avoid. + self.contributors.entry(id).or_default().push(origin); + } +} + +/// The layers to resolve, highest precedence first. +/// +/// Ordered by the caller because only the caller knows which layers it has; the order they +/// are added in is the order `--help` and the docs describe, so a builder that read +/// bottom-up would invite exactly the kind of quiet disagreement this replaces. +#[derive(Default)] +pub struct Layers<'a> { + layers: Vec<&'a dyn Layer>, +} + +impl<'a> Layers<'a> { + pub fn new() -> Self { + Self::default() + } + + /// Add a layer below every layer added so far. + pub fn then(mut self, layer: &'a dyn Layer) -> Self { + self.layers.push(layer); + self + } + + pub fn len(&self) -> usize { + self.layers.len() + } + + pub fn is_empty(&self) -> bool { + self.layers.is_empty() + } +} + +/// Resolve every setting in `registry` from `layers`. +/// +/// Declared defaults are the bottom layer always, and are not a [`Layer`]: they cost one +/// `const` conversion per setting that needs one and cannot fail, so making them an +/// implementation would be ceremony that could also be forgotten. +pub fn resolve(registry: Registry, layers: Layers<'_>) -> Result { + let ctx = LayerCtx::new(registry); + let count = registry.props.len(); + let mut resolved = Resolved { + values: vec![None; count], + provenance: vec![None; count], + contributors: BTreeMap::new(), + warnings: Vec::new(), + registry, + }; + + // Declared defaults are the bottom layer, seeded before anything else rather than applied + // afterwards as a floor. As a floor they could not take part in a merge at all: a `union` + // list with a declared default and any layer at all lost the default's items, because the + // floor only filled in what nothing had set. Being the lowest contributor is also what a + // default *is*, so `explain` now says so. + for id in registry.ids() { + // An old name is an alias, not a setting: seeding its default under its own id put the + // value somewhere no reader looks, since every lookup folds to the replacement. The + // setting that replaced it declares its own default. + if registry.get(id).renamed_to.is_some() { + continue; + } + if let Some(default) = registry.get(id).default { + let index = id.index(); + resolved.values[index] = Some(default.to_value()); + resolved.provenance[index] = Some(Origin::declared_default()); + resolved + .contributors + .entry(id) + .or_default() + .push(Origin::declared_default()); + } + } + + // Lowest precedence first, so a higher layer overwrites what a lower one put there. + // Loaded in this order too, which means a layer's warnings arrive in the order a reader + // would look for them. + let mut outputs = Vec::with_capacity(layers.len()); + for layer in layers.layers.iter().rev() { + outputs.push(layer.load(&ctx)?); + } + + for output in outputs { + resolved.warnings.extend(output.warnings); + for entry in output.entries { + let written = registry.get(entry.prop); + // Follow a rename here, not only in `LayerCtx::prop`: a layer that took its ids + // from `Registry::bindings` or `ids` supplies the old prop's own id, and storing + // the value there left it somewhere `get_key` — which follows the rename — would + // never look, so the value was silently dropped. + let (prop, meta) = match written + .renamed_to + .and_then(|new_key| registry.lookup(new_key)) + { + Some(target) => (target.id, registry.get(target.id)), + None => (entry.prop, written), + }; + // Whichever way the old name arrived: on the entry, because the layer looked the + // key up and `LayerCtx` folded it, or as a raw id this loop folded just now. + // Keyed on the fold alone, a file layer's deprecated key was folded in silence. + let written_key = entry.renamed_from.unwrap_or(written.key); + let as_written = registry + .lookup_exact(written_key) + .map(|id| registry.get(id)) + .unwrap_or(written); + if let Some(refusal) = refuse(meta.scope, &entry.origin) { + // `written_key`, like the two warnings below it: after `LayerCtx` folds a + // rename, `written.key` is the *replacement's* name, so a refused value was + // reported under a key that does not appear in the file the user would go and + // edit. + resolved.warnings.push(Warning::at( + format!("{written_key} {refusal}"), + entry.origin, + )); + continue; + } + if let Some(why) = as_written.deprecated { + resolved.warnings.push(Warning::at( + format!("{written_key} is deprecated: {why}"), + entry.origin.clone(), + )); + } + if written_key != meta.key { + // Both names: the key the user wrote, and the one it was read as. + resolved.warnings.push(Warning::at( + format!("{written_key} was read as {}", meta.key), + entry.origin.clone(), + )); + } + let index = prop.index(); + let merged = match meta.merge { + Merge::Replace => entry.value, + // Through `union` even for the first contribution, so a set's deduplication + // applies to one layer's list as well as across two — a single `TAGS=a,b,a` + // kept its repeat, because dedup lived only on the merge-two path. + Merge::Union => union( + resolved.values[index] + .take() + .unwrap_or(Value::List(Vec::new())), + entry.value, + meta.ty, + ), + Merge::Deep => match resolved.values[index].take() { + Some(existing) => deep(existing, entry.value), + None => entry.value, + }, + }; + resolved.values[index] = Some(merged); + // The winner is whatever came last, which after the reverse above is the + // highest-precedence contributor. + resolved.provenance[index] = Some(entry.origin.clone()); + // Keyed by the folded id, like the value and the winning origin beside it. Keyed + // by the id the layer supplied, a renamed setting's contributors ended up on a + // prop nothing reads while its value and origin were on another — the provenance + // split this crate exists to make unreachable, reintroduced by two lines. + resolved + .contributors + .entry(prop) + .or_default() + .push(entry.origin); + } + } + + Ok(resolved) +} + +/// Why this scope will not take a value from this origin, if it will not. +/// +/// Enforced here rather than in each layer: mise calls this a security property, and a check +/// that every layer has to remember to make is one a new layer will forget. +fn refuse(scope: Scope, origin: &Origin) -> Option<&'static str> { + match scope { + Scope::Any => None, + // Anything a repository can carry, whatever kind of place it is. Asking whether the + // origin was a *file* let a pkl file, a git config or an `.npmrc` in the checkout walk + // past a check the spec calls a security property. + // Not "config file": since the check became one about trust, this refuses a git + // config, a pkl file or an `.npmrc` in the checkout too, and telling that user their + // *config file* is at fault points them at a file that never held the value. The + // warning carries the origin, so whoever renders it can name the place exactly. + Scope::Global if origin.trust < Trust::Operator => { + Some("cannot be set by anything a project can carry") + } + Scope::Env if origin.trust < Trust::Invocation => { + Some("can only be set in the environment or on the command line") + } + _ => None, + } +} + +/// Lower-precedence values first, higher appended, repeats dropped for a set. +fn union(existing: Value, incoming: Value, ty: crate::ty::Ty) -> Value { + // An explicit empty list means "none", and is how a user turns a declared default off: + // `HK_EXCLUDE=` parses to an empty list for exactly that reason. Concatenating with it + // left every default item in place, so a `union` setting with a default could not be + // cleared at all. + if matches!(&incoming, Value::List(items) if items.is_empty()) { + return Value::List(Vec::new()); + } + let mut items = match existing { + Value::List(items) => items, + single => vec![single], + }; + match incoming { + Value::List(more) => items.extend(more), + single => items.push(single), + } + if matches!(ty.inner(), crate::ty::Ty::Set(_)) { + // First occurrence keeps its position, so the order of a set is the order it was + // first mentioned rather than something that shifts when a lower layer changes. + let mut seen: Vec = Vec::with_capacity(items.len()); + items.retain(|item| { + let fresh = !seen.contains(item); + if fresh { + seen.push(item.clone()); + } + fresh + }); + } + Value::List(items) +} + +/// Tables merged key by key, the incoming (higher-precedence) side winning each key. +fn deep(existing: Value, incoming: Value) -> Value { + match (existing, incoming) { + (Value::Map(mut base), Value::Map(overlay)) => { + for (key, value) in overlay { + let merged = match base.remove(&key) { + // Nested tables merge too, so a `deep` setting is deep all the way down + // rather than only at the top. + Some(existing @ Value::Map(_)) => deep(existing, value), + _ => value, + }; + base.insert(key, merged); + } + Value::Map(base) + } + // A `deep` setting given something that is not a table on either side has nothing to + // merge; the higher-precedence value stands, as `replace` would have it. + (_, incoming) => incoming, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::{Entry, LayerOutput}; + use crate::registry::PropMeta; + use crate::source::{FileScope, Trust}; + use crate::ty::Ty; + use crate::value::Const; + + static PROPS: &[PropMeta] = &[ + PropMeta { + default: Some(Const::Int(4)), + ..PropMeta::new("jobs", Ty::Uint) + }, + PropMeta { + merge: Merge::Union, + ..PropMeta::new("exclude", Ty::List(&Ty::String)) + }, + PropMeta { + merge: Merge::Union, + ..PropMeta::new("tags", Ty::Set(&Ty::String)) + }, + PropMeta { + merge: Merge::Union, + default: Some(Const::List(&[Const::Str("target")])), + ..PropMeta::new("excluded", Ty::List(&Ty::String)) + }, + PropMeta { + merge: Merge::Deep, + ..PropMeta::new("urls", Ty::Map(&Ty::String)) + }, + PropMeta { + scope: Scope::Global, + ..PropMeta::new("trusted", Ty::Bool) + }, + // An old name for a scope-restricted setting, which is how a refusal comes to be + // reported under a key the user never wrote. + PropMeta { + renamed_to: Some("trusted"), + ..PropMeta::new("old_trusted", Ty::Bool) + }, + PropMeta { + scope: Scope::Env, + ..PropMeta::new("config_file", Ty::Path) + }, + PropMeta { + deprecated: Some("Use jobs instead."), + ..PropMeta::new("old_jobs", Ty::Uint) + }, + // Deprecated *and* replaced, which is the pair a rename actually comes as. + PropMeta { + deprecated: Some("Use jobs instead."), + renamed_to: Some("jobs"), + // A default on an alias, which is a thing a registry ends up with after a rename + // and which must not be seeded anywhere. + default: Some(Const::Int(7)), + ..PropMeta::new("renamed_jobs", Ty::Uint) + }, + PropMeta::new("undeclared_default", Ty::String), + ]; + const REGISTRY: Registry = Registry::new(PROPS); + + /// A layer holding whatever a test hands it. + struct Fixed { + kind: SourceKind, + entries: Vec, + } + + impl Layer for Fixed { + fn source(&self) -> SourceKind { + self.kind + } + + fn load(&self, _ctx: &LayerCtx) -> Result { + Ok(LayerOutput { + entries: self.entries.clone(), + warnings: Vec::new(), + }) + } + } + + fn id(key: &str) -> PropId { + REGISTRY.lookup(key).expect("declared").id + } + + /// The id of a key *without* following its rename. + /// + /// What `Registry::bindings` and `Registry::ids` hand a layer — `lookup` folds renames, so + /// a test that went through it could not reproduce the case at all. + fn raw_id(key: &str) -> PropId { + let index = PROPS + .iter() + .position(|meta| meta.key == key) + .expect("declared"); + PropId(index as u16) + } + + fn layer(kind: SourceKind, entries: Vec<(&str, Value, Origin)>) -> Fixed { + Fixed { + kind, + entries: entries + .into_iter() + .map(|(key, value, origin)| Entry::new(id(key), value, origin)) + .collect(), + } + } + + #[test] + fn the_highest_layer_wins_and_says_so() { + let cli = layer( + SourceKind::CLI, + vec![( + "jobs", + Value::Int(1), + Origin::new(SourceKind::CLI, "--jobs"), + )], + ); + let env = layer( + SourceKind::ENV, + vec![( + "jobs", + Value::Int(2), + Origin::new(SourceKind::ENV, "HK_JOBS"), + )], + ); + let file = layer( + SourceKind::FILE, + vec![( + "jobs", + Value::Int(3), + Origin::file("hk.toml", FileScope::Project), + )], + ); + + let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&file)) + .expect("should resolve"); + + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1))); + // The identifier, not just the kind: "from the environment" is not something a user + // can act on and `--jobs` is. + assert_eq!(resolved.origin(id("jobs")).unwrap().describe(), "--jobs"); + // Every contributor is kept, lowest precedence first, even for a `replace` setting — + // and the declared default is the lowest of all, because that is what a default is. + let contributors: Vec<_> = resolved + .contributors(id("jobs")) + .iter() + .map(|o| o.describe().to_string()) + .collect(); + assert_eq!( + contributors, + ["the default", "hk.toml", "HK_JOBS", "--jobs"] + ); + } + + #[test] + fn a_declared_default_is_the_floor_and_is_marked_as_one() { + let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve"); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4))); + assert_eq!( + resolved.origin(id("jobs")).unwrap().kind, + SourceKind::DEFAULTS + ); + // A setting with no default and no value is absent rather than guessed at, which is + // what makes `option` expressible. + assert_eq!(resolved.get_key("undeclared_default"), None); + assert_eq!(resolved.origin(id("undeclared_default")), None); + } + + #[test] + fn a_union_setting_takes_from_every_layer_lowest_first() { + let env = layer( + SourceKind::ENV, + vec![( + "exclude", + Value::List(vec![Value::from("target")]), + Origin::new(SourceKind::ENV, "HK_EXCLUDE"), + )], + ); + let file = layer( + SourceKind::FILE, + vec![( + "exclude", + Value::List(vec![Value::from("vendor")]), + Origin::file("hk.toml", FileScope::Project), + )], + ); + let resolved = + resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve"); + assert_eq!( + resolved.get_key("exclude"), + Some(&Value::List(vec![ + Value::from("vendor"), + Value::from("target") + ])), + "lower precedence first, so the most specific reads last" + ); + // Both places are recorded, which is what per-item provenance is built on. + assert_eq!(resolved.contributors(id("exclude")).len(), 2); + } + + #[test] + fn a_set_keeps_the_first_of_each() { + let a = layer( + SourceKind::ENV, + vec![( + "tags", + Value::List(vec![Value::from("x"), Value::from("y")]), + Origin::new(SourceKind::ENV, "TAGS"), + )], + ); + let b = layer( + SourceKind::FILE, + vec![( + "tags", + Value::List(vec![Value::from("y"), Value::from("z")]), + Origin::file("hk.toml", FileScope::Project), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&a).then(&b)).expect("should resolve"); + assert_eq!( + resolved.get_key("tags"), + Some(&Value::List(vec![ + Value::from("y"), + Value::from("z"), + Value::from("x") + ])), + "y was first mentioned by the file, so it stays where it was" + ); + } + + #[test] + fn a_deep_setting_merges_tables_key_by_key() { + let map = |pairs: &[(&str, &str)]| { + Value::Map( + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), Value::from(*v))) + .collect(), + ) + }; + let env = layer( + SourceKind::ENV, + vec![( + "urls", + map(&[("a", "from-env")]), + Origin::new(SourceKind::ENV, "URLS"), + )], + ); + let file = layer( + SourceKind::FILE, + vec![( + "urls", + map(&[("a", "from-file"), ("b", "only-in-file")]), + Origin::file("hk.toml", FileScope::Project), + )], + ); + let resolved = + resolve(REGISTRY, Layers::new().then(&env).then(&file)).expect("should resolve"); + assert_eq!( + resolved.get_key("urls"), + Some(&map(&[("a", "from-env"), ("b", "only-in-file")])), + "the higher layer wins its own key without dropping the other's" + ); + } + + #[test] + fn a_scope_refuses_what_it_says_it_refuses() { + // The security property: a repository can carry a project file, so a setting that + // must not be changeable by a checkout says so and the merge enforces it — not each + // layer, which is how a new layer forgets. + let project = layer( + SourceKind::FILE, + vec![ + ( + "trusted", + Value::Bool(true), + Origin::file("hk.toml", FileScope::Project), + ), + ( + "config_file", + Value::from("/tmp/x"), + Origin::file("hk.toml", FileScope::Project), + ), + ], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve"); + assert_eq!(resolved.get_key("trusted"), None); + assert_eq!(resolved.get_key("config_file"), None); + // Refused out loud: silently ignoring what somebody wrote is how they conclude the + // setting does not work. + let messages: Vec<_> = resolved + .warnings + .iter() + .map(|w| w.message.clone()) + .collect(); + assert_eq!( + messages, + [ + "trusted cannot be set by anything a project can carry", + "config_file can only be set in the environment or on the command line", + ] + ); + + // The same settings from the places they *do* accept. + let global = layer( + SourceKind::FILE, + vec![( + "trusted", + Value::Bool(true), + Origin::file("~/.config/hk.toml", FileScope::Global), + )], + ); + let env = layer( + SourceKind::ENV, + vec![( + "config_file", + Value::from("/tmp/x"), + Origin::new(SourceKind::ENV, "HK_CONFIG_FILE"), + )], + ); + let resolved = + resolve(REGISTRY, Layers::new().then(&env).then(&global)).expect("should resolve"); + assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true))); + assert_eq!( + resolved.get_key("config_file"), + Some(&Value::from("/tmp/x")) + ); + assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings); + } + + #[test] + fn using_a_deprecated_setting_says_so_once_per_place_it_was_set() { + let file = layer( + SourceKind::FILE, + vec![( + "old_jobs", + Value::Int(2), + Origin::file("hk.toml", FileScope::Project), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve"); + // Still honoured — a warning is not a refusal. + assert_eq!(resolved.get_key("old_jobs"), Some(&Value::Int(2))); + assert_eq!( + resolved.warnings[0].message, + "old_jobs is deprecated: Use jobs instead." + ); + assert_eq!( + resolved.warnings[0].origin.as_ref().unwrap().describe(), + "hk.toml" + ); + } + + #[test] + fn a_value_the_cli_rewrote_says_it_was_rewritten() { + // mise's `raw` implying `jobs = 1`. Recording this as coming from wherever the + // original value came from is how a user ends up editing a file that has nothing to + // do with what they are seeing. + let env = layer( + SourceKind::ENV, + vec![( + "jobs", + Value::Int(8), + Origin::new(SourceKind::ENV, "HK_JOBS"), + )], + ); + let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve"); + resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job"); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1))); + let origin = resolved.origin(id("jobs")).unwrap(); + assert_eq!(origin.kind, SourceKind::COERCED); + assert_eq!(origin.describe(), "raw implies one job"); + } + + #[test] + fn a_refusal_names_the_key_that_was_written() { + // The deprecation and rename warnings already said the name the user wrote; the refusal + // still said the folded one, so a refused value was reported under a key that does not + // appear anywhere in the file they would go and edit. + struct FileLike; + impl Layer for FileLike { + fn source(&self) -> SourceKind { + SourceKind::FILE + } + fn load(&self, ctx: &LayerCtx) -> Result { + let mut out = LayerOutput::new(); + let origin = Origin::file("hk.toml", FileScope::Project); + match ctx.entry_for_key("old_trusted", "true", origin) { + Ok(entry) => out.push(entry), + Err(warning) => out.warn(warning), + } + Ok(out) + } + } + let file = FileLike; + let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve"); + assert_eq!(resolved.get_key("trusted"), None); + let messages: Vec<_> = resolved + .warnings + .iter() + .map(|w| w.message.clone()) + .collect(); + assert!( + messages + .iter() + .any(|m| m.starts_with("old_trusted cannot be set")), + "the refusal should name the key in the file: {messages:?}" + ); + } + + #[test] + fn a_refused_custom_source_is_not_called_a_config_file() { + // The check is about trust now, so it refuses a pkl file or a git config in the checkout + // too — and telling that user their *config file* is at fault points them at a file that + // never held the value. + let pkl = layer( + SourceKind::new("pkl"), + vec![( + "trusted", + Value::Bool(true), + Origin::new(SourceKind::new("pkl"), "hk.pkl"), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve"); + assert_eq!( + resolved.warnings[0].message, + "trusted cannot be set by anything a project can carry" + ); + // And the origin travels with it, so a renderer can name the place exactly. + assert_eq!( + resolved.warnings[0].origin.as_ref().unwrap().describe(), + "hk.pkl" + ); + } + + #[test] + fn a_custom_source_is_held_to_the_same_scope_as_a_file() { + // The hole: `refuse` asked whether the origin was a *file*, and every custom source is + // built with `Origin::new`, so a pkl file or a git config in the checkout could set a + // setting the spec says a project must not touch. A pkl file in a repository is as much + // a thing a checkout carries as `hk.toml` is. + let pkl = layer( + SourceKind::new("pkl"), + vec![ + ( + "trusted", + Value::Bool(true), + Origin::new(SourceKind::new("pkl"), "hk.pkl"), + ), + ( + "config_file", + Value::from("/tmp/x"), + Origin::new(SourceKind::new("pkl"), "hk.pkl"), + ), + ], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&pkl)).expect("should resolve"); + assert_eq!(resolved.get_key("trusted"), None); + assert_eq!(resolved.get_key("config_file"), None); + assert_eq!(resolved.warnings.len(), 2, "{:?}", resolved.warnings); + + // And a layer that knows it read from the user's own configuration says so, at which + // point a `global` setting will take it — while an `env` one still will not. + let global_pkl = layer( + SourceKind::new("pkl"), + vec![ + ( + "trusted", + Value::Bool(true), + Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl") + .trusted_as(Trust::Operator), + ), + ( + "config_file", + Value::from("/tmp/x"), + Origin::new(SourceKind::new("pkl"), "~/.config/hk.pkl") + .trusted_as(Trust::Operator), + ), + ], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&global_pkl)).expect("should resolve"); + assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true))); + assert_eq!(resolved.get_key("config_file"), None); + } + + #[test] + fn a_value_written_under_an_old_key_lands_on_the_new_one() { + // `LayerCtx::prop` follows a rename, but a layer that took its ids from + // `Registry::bindings` or `ids` hands over the *old* prop's id — and storing the value + // there put it somewhere `get_key`, which follows the rename, would never look. The + // value was honoured nowhere and reported nowhere. + let git = Fixed { + kind: SourceKind::new("git"), + entries: vec![Entry::new( + raw_id("renamed_jobs"), + Value::Int(3), + Origin::new(SourceKind::new("git"), "hk.renamedJobs"), + )], + }; + let resolved = resolve(REGISTRY, Layers::new().then(&git)).expect("should resolve"); + assert_eq!( + resolved.get_key("jobs"), + Some(&Value::Int(3)), + "the old key's value should land on the setting that replaced it" + ); + // Said out loud, in both names: the one written and the one it was read as. + let messages: Vec<_> = resolved + .warnings + .iter() + .map(|w| w.message.clone()) + .collect(); + assert!( + messages.contains(&"renamed_jobs was read as jobs".to_string()), + "{messages:?}" + ); + assert!( + messages.iter().any(|m| m.contains("is deprecated")), + "{messages:?}" + ); + } + + #[test] + fn a_set_drops_a_repeat_from_one_source_too() { + // Deduplication lived on the merge-two path, so a single `TAGS=a,b,a` kept its repeat — + // a set that is only a set once two layers disagree is not a set. + let one = layer( + SourceKind::ENV, + vec![( + "tags", + Value::List(vec![Value::from("a"), Value::from("b"), Value::from("a")]), + Origin::new(SourceKind::ENV, "TAGS"), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&one)).expect("should resolve"); + assert_eq!( + resolved.get_key("tags"), + Some(&Value::List(vec![Value::from("a"), Value::from("b")])) + ); + } + + #[test] + fn a_collection_default_takes_part_in_the_merge() { + // As a floor rather than a layer, a default only applied where nothing had been set — + // so a `union` list with a declared default lost every one of the default's items the + // moment any layer supplied anything at all. + let env = layer( + SourceKind::ENV, + vec![( + "excluded", + Value::List(vec![Value::from("from-env")]), + Origin::new(SourceKind::ENV, "EXCLUDED"), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve"); + assert_eq!( + resolved.get_key("excluded"), + Some(&Value::List(vec![ + Value::from("target"), + Value::from("from-env") + ])), + "the default's items are the lowest-precedence contribution, not a fallback" + ); + // And the default is recorded as the contributor it is. + assert_eq!( + resolved.contributors(id("excluded"))[0].describe(), + "the default" + ); + } + + #[test] + fn the_winning_origin_is_always_the_last_contributor() { + // The invariant behind `explain`, asserted as an invariant rather than field by field. + // A rename put the value and the winning origin on the folded prop and its contributors + // on the one the layer named, and every per-field assertion I had still passed. + let cli = layer( + SourceKind::CLI, + vec![( + "jobs", + Value::Int(1), + Origin::new(SourceKind::CLI, "--jobs"), + )], + ); + let env = layer( + SourceKind::ENV, + vec![ + ( + "jobs", + Value::Int(2), + Origin::new(SourceKind::ENV, "HK_JOBS"), + ), + ( + "excluded", + Value::List(vec![Value::from("from-env")]), + Origin::new(SourceKind::ENV, "EXCLUDED"), + ), + ( + "tags", + Value::List(vec![Value::from("a"), Value::from("a")]), + Origin::new(SourceKind::ENV, "TAGS"), + ), + ], + ); + let renamed = Fixed { + kind: SourceKind::new("git"), + entries: vec![Entry::new( + raw_id("renamed_jobs"), + Value::Int(9), + Origin::new(SourceKind::new("git"), "hk.renamedJobs"), + )], + }; + let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env).then(&renamed)) + .expect("should resolve"); + + for id in REGISTRY.ids() { + let key = REGISTRY.get(id).key; + match (resolved.origin(id), resolved.contributors(id).last()) { + (Some(winner), Some(last)) => assert_eq!( + winner, last, + "{key}: the winning origin is not the last contributor" + ), + (None, None) => {} + (winner, last) => panic!("{key}: origin {winner:?} but contributors end {last:?}"), + } + } + // And specifically for the renamed one, whose contributors used to live elsewhere. + let contributors: Vec<_> = resolved + .contributors(id("jobs")) + .iter() + .map(|o| o.describe().to_string()) + .collect(); + assert!( + contributors.contains(&"hk.renamedJobs".to_string()), + "a value read through a rename contributed and should say so: {contributors:?}" + ); + } + + #[test] + fn a_deprecated_key_is_reported_however_the_layer_found_it() { + // A layer reading a file looks keys up, and `LayerCtx` folds a rename on the way — so + // the entry arrives already carrying the *new* id and the resolver could not tell that + // anybody had written the old name. The deprecated key in somebody's config file was + // honoured in complete silence. + struct FileLike; + impl Layer for FileLike { + fn source(&self) -> SourceKind { + SourceKind::FILE + } + fn load(&self, ctx: &LayerCtx) -> Result { + let mut out = LayerOutput::new(); + let origin = Origin::file("hk.toml", FileScope::Project); + match ctx.entry_for_key("renamed_jobs", "5", origin) { + Ok(entry) => out.push(entry), + Err(warning) => out.warn(warning), + } + Ok(out) + } + } + let file = FileLike; + let resolved = resolve(REGISTRY, Layers::new().then(&file)).expect("should resolve"); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(5))); + let messages: Vec<_> = resolved + .warnings + .iter() + .map(|w| w.message.clone()) + .collect(); + assert!( + messages.contains(&"renamed_jobs is deprecated: Use jobs instead.".to_string()), + "{messages:?}" + ); + assert!( + messages.contains(&"renamed_jobs was read as jobs".to_string()), + "{messages:?}" + ); + } + + #[test] + fn an_unknown_key_is_a_warning_rather_than_a_failure() { + // Newer config read by an older binary: the key it does not know is reported and the + // rest of the file still applies. + struct Stray; + impl Layer for Stray { + fn source(&self) -> SourceKind { + SourceKind::FILE + } + fn load(&self, ctx: &LayerCtx) -> Result { + let mut out = LayerOutput::new(); + let origin = Origin::file("hk.toml", FileScope::Project); + match ctx.entry_for_key("from_the_future", "1", origin) { + Ok(entry) => out.push(entry), + Err(warning) => out.warn(warning), + } + Ok(out) + } + } + let stray = Stray; + let resolved = resolve(REGISTRY, Layers::new().then(&stray)).expect("should resolve"); + assert_eq!( + resolved.warnings[0].message, + "unknown setting `from_the_future`" + ); + } + + #[test] + fn an_alias_does_not_carry_a_default_of_its_own() { + // Seeded under its own id, a renamed prop's default landed where no reader looks: every + // lookup folds to the replacement. The setting that replaced it declares its own. + let resolved = resolve(REGISTRY, Layers::new()).expect("should resolve"); + assert_eq!( + resolved.get(raw_id("renamed_jobs")), + None, + "an alias should hold nothing at all" + ); + assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4))); + } + + #[test] + fn an_explicit_empty_list_clears_a_union_default() { + // How a user turns a declared default off. `HK_EXCLUDE=` parses to an empty list for + // exactly this reason, and with defaults now merging rather than filling in, an empty + // list that concatenated left every default item in place. + let env = layer( + SourceKind::ENV, + vec![( + "excluded", + Value::List(Vec::new()), + Origin::new(SourceKind::ENV, "EXCLUDED"), + )], + ); + let resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve"); + assert_eq!(resolved.get_key("excluded"), Some(&Value::List(Vec::new()))); + } + + #[test] + fn a_rewrite_stays_the_last_contributor() { + // The invariant `explain` rests on has to survive the post-merge hook too: rewriting + // the value and the winning origin without touching the contributor list left + // `origin()` naming the rewrite and `contributors().last()` naming what it replaced. + let env = layer( + SourceKind::ENV, + vec![( + "jobs", + Value::Int(8), + Origin::new(SourceKind::ENV, "HK_JOBS"), + )], + ); + let mut resolved = resolve(REGISTRY, Layers::new().then(&env)).expect("should resolve"); + resolved.coerced(id("jobs"), Value::Int(1), "raw implies one job"); + assert_eq!( + resolved.origin(id("jobs")), + resolved.contributors(id("jobs")).last() + ); + } + + #[test] + fn a_layer_that_cannot_read_its_source_stops_the_resolution() { + // Unlike an unknown key, which degrades to a warning: a file that exists and cannot + // be parsed means the values a user believes are in effect are not, and carrying on + // as though they had never written it is worse than saying so. + struct Broken; + impl Layer for Broken { + fn source(&self) -> SourceKind { + SourceKind::FILE + } + fn load(&self, _ctx: &LayerCtx) -> Result { + Err(LayerError::Unreadable { + source: "hk.toml".to_string(), + why: "expected a value at line 3".to_string(), + }) + } + } + let broken = Broken; + let err = resolve(REGISTRY, Layers::new().then(&broken)).expect_err("should fail"); + assert_eq!( + err.to_string(), + "could not read hk.toml: expected a value at line 3" + ); + } +} diff --git a/config/src/source.rs b/config/src/source.rs new file mode 100644 index 00000000..58b1ce79 --- /dev/null +++ b/config/src/source.rs @@ -0,0 +1,195 @@ +//! Where a value came from. +//! +//! Provenance is not an extra pass here: it is the output of the only merge there is. hk grew +//! a second parallel merge function purely to answer "where did this come from", and the two +//! could disagree — so `hk config explain` could describe a resolution that never happened. +//! Recording the origin as the value is chosen makes that class of bug unreachable. + +/// A kind of place a value can come from. +/// +/// Deliberately open: usage knows about the command line, the environment, files and declared +/// defaults, and every CLI in the fleet has at least one kind it reads itself — a git config, +/// a pkl file, an `.npmrc`. Those declare a `source` in the spec and pass their own kind here. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct SourceKind(&'static str); + +impl SourceKind { + /// The command line. + pub const CLI: Self = Self("cli"); + /// The environment. + pub const ENV: Self = Self("env"); + /// A configuration file usage read itself. + pub const FILE: Self = Self("file"); + /// The default the spec declares. + pub const DEFAULTS: Self = Self("defaults"); + /// A value the CLI rewrote after merging — mise's `raw` implying `jobs = 1`. + /// + /// Its own kind so `explain` never claims a file said something it did not. A rewrite + /// that looked like it came from wherever the original value did is how a user ends up + /// editing a file that has nothing to do with the value they are seeing. + pub const COERCED: Self = Self("coerced"); + + pub const fn new(name: &'static str) -> Self { + Self(name) + } + + pub const fn name(self) -> &'static str { + self.0 + } +} + +/// Which class of file a value came from, when it came from one. +/// +/// Mirrors `scope=` on a spec's `file` node, and decides the origin's [`Trust`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum FileScope { + /// Somewhere a repository can carry — the least trusted. + Project, + /// The user's own configuration. + Global, + /// Installed by whoever administers the machine. + System, +} + +/// How much a place is trusted, which is what a setting's scope is about. +/// +/// The distinction is not "was it a file": a pkl file, a git config or an `.npmrc` inside a +/// repository is every bit as much a thing a checkout can carry as `hk.toml` is. Asking about +/// files let every custom source — the natural use of [`Origin::new`] — walk straight past a +/// check the spec calls a security property. +/// +/// So the question is trust, every origin carries an answer, and the default for a kind usage +/// does not recognize is the *least* trusting one. A layer that knows better says so with +/// [`Origin::trusted_as`]; a layer that says nothing cannot accidentally be believed. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum Trust { + /// Somewhere a repository can carry — a project file, a git config in the checkout. + Project, + /// The user's own configuration, or the machine's. + Operator, + /// This invocation itself: the command line, the environment, a declared default. + Invocation, +} + +/// The exact place a value came from. +/// +/// Not just the kind: the *identifier*, because "from the environment" is not an answer a +/// user can act on and `HK_JOBS` is. This is what makes `config explain` worth having. +#[derive(Debug, Clone, PartialEq)] +pub struct Origin { + pub kind: SourceKind, + /// The environment variable's name, the file's path, the git key — whatever a user would + /// have to go and edit. + pub identifier: String, + /// How much this place is trusted, which is what the scope check reads. + pub trust: Trust, +} + +impl Origin { + /// An origin of the given kind. + /// + /// The trust follows the kind: this invocation for the command line, the environment and + /// the built-ins, and [`Trust::Project`] for anything else — because a kind usage does not + /// recognize is one it cannot vouch for, and a check that has to be remembered by each new + /// layer is one a new layer will forget. Say otherwise with [`Origin::trusted_as`]. + pub fn new(kind: SourceKind, identifier: impl Into) -> Self { + let trust = match kind { + SourceKind::CLI | SourceKind::ENV | SourceKind::DEFAULTS | SourceKind::COERCED => { + Trust::Invocation + } + _ => Trust::Project, + }; + Self { + kind, + identifier: identifier.into(), + trust, + } + } + + /// The same origin, trusted as stated. + /// + /// For a custom layer that knows where it read from: a git config in `$HOME` is the + /// user's own, while one in the checkout is not. + pub fn trusted_as(mut self, trust: Trust) -> Self { + self.trust = trust; + self + } + + /// An origin in a config file of the given class. + pub fn file(identifier: impl Into, scope: FileScope) -> Self { + Self { + kind: SourceKind::FILE, + identifier: identifier.into(), + trust: match scope { + FileScope::Project => Trust::Project, + FileScope::Global | FileScope::System => Trust::Operator, + }, + } + } + + /// The declared default. + /// + /// Named for what it *is* rather than spelled `Default::default`, because an `Origin` has + /// no sensible zero — every one of them names a real place. + pub fn declared_default() -> Self { + Self::new(SourceKind::DEFAULTS, "the default") + } + + /// How to describe this in one phrase: `HK_JOBS`, `hk.toml`, `the default`. + pub fn describe(&self) -> &str { + &self.identifier + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_kind_usage_does_not_know_is_still_a_kind() { + // hk's git config, aube's .npmrc: the reason this is not a closed enum. + let git = SourceKind::new("git"); + assert_eq!(git.name(), "git"); + assert_ne!(git, SourceKind::FILE); + // And the built-ins are distinguishable from each other, which the scope check and + // `explain` both depend on. + assert_ne!(SourceKind::CLI, SourceKind::ENV); + assert_ne!(SourceKind::DEFAULTS, SourceKind::COERCED); + } + + #[test] + fn a_kind_usage_cannot_vouch_for_is_trusted_least() { + // The hole this closes: the scope check used to ask whether an origin was a *file*, so + // every custom source — a pkl file, a git config, an `.npmrc`, all built with + // `Origin::new` — walked straight past it. A pkl file in a checkout is exactly as much + // a thing a repository can carry as `hk.toml` is. + assert_eq!( + Origin::new(SourceKind::new("pkl"), "jobs").trust, + Trust::Project + ); + assert_eq!( + Origin::new(SourceKind::new("git"), "hk.jobs").trust, + Trust::Project + ); + // The kinds usage does know are the invocation itself. + for kind in [SourceKind::CLI, SourceKind::ENV, SourceKind::COERCED] { + assert_eq!(Origin::new(kind, "x").trust, Trust::Invocation, "{kind:?}"); + } + assert_eq!(Origin::declared_default().trust, Trust::Invocation); + // A layer that knows better says so, rather than being believed by default. + assert_eq!( + Origin::new(SourceKind::new("git"), "hk.jobs") + .trusted_as(Trust::Operator) + .trust, + Trust::Operator + ); + // And a file's class decides its trust. + assert_eq!( + Origin::file("hk.toml", FileScope::Project).trust, + Trust::Project + ); + for scope in [FileScope::Global, FileScope::System] { + assert_eq!(Origin::file("x", scope).trust, Trust::Operator, "{scope:?}"); + } + } +} diff --git a/config/src/ty.rs b/config/src/ty.rs new file mode 100644 index 00000000..9305d136 --- /dev/null +++ b/config/src/ty.rs @@ -0,0 +1,365 @@ +//! 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 +//! rather than a parse. +//! +//! Every layer that reads text — the environment, an `.npmrc`, a git config — hands over a +//! string, and the declared type is the only thing that says whether `"1"` is the number +//! one, the string "1", or a one-element list. + +use crate::value::Value; + +/// A declared type, as a generated registry holds it. +/// +/// Containers borrow so the whole thing is `const`-constructible: +/// `Ty::List(&Ty::String)`. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Ty { + Bool, + Int, + /// An integer that may not be negative. + Uint, + Float, + String, + /// A filesystem path. Read as a string here; what makes it a path is what the CLI does + /// with it, and refusing one because it does not exist yet would be wrong. + Path, + Url, + /// A span of time, as text — `"30s"`, `"1h"`. Not parsed here: the crate that owns the + /// duration type owns its spelling, and the generated struct is where it is turned into + /// one. + Duration, + /// A table whose keys the spec does not describe. + Object, + List(&'static Ty), + /// Like a list, but duplicates are dropped on merge. + Set(&'static Ty), + /// A table with values of one type. + Map(&'static Ty), + /// Absent is a legitimate state. Only meaningful about the setting as a whole, so + /// coercion looks straight through it. + Option(&'static Ty), + /// A union, or a type only the tool understands. Nothing is coerced and nothing is + /// refused: the spec said usage cannot know what belongs here, so it takes what it is + /// given. + Any, +} + +/// Why a value could not be read as the type its setting declares. +#[derive(Debug, Clone, PartialEq)] +pub struct TypeError { + /// The type as a human reads it: "an integer". + pub expected: &'static str, + /// What arrived instead, quoted the way it was written. + pub found: String, +} + +impl Ty { + /// The innermost type, looking through `option`. + pub fn inner(self) -> Ty { + match self { + Self::Option(inner) => inner.inner(), + other => other, + } + } + + /// The name of this type as an error message should say it. + pub fn describe(self) -> &'static str { + match self.inner() { + Self::Bool => "a boolean", + Self::Int => "an integer", + Self::Uint => "a positive integer", + Self::Float => "a number", + Self::String => "a string", + Self::Path => "a path", + Self::Url => "a URL", + Self::Duration => "a duration", + Self::Object | Self::Map(_) => "a table", + Self::List(_) | Self::Set(_) => "a list", + Self::Option(_) | Self::Any => "a value", + } + } + + /// `value` read as this type. + /// + /// Text arriving from a layer that has no types of its own is converted; a value that + /// already has the right shape passes through untouched. Anything else is an error + /// rather than a silent reinterpretation — the whole point of declaring the type. + pub fn coerce(self, value: Value) -> Result { + let ty = self.inner(); + // A list-typed setting given one bare value means a list of one. Every registry in + // the fleet relies on this for `MISE_ENV=production`, and doing it here means no + // layer has to know. + if let ( + Self::List(item) | Self::Set(item), + Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::String(_), + ) = (ty, &value) + { + // An empty string is no items, not one empty item — the same rule the named + // parsers follow, and the one `HK_EXCLUDE=` relies on to turn a declared default + // off. Wrapping it produced a list holding `""`, which cleared nothing and added + // an item nobody asked for. + if matches!(&value, Value::String(text) if text.is_empty()) { + return Ok(Value::List(Vec::new())); + } + return Ok(Value::List(vec![item.coerce(value)?])); + } + match (ty, value) { + // Nothing to say about a type nothing was declared for. + (Self::Any, value) => Ok(value), + + (Self::Bool, Value::Bool(b)) => Ok(Value::Bool(b)), + (Self::Bool, Value::String(text)) => match text.as_str() { + // The spellings every one of these registries accepts. Deliberately not + // "anything non-empty is true": `FOO=false` meaning true is the kind of + // surprise a config system exists to prevent. + "true" | "1" | "yes" | "y" | "on" => Ok(Value::Bool(true)), + "false" | "0" | "no" | "n" | "off" | "" => Ok(Value::Bool(false)), + _ => Err(TypeError { + expected: "a boolean", + found: text, + }), + }, + + (Self::Int | Self::Uint, Value::Int(i)) if ty != Self::Uint || i >= 0 => { + Ok(Value::Int(i)) + } + (Self::Int | Self::Uint, Value::String(text)) => match text.trim().parse::() { + Ok(i) if ty != Self::Uint || i >= 0 => Ok(Value::Int(i)), + _ => Err(TypeError { + expected: ty.describe(), + found: text, + }), + }, + + (Self::Float, Value::Float(f)) => Ok(Value::Float(f)), + // A whole number is a perfectly good float, and a spec that says `float` should + // not reject `1`. + (Self::Float, Value::Int(i)) => Ok(Value::Float(i as f64)), + (Self::Float, Value::String(text)) => match text.trim().parse::() { + Ok(f) => Ok(Value::Float(f)), + Err(_) => Err(TypeError { + expected: "a number", + found: text, + }), + }, + + (Self::String | Self::Path | Self::Url | Self::Duration, Value::String(s)) => { + Ok(Value::String(s)) + } + // A path or a URL is text, and a number written where text was expected is text + // that happens to look like a number — `MISE_PYTHON_VERSION=3` should not fail. + (Self::String | Self::Path | Self::Url | Self::Duration, other) => { + Ok(Value::String(other.display())) + } + + (Self::List(item) | Self::Set(item), Value::List(items)) => Ok(Value::List( + items + .into_iter() + .map(|value| item.coerce(value)) + .collect::, _>>()?, + )), + + (Self::Object, Value::Map(entries)) => Ok(Value::Map(entries)), + (Self::Map(item), Value::Map(entries)) => Ok(Value::Map( + entries + .into_iter() + .map(|(key, value)| item.coerce(value).map(|value| (key, value))) + .collect::>()?, + )), + + (ty, found) => Err(TypeError { + expected: ty.describe(), + found: found.display(), + }), + } + } +} + +/// A named way of splitting one string into several values. +/// +/// Spec vocabulary rather than a Rust callback, so a spec that says `parse="list_by_comma"` +/// means the same thing to a Go or a TypeScript runtime reading the same file. A parser a +/// tool has written itself rides as an `x` extension and never reaches here. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Parser { + ListByComma, + ListByColon, + /// `:` or `;`, whichever this platform uses between path entries. + ListByOsPathSeparator, + /// Splits on commas and drops repeats, keeping the first of each. + SetByComma, +} + +impl Parser { + /// The name a spec writes. + pub fn name(self) -> &'static str { + match self { + Self::ListByComma => "list_by_comma", + Self::ListByColon => "list_by_colon", + Self::ListByOsPathSeparator => "list_by_os_path_separator", + Self::SetByComma => "set_by_comma", + } + } + + /// This parser by the name a spec writes. + pub fn from_name(name: &str) -> Option { + match name { + "list_by_comma" => Some(Self::ListByComma), + "list_by_colon" => Some(Self::ListByColon), + "list_by_os_path_separator" => Some(Self::ListByOsPathSeparator), + "set_by_comma" => Some(Self::SetByComma), + _ => None, + } + } + + /// `raw` split into the values it names. + /// + /// An empty string is an empty list rather than a list holding nothing — `HK_EXCLUDE=` + /// means "exclude nothing", which is a thing a user says to override a default. + pub fn split(self, raw: &str) -> Value { + let separator = match self { + Self::ListByComma | Self::SetByComma => ',', + Self::ListByColon => ':', + Self::ListByOsPathSeparator => { + if cfg!(windows) { + ';' + } else { + ':' + } + } + }; + if raw.is_empty() { + return Value::List(Vec::new()); + } + let mut parts: Vec<&str> = raw.split(separator).map(str::trim).collect(); + if self == Self::SetByComma { + let mut seen = Vec::new(); + parts.retain(|part| { + let fresh = !seen.contains(part); + if fresh { + seen.push(*part); + } + fresh + }); + } + Value::List(parts.into_iter().map(Value::from).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(text: &str) -> Value { + Value::String(text.to_string()) + } + + #[test] + fn text_from_a_layer_with_no_types_is_read_as_declared() { + // Every environment variable arrives as a string, so this is the path most values + // in a real CLI take. + assert_eq!(Ty::Bool.coerce(s("yes")), Ok(Value::Bool(true))); + assert_eq!(Ty::Bool.coerce(s("off")), Ok(Value::Bool(false))); + assert_eq!(Ty::Int.coerce(s("-3")), Ok(Value::Int(-3))); + assert_eq!(Ty::Float.coerce(s(" 1.5 ")), Ok(Value::Float(1.5))); + // Whitespace around a number is a typo, not a different number. + assert_eq!(Ty::Int.coerce(s(" 4 ")), Ok(Value::Int(4))); + } + + #[test] + fn a_value_that_cannot_be_the_declared_type_is_an_error() { + // The error carries what arrived, because "expected an integer" without the value is + // no help when it came from a file three directories up. + assert_eq!( + Ty::Int.coerce(s("abc")), + Err(TypeError { + expected: "an integer", + found: "abc".to_string() + }) + ); + // `FOO=maybe` for a boolean is a mistake worth reporting rather than reading as true. + assert!(Ty::Bool.coerce(s("maybe")).is_err()); + // A negative number where only positives belong. + assert!(Ty::Uint.coerce(s("-1")).is_err()); + assert!(Ty::Uint.coerce(Value::Int(-1)).is_err()); + assert_eq!(Ty::Uint.coerce(Value::Int(0)), Ok(Value::Int(0))); + } + + #[test] + fn one_value_where_a_list_belongs_is_a_list_of_one() { + // `MISE_ENV=production`, which every registry in the fleet accepts and no layer + // should have to know about. + const ITEM: &Ty = &Ty::String; + assert_eq!( + Ty::List(ITEM).coerce(s("production")), + Ok(Value::List(vec![s("production")])) + ); + // And the items of a real list are coerced too, so a list of ints from a JSON file + // full of strings still arrives as ints. + const INT: &Ty = &Ty::Int; + assert_eq!( + Ty::List(INT).coerce(Value::List(vec![s("1"), Value::Int(2)])), + Ok(Value::List(vec![Value::Int(1), Value::Int(2)])) + ); + assert!(Ty::List(INT).coerce(Value::List(vec![s("x")])).is_err()); + } + + #[test] + fn an_empty_string_is_no_items_rather_than_one_empty_one() { + // What `HK_EXCLUDE=` means, and the rule the named parsers already follow. Wrapping it + // as a one-element list holding `""` added an item nobody asked for, and — since an + // empty list is how a higher layer clears a declared default — left the default in + // place for exactly the setting the user was trying to empty. + const ITEM: &Ty = &Ty::String; + assert_eq!(Ty::List(ITEM).coerce(s("")), Ok(Value::List(Vec::new()))); + assert_eq!(Ty::Set(ITEM).coerce(s("")), Ok(Value::List(Vec::new()))); + // A non-empty bare value is still a list of one. + assert_eq!( + Ty::List(ITEM).coerce(s("only")), + Ok(Value::List(vec![s("only")])) + ); + // And an empty string is still a perfectly good *string*. + assert_eq!(Ty::String.coerce(s("")), Ok(s(""))); + } + + #[test] + fn a_type_usage_cannot_know_takes_what_it_is_given() { + // The escape hatch: a union or a tool-private type. Refusing here would make the + // spec's own escape hatch unusable. + assert_eq!(Ty::Any.coerce(s("either")), Ok(s("either"))); + assert_eq!(Ty::Any.coerce(Value::Bool(true)), Ok(Value::Bool(true))); + // And `option` is coerced as its inner type, since absence is about the setting + // rather than about the value that did arrive. + const INNER: &Ty = &Ty::Int; + assert_eq!(Ty::Option(INNER).coerce(s("7")), Ok(Value::Int(7))); + } + + #[test] + fn a_named_parser_splits_one_string_the_way_the_spec_says() { + assert_eq!( + Parser::ListByComma.split("a, b,c"), + Value::List(vec![s("a"), s("b"), s("c")]) + ); + // A set keeps the first of each, so the position of a value is stable. + assert_eq!( + Parser::SetByComma.split("a,b,a"), + Value::List(vec![s("a"), s("b")]) + ); + // Emptying a list is a thing a user does to override a default, so it has to be + // expressible: `HK_EXCLUDE=` is no items, not one empty one. + assert_eq!(Parser::ListByComma.split(""), Value::List(Vec::new())); + // Round-tripping the name is what lets a spec and another language's runtime agree. + for parser in [ + Parser::ListByComma, + Parser::ListByColon, + Parser::ListByOsPathSeparator, + Parser::SetByComma, + ] { + assert_eq!(Parser::from_name(parser.name()), Some(parser)); + } + assert_eq!(Parser::from_name("list_by_semicolon"), None); + } +} diff --git a/config/src/value.rs b/config/src/value.rs new file mode 100644 index 00000000..7e9d206c --- /dev/null +++ b/config/src/value.rs @@ -0,0 +1,165 @@ +//! What a setting holds, at runtime and as a declared default. +//! +//! Two types rather than one, because they answer to different masters. [`Value`] is owned: +//! it comes from a file or an environment variable while the process runs, so it has to be +//! allocated. [`Const`] is what a *declared* default is, and every field of it is +//! `const`-constructible so a generated registry costs nothing to load — no parsing, no +//! allocation, nothing done per process start for a setting nobody reads. + +use std::collections::BTreeMap; +use std::fmt; + +/// A resolved configuration value. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Bool(bool), + Int(i64), + Float(f64), + String(String), + List(Vec), + /// A table. Ordered by key so a resolution is reproducible and two runs of `config + /// explain` cannot disagree about what came first. + Map(BTreeMap), +} + +impl Value { + /// The name of this shape, for an error a human has to read. + pub fn type_name(&self) -> &'static str { + match self { + Self::Bool(_) => "a boolean", + Self::Int(_) => "an integer", + Self::Float(_) => "a number", + Self::String(_) => "a string", + Self::List(_) => "a list", + Self::Map(_) => "a table", + } + } + + /// This value written the way a user would type it. + pub fn display(&self) -> String { + match self { + Self::Bool(b) => b.to_string(), + Self::Int(i) => i.to_string(), + Self::Float(f) => f.to_string(), + Self::String(s) => s.clone(), + Self::List(items) => items + .iter() + .map(Self::display) + .collect::>() + .join(","), + Self::Map(entries) => entries + .iter() + .map(|(key, value)| format!("{key}={}", value.display())) + .collect::>() + .join(","), + } + } +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.display()) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Int(value) + } +} + +impl From for Value { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::String(value.to_string()) + } +} + +impl From for Value { + fn from(value: String) -> Self { + Self::String(value) + } +} + +/// A declared default, in the form a generated registry can hold as a `const`. +/// +/// The same shapes as [`Value`], with borrowed strings and slices so nothing is allocated +/// until somebody actually asks for the default of a setting no layer supplied. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum Const { + Bool(bool), + Int(i64), + Float(f64), + Str(&'static str), + List(&'static [Const]), + /// Key-value pairs, ordered by the generator so the `Value` it becomes is too. + Map(&'static [(&'static str, Const)]), +} + +impl Const { + /// The owned value this stands for. + pub fn to_value(self) -> Value { + match self { + Self::Bool(b) => Value::Bool(b), + Self::Int(i) => Value::Int(i), + Self::Float(f) => Value::Float(f), + Self::Str(s) => Value::String(s.to_string()), + Self::List(items) => Value::List(items.iter().map(|item| item.to_value()).collect()), + Self::Map(entries) => Value::Map( + entries + .iter() + .map(|(key, value)| ((*key).to_string(), value.to_value())) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_declared_default_becomes_the_value_it_names() { + // The registry holds these as consts, so this conversion is the only cost a default + // ever has — and only for a setting whose default is actually reached for. + const NESTED: &[Const] = &[Const::Int(80), Const::Int(443)]; + const PAIRS: &[(&str, Const)] = &[("a", Const::Bool(true))]; + assert_eq!(Const::Bool(true).to_value(), Value::Bool(true)); + assert_eq!(Const::Str("x").to_value(), Value::String("x".into())); + assert_eq!( + Const::List(NESTED).to_value(), + Value::List(vec![Value::Int(80), Value::Int(443)]) + ); + assert_eq!( + Const::Map(PAIRS).to_value(), + Value::Map([("a".to_string(), Value::Bool(true))].into_iter().collect()) + ); + } + + #[test] + fn a_value_can_be_written_the_way_it_was_typed() { + // What `config get` prints and what an error quotes back, so a list has to read as + // one rather than as its debug form. + assert_eq!(Value::Bool(false).display(), "false"); + assert_eq!( + Value::List(vec![Value::String("a".into()), Value::Int(2)]).display(), + "a,2" + ); + assert_eq!( + Value::Map([("k".to_string(), Value::Int(1))].into_iter().collect()).display(), + "k=1" + ); + } +}