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" + ); + } +}