From 6caadebcdb8707d88cb6e74802026baa3d5f875d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:09:32 +0000 Subject: [PATCH] feat(config): a conformance corpus for resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The argv corpus pins which token becomes which flag. This pins what a resolution is: given a registry and layers that supply values, which value wins, where it is recorded as coming from, and what the merge had to say about the ones it refused. 46 vectors in six sections — precedence, merging, scope, renames, types, choices — each with a sentence saying what it establishes, because a vector nobody can read is a vector nobody will maintain. A vector describes a **registry** rather than a spec. An argv vector carries KDL because parsing a command line is a question about a spec; a resolution is a question about keys, types, defaults and merge policies, which a build step produces long before anything resolves. So an implementation in Go or TypeScript can run this without first acquiring a KDL parser, and the spec-to-registry mapping stays `usage-config-build`'s question, which its own tests answer. Warnings are pinned by kind and never by message, the same line the argv corpus holds for its error codes. Nothing here opens a file, reads an environment, or starts a process: a layer is a description of what it supplies, which is all the merge ever sees of one. The harness reads the type grammar itself rather than asking the crate under test, since a conformance harness that asks the implementation what a type means is checking that it agrees with itself. --- Cargo.lock | 2 + conformance/Cargo.toml | 2 + conformance/src/config.rs | 848 ++++++++++++++++++++++++++++++++ conformance/src/lib.rs | 25 +- conformance/tests/config.rs | 103 ++++ corpus/README.md | 4 + corpus/config/01-precedence.kdl | 101 ++++ corpus/config/02-merging.kdl | 109 ++++ corpus/config/03-scope.kdl | 78 +++ corpus/config/04-renames.kdl | 80 +++ corpus/config/05-types.kdl | 129 +++++ corpus/config/06-choices.kdl | 107 ++++ corpus/config/README.md | 113 +++++ 13 files changed, 1693 insertions(+), 8 deletions(-) create mode 100644 conformance/src/config.rs create mode 100644 conformance/tests/config.rs create mode 100644 corpus/config/01-precedence.kdl create mode 100644 corpus/config/02-merging.kdl create mode 100644 corpus/config/03-scope.kdl create mode 100644 corpus/config/04-renames.kdl create mode 100644 corpus/config/05-types.kdl create mode 100644 corpus/config/06-choices.kdl create mode 100644 corpus/config/README.md diff --git a/Cargo.lock b/Cargo.lock index deae175a3..c05992993 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2106,9 +2106,11 @@ name = "usage-conformance" version = "0.0.0" dependencies = [ "insta", + "kdl", "serde", "serde_json", "usage-argv", + "usage-config", "usage-derive", "usage-lib", ] diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index 5265fb564..ab05f7abb 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -12,9 +12,11 @@ authors = { workspace = true } license = { workspace = true } [dependencies] +kdl = "6" serde = { version = "1", features = ["derive"] } serde_json = "1" usage-argv = { workspace = true, features = ["spec", "complete"] } +usage-config = { workspace = true } usage-lib = { workspace = true } [dev-dependencies] diff --git a/conformance/src/config.rs b/conformance/src/config.rs new file mode 100644 index 000000000..5e5b34579 --- /dev/null +++ b/conformance/src/config.rs @@ -0,0 +1,848 @@ +//! The config conformance corpus: its format, and the runner that answers it. +//! +//! The argv corpus pins which token becomes which flag. This one pins what a *resolution* is: given +//! a set of settings and some layers that supply values, which value wins, where it is recorded as +//! coming from, and what the merge has to say about the ones it refused. +//! +//! # Why a registry rather than a spec +//! +//! An argv vector carries a KDL spec, because parsing argv is a question about a spec. A resolution +//! is not: it is a question about a *registry* — keys, types, defaults, merge policies — which a +//! CLI's build step produces from the spec long before anything is resolved. So a vector describes +//! the registry directly in KDL, usage's canonical interchange format. How a spec becomes a +//! registry is `usage-config-build`'s question, and its own golden test answers it. +//! +//! Nothing here touches the filesystem, the process environment, or a subprocess. A file layer is a +//! description of what a file said, which is all the merge ever sees of one. + +use std::collections::BTreeMap; +use std::path::Path; + +use kdl::{KdlDocument, KdlNode, KdlValue}; +use serde::{Deserialize, Serialize}; +use usage_config::{ + resolve, Const, Layer, LayerCtx, LayerError, LayerOutput, Layers, Merge, Origin, PropMeta, + Registry, Scope, SourceKind, Trust, Ty, Value, WarningKind, +}; + +/// One `corpus/config/*.kdl` file: a themed group of vectors. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct VectorFile { + /// Which part of resolution this file covers, e.g. `"precedence"`. + pub section: String, + /// What the group establishes, plus anything a reader needs in order to judge whether these + /// expectations are the right ones. + pub about: String, + pub vectors: Vec, +} + +/// A single case: resolve `layers` against `settings` and you must get `expect`. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Vector { + /// Stable identifier, unique across the corpus. Reports quote it, so renaming one breaks + /// anybody tracking known failures. + pub id: String, + /// What this vector pins down, in one sentence. + pub doc: String, + /// The settings that exist, in the order a registry declares them. + pub settings: Vec, + /// The layers, **highest precedence first** — the order `Layers::then` takes them, and the + /// order a reader thinks in: the command line, then the environment, then files. + #[serde(default)] + pub layers: Vec, + pub expect: Expect, +} + +/// One setting, as a registry holds it. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Setting { + pub key: String, + /// The type, in the spec's own grammar: `uint`, `list`, `map`, + /// `option`. A name this corpus does not know is `any`, which is what a union is too. + #[serde(default = "default_type")] + pub r#type: String, + /// The value when no layer supplies one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub merge: MergePolicy, + #[serde(default, skip_serializing_if = "is_default")] + pub scope: ScopeRule, + /// A named parser, for a layer that hands over one string where several values are meant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parse: Option, + /// The only values this setting accepts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub choices: Vec, + /// The setting this one was replaced by. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub renamed_to: Option, + /// Why not to use this one any more. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deprecated: Option, +} + +fn default_type() -> String { + "string".to_string() +} + +fn is_default(value: &T) -> bool { + *value == T::default() +} + +/// How values from several layers combine. +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MergePolicy { + /// The highest-precedence layer wins outright. + #[default] + Replace, + /// Collections from every layer are concatenated, keeping first position. + Union, + /// Tables are merged key by key. + Deep, +} + +/// Which places may set a setting. +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScopeRule { + #[default] + Any, + /// Not from anything a repository can carry. + Global, + /// Never from a file at all. + Env, +} + +/// One layer, described by what it supplies rather than by where it read it. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LayerSpec { + /// The kind of place: `cli`, `env`, `file`, or a name only the tool knows, like `git`. + pub source: String, + /// What a report calls it: the variable's name, the file's path. Defaults to the source's own + /// name, which is enough for a vector with one layer of a kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// How much this place is trusted, which is what the scope rules read. Defaults to what the + /// kind implies: the command line and the environment are the user's own, and anything else is + /// taken to be something a repository could carry until it says otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust: Option, + /// Values as text, the way a layer that reads an environment or a `.ini` hands them over. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub values: BTreeMap, + /// Values with a shape of their own, the way a layer that reads TOML or JSON hands them over. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub shaped: BTreeMap, +} + +/// How far a place is trusted. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TrustLevel { + /// Given by whoever ran the command. + Invocation, + /// Something the person who set the machine up put there. + Operator, + /// Something a checkout can carry, and therefore anybody who can open a pull request. + Project, +} + +/// What a resolution must produce. +#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Expect { + /// The value of each setting. A setting left out is expected to have no value at all, so a + /// vector says what it means rather than only what it is interested in. + #[serde(default)] + pub values: BTreeMap, + /// Which place each value is recorded as coming from, where the vector is about that. Omitted + /// keys are not checked. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub origins: BTreeMap, + /// What the merge had to say, by kind and in order. + /// + /// Kinds, not messages: wording is a quality-of-implementation concern and is expected to + /// differ between implementations, which is the same line the argv corpus holds for its error + /// codes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// The classes of thing a resolution reports. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Complaint { + /// A key no setting declares. + UnknownSetting, + /// A value the declared type cannot read. + WrongType, + /// A value the declared choices do not allow. + NotAllowed, + /// A place that may not set this setting. + OutOfScope, + /// A setting whose spec says not to use it any more. + Deprecated, + /// A value read as the setting that replaced the name it was written under. + Renamed, + /// A value passed over because another name for the same setting won. + NotRead, +} + +/// Load every config corpus KDL file in a directory, sorted by file name. +pub fn load(dir: impl AsRef) -> Result, String> { + let dir = dir.as_ref(); + let mut paths: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| format!("reading {}: {e}", dir.display()))? + .map(|entry| { + entry + .map(|entry| entry.path()) + .map_err(|e| format!("reading an entry of {}: {e}", dir.display())) + }) + .collect::, _>>()? + .into_iter() + .filter(|path| path.extension().is_some_and(|extension| extension == "kdl")) + .collect(); + paths.sort(); + + paths + .iter() + .map(|path| { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("reading {}: {e}", path.display()))?; + parse_file(&text).map_err(|e| format!("parsing {}: {e}", path.display())) + }) + .collect() +} + +/// Parse one config corpus file from canonical KDL. +pub fn parse_file(text: &str) -> Result { + let document: KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?; + let section = one_string(&document, "section")?; + let about = one_string(&document, "about")?; + let vectors = document + .nodes() + .iter() + .filter(|node| node.name().value() == "vector") + .map(vector_from) + .collect::>()?; + reject_nodes(&document, &["section", "about", "vector"])?; + Ok(VectorFile { + section, + about, + vectors, + }) +} + +/// Parse one vector, useful to tests that exercise malformed values without making a corpus file. +pub fn parse_vector(text: &str) -> Result { + let document: KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?; + let nodes: Vec<_> = document + .nodes() + .iter() + .filter(|node| node.name().value() == "vector") + .collect(); + if nodes.len() != 1 || document.nodes().len() != 1 { + return Err("a vector document must contain exactly one `vector` node".to_string()); + } + vector_from(nodes[0]) +} + +fn vector_from(node: &KdlNode) -> Result { + let id = string(node, 0)?; + let doc = string(node, "doc")?; + let children = children(node)?; + let settings = children + .nodes() + .iter() + .filter(|node| node.name().value() == "setting") + .map(setting_from) + .collect::>()?; + let layers = children + .nodes() + .iter() + .filter(|node| node.name().value() == "layer") + .map(layer_from) + .collect::>()?; + let expects: Vec<_> = children + .nodes() + .iter() + .filter(|node| node.name().value() == "expect") + .collect(); + if expects.len() != 1 { + return Err(format!( + "vector `{id}` must contain exactly one `expect` node" + )); + } + reject_nodes(children, &["setting", "layer", "expect"])?; + Ok(Vector { + id, + doc, + settings, + layers, + expect: expect_from(expects[0])?, + }) +} + +fn setting_from(node: &KdlNode) -> Result { + let children = node.children(); + if let Some(children) = children { + reject_nodes(children, &["default", "choice"])?; + } + let defaults = child_values(children, "default")?; + let default = match (node.get("default"), defaults.is_empty()) { + (Some(_), false) => { + return Err("a setting cannot have both scalar and list defaults".into()) + } + (Some(value), true) => Some(json_value(value)?), + (None, false) => Some(serde_json::Value::Array(defaults)), + (None, true) => None, + }; + Ok(Setting { + key: string(node, 0)?, + r#type: optional_string(node, "type")?.unwrap_or_else(default_type), + default, + merge: match optional_string(node, "merge")?.as_deref() { + None | Some("replace") => MergePolicy::Replace, + Some("union") => MergePolicy::Union, + Some("deep") => MergePolicy::Deep, + Some(value) => return Err(format!("no merge policy named `{value}`")), + }, + scope: match optional_string(node, "scope")?.as_deref() { + None | Some("any") => ScopeRule::Any, + Some("global") => ScopeRule::Global, + Some("env") => ScopeRule::Env, + Some(value) => return Err(format!("no scope named `{value}`")), + }, + parse: optional_string(node, "parse")?, + choices: child_values(children, "choice")?, + renamed_to: optional_string(node, "renamed-to")?, + deprecated: optional_string(node, "deprecated")?, + }) +} + +fn layer_from(node: &KdlNode) -> Result { + let children = node.children(); + if let Some(children) = children { + reject_nodes(children, &["value", "shaped", "shaped-list"])?; + } + let trust = match optional_string(node, "trust")?.as_deref() { + None => None, + Some("invocation") => Some(TrustLevel::Invocation), + Some("operator") => Some(TrustLevel::Operator), + Some("project") => Some(TrustLevel::Project), + Some(value) => return Err(format!("no trust level named `{value}`")), + }; + Ok(LayerSpec { + source: string(node, 0)?, + id: optional_string(node, "id")?, + trust, + values: keyed_strings(children, "value")?, + shaped: keyed_values(children, "shaped", "shaped-list")?, + }) +} + +fn expect_from(node: &KdlNode) -> Result { + let children = node.children(); + if let Some(children) = children { + reject_nodes(children, &["value", "list", "map", "origin", "warning"])?; + } + let warnings = children + .into_iter() + .flat_map(KdlDocument::nodes) + .filter(|node| node.name().value() == "warning") + .map(|node| match string(node, 0)?.as_str() { + "unknown-setting" => Ok(Complaint::UnknownSetting), + "wrong-type" => Ok(Complaint::WrongType), + "not-allowed" => Ok(Complaint::NotAllowed), + "out-of-scope" => Ok(Complaint::OutOfScope), + "deprecated" => Ok(Complaint::Deprecated), + "renamed" => Ok(Complaint::Renamed), + "not-read" => Ok(Complaint::NotRead), + value => Err(format!("no warning kind named `{value}`")), + }) + .collect::>()?; + Ok(Expect { + values: keyed_values(children, "value", "list")? + .into_iter() + .chain(keyed_values(children, "map", "unused")?) + .collect(), + origins: keyed_strings(children, "origin")?, + warnings, + }) +} + +fn keyed_strings( + document: Option<&KdlDocument>, + name: &str, +) -> Result, String> { + document + .into_iter() + .flat_map(KdlDocument::nodes) + .filter(|node| node.name().value() == name) + .map(|node| Ok((string(node, 0)?, string(node, 1)?))) + .collect() +} + +fn keyed_values( + document: Option<&KdlDocument>, + scalar_or_map: &str, + list: &str, +) -> Result, String> { + let mut values = BTreeMap::new(); + for node in document.into_iter().flat_map(KdlDocument::nodes) { + let name = node.name().value(); + let value = if name == list { + serde_json::Value::Array( + positional(node) + .skip(1) + .map(json_value) + .collect::>()?, + ) + } else if name == scalar_or_map { + if let Some(children) = node.children() { + serde_json::Value::Object( + keyed_values(Some(children), "value", "list")? + .into_iter() + .collect(), + ) + } else { + json_value( + node.get(1) + .ok_or_else(|| format!("`{name}` needs a key and a value"))?, + )? + } + } else { + continue; + }; + let key = string(node, 0)?; + if values.insert(key.clone(), value).is_some() { + return Err(format!("`{key}` is declared twice")); + } + } + Ok(values) +} + +fn child_values( + document: Option<&KdlDocument>, + name: &str, +) -> Result, String> { + document + .into_iter() + .flat_map(KdlDocument::nodes) + .filter(|node| node.name().value() == name) + .map(|node| { + node.get(0) + .ok_or_else(|| format!("`{name}` needs a value")) + .and_then(json_value) + }) + .collect() +} + +fn one_string(document: &KdlDocument, name: &str) -> Result { + let nodes: Vec<_> = document + .nodes() + .iter() + .filter(|node| node.name().value() == name) + .collect(); + if nodes.len() != 1 { + return Err(format!( + "a corpus file must contain exactly one `{name}` node" + )); + } + string(nodes[0], 0) +} + +fn string(node: &KdlNode, key: impl Into) -> Result { + let name = node.name().value(); + node.get(key) + .and_then(KdlValue::as_string) + .map(str::to_string) + .ok_or_else(|| format!("`{name}` needs a string value")) +} + +fn optional_string(node: &KdlNode, key: impl Into) -> Result, String> { + let key = key.into(); + node.get(key.clone()) + .map(|value| { + value + .as_string() + .map(str::to_string) + .ok_or_else(|| format!("`{}` has a non-string property", node.name().value())) + }) + .transpose() +} + +fn children(node: &KdlNode) -> Result<&KdlDocument, String> { + node.children() + .ok_or_else(|| format!("`{}` needs a child block", node.name().value())) +} + +fn reject_nodes(document: &KdlDocument, allowed: &[&str]) -> Result<(), String> { + if let Some(node) = document + .nodes() + .iter() + .find(|node| !allowed.contains(&node.name().value())) + { + return Err(format!("unexpected `{}` node", node.name().value())); + } + Ok(()) +} + +fn positional(node: &KdlNode) -> impl Iterator { + node.entries() + .iter() + .filter(|entry| entry.name().is_none()) + .map(kdl::KdlEntry::value) +} + +fn json_value(value: &KdlValue) -> Result { + Ok(match value { + KdlValue::String(value) => serde_json::Value::String(value.clone()), + KdlValue::Integer(value) => { + if let Ok(value) = i64::try_from(*value) { + serde_json::Value::Number(value.into()) + } else if let Ok(value) = u64::try_from(*value) { + serde_json::Value::Number(value.into()) + } else { + return Err(format!("`{value}` is too large for the corpus value model")); + } + } + KdlValue::Float(value) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .ok_or_else(|| format!("`{value}` is not a finite number"))?, + KdlValue::Bool(value) => serde_json::Value::Bool(*value), + KdlValue::Null => serde_json::Value::Null, + }) +} + +impl Complaint { + fn of(kind: WarningKind) -> Option { + Some(match kind { + WarningKind::UnknownSetting => Self::UnknownSetting, + WarningKind::WrongType => Self::WrongType, + WarningKind::NotAllowed => Self::NotAllowed, + WarningKind::OutOfScope => Self::OutOfScope, + WarningKind::Deprecated => Self::Deprecated, + WarningKind::Renamed => Self::Renamed, + WarningKind::NotRead => Self::NotRead, + // A layer of a CLI's own, which no vector can describe: this corpus only builds the + // layers it defines, so there is nothing here that could produce one. + _ => return None, + }) + } +} + +/// Resolve a vector, and say what came out. +pub fn run(vector: &Vector) -> Result { + let registry = registry_of(&vector.settings)?; + let layers: Vec = vector.layers.iter().map(Described::new).collect(); + let mut plan = Layers::new(); + for layer in &layers { + plan = plan.then(layer); + } + let resolved = resolve(registry, plan).map_err(|err| format!("{err}"))?; + + let mut values = BTreeMap::new(); + let mut origins = BTreeMap::new(); + for id in registry.ids() { + let meta = registry.get(id); + // An old name has no value of its own — every read of it folds — so reporting one would be + // reporting the replacement's value twice, under a key nothing resolves. + if meta.renamed_to.is_some() { + continue; + } + if let Some(value) = resolved.get(id) { + values.insert(meta.key.to_string(), json_of(value)); + } + if let Some(origin) = resolved.origin(id) { + origins.insert(meta.key.to_string(), origin.describe().to_string()); + } + } + Ok(Expect { + values, + origins, + warnings: resolved + .warnings + .iter() + .filter_map(|warning| Complaint::of(warning.kind)) + .collect(), + }) +} + +/// Compare what came out against what a vector asked for. +/// +/// `origins` is checked only where the vector names a key, because most vectors are not about where +/// a value came from and listing every one of them would bury the ones that are. +pub fn matches(expect: &Expect, actual: &Expect) -> bool { + expect.values == actual.values + && expect.warnings == actual.warnings + && expect + .origins + .iter() + .all(|(key, origin)| actual.origins.get(key) == Some(origin)) +} + +/// A layer that supplies exactly what a vector described. +struct Described<'a> { + spec: &'a LayerSpec, + kind: SourceKind, +} + +impl<'a> Described<'a> { + fn new(spec: &'a LayerSpec) -> Self { + let kind = match spec.source.as_str() { + "cli" => SourceKind::CLI, + "env" => SourceKind::ENV, + "file" => SourceKind::FILE, + // A kind usage does not know is exactly what `source "git"` in a spec declares, and the + // merge is expected to treat it as one it cannot vouch for. + other => SourceKind::new(Box::leak(other.to_string().into_boxed_str())), + }; + Self { spec, kind } + } + + fn origin(&self) -> Origin { + let id = self + .spec + .id + .clone() + .unwrap_or_else(|| self.spec.source.clone()); + let origin = Origin::new(self.kind, id); + match self.spec.trust { + Some(TrustLevel::Invocation) => origin.trusted_as(Trust::Invocation), + Some(TrustLevel::Operator) => origin.trusted_as(Trust::Operator), + Some(TrustLevel::Project) => origin.trusted_as(Trust::Project), + None => origin, + } + } +} + +impl Layer for Described<'_> { + fn source(&self) -> SourceKind { + self.kind + } + + fn load(&self, ctx: &LayerCtx) -> Result { + let mut out = LayerOutput::new(); + for (key, raw) in &self.spec.values { + match ctx.entry_for_key(key, raw, self.origin()) { + Ok(entry) => out.push(entry), + Err(warning) => out.warn(warning), + } + } + for (key, value) in &self.spec.shaped { + // A value the harness cannot read is a broken *vector*, not a layer with something to + // say about it: it stops the run rather than resolving to something nobody wrote. + let value = value_of(value).map_err(|why| LayerError::Unreadable { + source: format!("the `{key}` value of a {} layer", self.spec.source), + why, + })?; + match ctx.entry_from_value(key, value, self.origin()) { + Ok(entry) => out.push(entry), + Err(warning) => out.warn(warning), + } + } + Ok(out) + } +} + +/// The registry a vector's settings describe. +/// +/// Leaked, because a registry is `&'static` by design: it is a `const` in every real CLI, built at +/// compile time from the spec. A test harness is the one place that needs to build one while it +/// runs, and a few dozen vectors' worth of settings is a few dozen kilobytes for the life of a test +/// process. +fn registry_of(settings: &[Setting]) -> Result { + let mut props = Vec::with_capacity(settings.len()); + for setting in settings { + let ty = ty_of(&setting.r#type)?; + props.push(PropMeta { + key: leak(&setting.key), + ty, + default: setting.default.as_ref().map(const_of).transpose()?, + merge: match setting.merge { + MergePolicy::Replace => Merge::Replace, + MergePolicy::Union => Merge::Union, + MergePolicy::Deep => Merge::Deep, + }, + scope: match setting.scope { + ScopeRule::Any => Scope::Any, + ScopeRule::Global => Scope::Global, + ScopeRule::Env => Scope::Env, + }, + parse: match &setting.parse { + Some(name) => Some( + usage_config::Parser::from_name(name) + .ok_or_else(|| format!("no parser named `{name}`"))?, + ), + None => None, + }, + envs: &[], + bindings: &[], + choices: Box::leak( + setting + .choices + .iter() + .map(const_of) + .collect::, _>>()? + .into_boxed_slice(), + ), + hide: false, + deprecated: setting.deprecated.as_deref().map(leak), + renamed_to: setting.renamed_to.as_deref().map(leak), + help: None, + }); + } + Ok(Registry::new(Box::leak(props.into_boxed_slice()))) +} + +/// The corpus's own reading of the type grammar. +/// +/// Deliberately its own: a conformance harness that asked the implementation under test what a type +/// means would be checking that it agrees with itself. +fn ty_of(text: &str) -> Result { + let text = text.trim(); + if let Some(inner) = wrapped(text, "list") { + return Ok(Ty::List(Box::leak(Box::new(ty_of(inner)?)))); + } + if let Some(inner) = wrapped(text, "set") { + return Ok(Ty::Set(Box::leak(Box::new(ty_of(inner)?)))); + } + if let Some(inner) = wrapped(text, "option") { + return Ok(Ty::Option(Box::leak(Box::new(ty_of(inner)?)))); + } + if let Some(inner) = wrapped(text, "map") { + // A table's keys are text in every format a settings file is written in, so only the value + // type is carried. + let (key, value) = inner + .split_once(',') + .ok_or_else(|| format!("`{text}` needs a key type and a value type"))?; + if key.trim() != "string" { + return Err(format!("`{text}`: a table's keys are text")); + } + return Ok(Ty::Map(Box::leak(Box::new(ty_of(value)?)))); + } + Ok(match text { + "bool" => Ty::Bool, + "int" => Ty::Int, + "uint" => Ty::Uint, + "float" => Ty::Float, + "string" => Ty::String, + "path" => Ty::Path, + "url" => Ty::Url, + "duration" => Ty::Duration, + "object" => Ty::Object, + // A union, or a name this corpus does not know: the type says nothing is decided here. + "any" => Ty::Any, + other if other.contains('|') => Ty::Any, + other => return Err(format!("no type named `{other}`")), + }) +} + +fn wrapped<'t>(text: &'t str, name: &str) -> Option<&'t str> { + text.strip_prefix(name)? + .strip_prefix('<')? + .strip_suffix('>') +} + +fn leak(text: &str) -> &'static str { + Box::leak(text.to_string().into_boxed_str()) +} + +/// A corpus number as the number this crate holds, or a reason it is not one. +/// +/// Refused rather than clamped or zeroed. A harness that quietly misreads its own corpus is worse +/// than one that cannot read it: the vector would go on to pass or fail for a reason that has +/// nothing to do with what it says. +fn number(n: &serde_json::Number) -> Result { + if let Some(i) = n.as_i64() { + return Ok(Number::Int(i)); + } + if let Some(f) = n.as_f64().filter(|_| n.is_f64()) { + return Ok(Number::Float(f)); + } + Err(format!( + "`{n}` is not a number this crate can hold: an integer is 64 bits and signed" + )) +} + +/// One of the two shapes a number arrives in. +enum Number { + Int(i64), + Float(f64), +} + +/// A corpus value as a declared constant. +fn const_of(value: &serde_json::Value) -> Result { + Ok(match value { + serde_json::Value::Bool(b) => Const::Bool(*b), + serde_json::Value::Number(n) => match number(n)? { + Number::Int(i) => Const::Int(i), + Number::Float(f) => Const::Float(f), + }, + serde_json::Value::Array(items) => Const::List(Box::leak( + items + .iter() + .map(const_of) + .collect::, _>>()? + .into_boxed_slice(), + )), + serde_json::Value::Object(entries) => Const::Map(Box::leak( + entries + .iter() + .map(|(key, value)| const_of(value).map(|value| (leak(key), value))) + .collect::, _>>()? + .into_boxed_slice(), + )), + // `null` is a key that is not there, and a default of one is a default of nothing. + serde_json::Value::Null => Const::Str(""), + serde_json::Value::String(s) => Const::Str(leak(s)), + }) +} + +/// A corpus value as a resolved one. +fn value_of(value: &serde_json::Value) -> Result { + Ok(match value { + serde_json::Value::Bool(b) => Value::Bool(*b), + serde_json::Value::Number(n) => match number(n)? { + Number::Int(i) => Value::Int(i), + Number::Float(f) => Value::Float(f), + }, + serde_json::Value::Array(items) => { + Value::List(items.iter().map(value_of).collect::, _>>()?) + } + serde_json::Value::Object(entries) => Value::Map( + entries + .iter() + .map(|(key, value)| value_of(value).map(|value| (key.clone(), value))) + .collect::>()?, + ), + serde_json::Value::Null => Value::String(String::new()), + serde_json::Value::String(s) => Value::String(s.clone()), + }) +} + +/// A resolved value as JSON, for comparing with what a vector wrote. +fn json_of(value: &Value) -> serde_json::Value { + match value { + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Int(i) => serde_json::Value::Number((*i).into()), + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::String(s) => serde_json::Value::String(s.clone()), + Value::List(items) => serde_json::Value::Array(items.iter().map(json_of).collect()), + Value::Map(entries) => serde_json::Value::Object( + entries + .iter() + .map(|(key, value)| (key.clone(), json_of(value))) + .collect(), + ), + } +} diff --git a/conformance/src/lib.rs b/conformance/src/lib.rs index 125b4ec97..0a9d16fe4 100644 --- a/conformance/src/lib.rs +++ b/conformance/src/lib.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; pub mod argv; +pub mod config; pub mod reference; /// One `corpus/*.json` file: a themed group of vectors. @@ -175,9 +176,23 @@ pub enum Reference { /// Load every `*.json` file in a corpus directory, sorted by file name. pub fn load(dir: impl AsRef) -> Result, String> { + load_as(dir) +} + +/// The corpus directory, resolved against this crate rather than the process's +/// working directory. +pub fn corpus_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../corpus") +} + +/// Load every `*.json` file in a directory as `T`, sorted by file name. +/// +/// The same walk [`load`] does, for a corpus whose vectors are a different shape: a config vector +/// describes a registry and some layers rather than a spec and an argv. +pub fn load_as(dir: impl AsRef) -> Result, String> { let dir = dir.as_ref(); - // An unreadable entry is an error rather than something to skip: silently - // dropping one would let CI validate a partial corpus and still pass. + // An unreadable entry is an error rather than something to skip: silently dropping one would + // let CI validate a partial corpus and still pass. let mut paths: Vec<_> = std::fs::read_dir(dir) .map_err(|e| format!("reading {}: {e}", dir.display()))? .map(|entry| { @@ -200,9 +215,3 @@ pub fn load(dir: impl AsRef) -> Result, String> { }) .collect() } - -/// The corpus directory, resolved against this crate rather than the process's -/// working directory. -pub fn corpus_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../corpus") -} diff --git a/conformance/tests/config.rs b/conformance/tests/config.rs new file mode 100644 index 000000000..ac046c0c6 --- /dev/null +++ b/conformance/tests/config.rs @@ -0,0 +1,103 @@ +//! Checks usage-config against the config corpus. +//! +//! The corpus is the definition of what a resolution is; this is the reference implementation +//! answering it. Every vector applies — there is no layer of this that an implementation may skip, +//! because a resolution is one thing rather than a pipeline whose halves can be implemented apart. +//! +//! Corpus well-formedness — unique ids, no empty sections — is checked here too, since this is the +//! only test that loads the config corpus. + +use std::collections::BTreeSet; + +use usage_conformance::config::{matches, run, VectorFile}; + +fn corpus() -> Vec { + usage_conformance::config::load(usage_conformance::corpus_dir().join("config")) + .expect("the config corpus should load") +} + +#[test] +fn every_vector_resolves_the_way_it_says() { + let mut failures = Vec::new(); + for file in corpus() { + for vector in &file.vectors { + match run(vector) { + Ok(actual) if matches(&vector.expect, &actual) => {} + Ok(actual) => failures.push(format!( + "{}: {}\n expected: {:?}\n got: {actual:?}", + vector.id, vector.doc, vector.expect + )), + Err(err) => failures.push(format!("{}: {}\n {err}", vector.id, vector.doc)), + } + } + } + assert!( + failures.is_empty(), + "{} of the corpus's vectors do not resolve as written:\n\n{}", + failures.len(), + failures.join("\n\n") + ); +} + +#[test] +fn the_corpus_is_well_formed() { + let files = corpus(); + assert!(!files.is_empty(), "the corpus should have files in it"); + + let mut ids = BTreeSet::new(); + for file in &files { + assert!( + !file.about.is_empty(), + "{} has nothing to say", + file.section + ); + assert!( + !file.vectors.is_empty(), + "{} has no vectors in it", + file.section + ); + for vector in &file.vectors { + assert!( + ids.insert(vector.id.clone()), + "two vectors are called `{}`; reports quote these, so they have to be unique", + vector.id + ); + assert!(!vector.doc.is_empty(), "{} says nothing", vector.id); + assert!( + !vector.settings.is_empty(), + "{} declares no settings, so there is nothing for it to resolve", + vector.id + ); + } + } +} + +#[test] +fn a_vector_the_harness_cannot_read_stops_rather_than_resolving() { + // A number too large for the value model used to become zero, so the vector went on to pass or + // fail for a reason that has nothing to do with what it says. A harness that quietly misreads + // its own corpus is worse than one that cannot read it. + let vector = usage_conformance::config::parse_vector( + r#"vector "too-big" doc="An integer past what the value model holds." { + setting "jobs" type="int" default=9223372036854775808 + expect {} + }"#, + ) + .expect("the vector itself is well-formed KDL"); + let err = run(&vector).expect_err("the default cannot be read"); + assert!(err.contains("9223372036854775808"), "{err}"); + + // The same in a layer, which reaches it by the other road. + let vector = usage_conformance::config::parse_vector( + r#"vector "too-big-supplied" doc="The same number, supplied by a layer." { + setting "jobs" type="int" + layer "file" { + shaped "jobs" 9223372036854775808 + } + expect {} + }"#, + ) + .expect("the vector itself is well-formed KDL"); + let err = run(&vector).expect_err("the value cannot be read"); + assert!(err.contains("jobs"), "{err}"); +} diff --git a/corpus/README.md b/corpus/README.md index 2a647521f..dccd39497 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -1,5 +1,9 @@ # The argv conformance corpus +> Resolving a CLI's _configuration_ — layers, precedence, merge policies — has a +> corpus of its own in [`config/`](config/README.md). This file is about parsing +> a command line. + Test vectors for [the argv grammar](https://usage.jdx.dev/spec/argv). Each one pairs a spec with a command line and the result parsing them must produce. diff --git a/corpus/config/01-precedence.kdl b/corpus/config/01-precedence.kdl new file mode 100644 index 000000000..cb07ee76d --- /dev/null +++ b/corpus/config/01-precedence.kdl @@ -0,0 +1,101 @@ +section "precedence" +about "Which layer's value a setting ends up with. The order is fixed and not negotiable — the command line, then the environment, then files nearest-first, then whatever a CLI puts below them, then the declared default — and which layers a CLI has is its own business. A layer that supplies nothing for a setting is not a layer that clears it." + +vector "default-when-nothing-supplies" doc="A declared default is the value when no layer has one." { + setting "jobs" type="uint" default=4 + expect { + value "jobs" 4 + origin "jobs" "the default" + } +} + +vector "no-default-is-no-value" doc="A setting with no default and nothing supplied has no value at all, rather than an empty one." { + setting "profile" type="string" + expect { + } +} + +vector "one-layer-beats-the-default" doc="Any layer at all beats the declared default." { + setting "jobs" type="uint" default=4 + layer "file" id="hk.toml" { + value "jobs" "2" + } + expect { + value "jobs" 2 + origin "jobs" "hk.toml" + } +} + +vector "cli-beats-env-beats-file" doc="The declared order decides: the command line, then the environment, then a file." { + setting "jobs" type="uint" default=4 + layer "cli" id="--jobs" { + value "jobs" "8" + } + layer "env" id="EX_JOBS" { + value "jobs" "6" + } + layer "file" id="hk.toml" { + value "jobs" "2" + } + expect { + value "jobs" 8 + origin "jobs" "--jobs" + } +} + +vector "env-beats-file" doc="With no command line, the environment wins over a file." { + setting "jobs" type="uint" default=4 + layer "env" id="EX_JOBS" { + value "jobs" "6" + } + layer "file" id="hk.toml" { + value "jobs" "2" + } + expect { + value "jobs" 6 + origin "jobs" "EX_JOBS" + } +} + +vector "nearest-file-wins" doc="Among files, the one listed first wins — which is the nearest to where the user is standing." { + setting "jobs" type="uint" + layer "file" id="./hk.toml" { + value "jobs" "2" + } + layer "file" id="~/.config/hk.toml" { + value "jobs" "9" + } + expect { + value "jobs" 2 + origin "jobs" "./hk.toml" + } +} + +vector "a-layer-that-says-nothing-changes-nothing" doc="A higher layer with no value for a setting leaves the lower one alone." { + setting "jobs" type="uint" + setting "profile" type="string" + layer "cli" { + value "profile" "release" + } + layer "file" id="hk.toml" { + value "jobs" "2" + } + expect { + value "jobs" 2 + value "profile" "release" + origin "jobs" "hk.toml" + origin "profile" "cli" + } +} + +vector "a-key-nobody-declares-is-a-warning-and-nothing-else" doc="An unknown key is reported and the rest of the layer still applies: a file written for a newer binary must still work with an older one." { + setting "jobs" type="uint" + layer "file" id="hk.toml" { + value "jobs" "2" + value "from_the_future" "yes" + } + expect { + value "jobs" 2 + warning "unknown-setting" + } +} diff --git a/corpus/config/02-merging.kdl b/corpus/config/02-merging.kdl new file mode 100644 index 000000000..08327d204 --- /dev/null +++ b/corpus/config/02-merging.kdl @@ -0,0 +1,109 @@ +section "merging" +about "What happens when more than one layer has something to say about the same setting. Replacing is the default and the only sensible one for a scalar; a collection may instead be declared to accumulate, which is how a project's exclusions add to a machine's rather than hiding them. The policy is the setting's, declared once, rather than each layer's to decide." + +vector "replace-takes-the-highest" doc="A `replace` setting takes the highest layer's value and forgets the rest." { + setting "exclude" type="list" parse="list_by_comma" + layer "env" id="EX_EXCLUDE" { + value "exclude" "target" + } + layer "file" id="hk.toml" { + value "exclude" "vendor,dist" + } + expect { + list "exclude" "target" + } +} + +vector "union-keeps-every-layer" doc="A `union` setting takes the items from every layer, lowest first, so the nearest file's additions come last." { + setting "exclude" type="list" merge="union" parse="list_by_comma" + layer "env" id="EX_EXCLUDE" { + value "exclude" "target" + } + layer "file" id="hk.toml" { + value "exclude" "vendor,dist" + } + expect { + list "exclude" "vendor" "dist" "target" + } +} + +vector "union-includes-the-declared-default" doc="The declared default is the bottom layer, so a union adds to it rather than replacing it." { + setting "exclude" type="list" merge="union" parse="list_by_comma" { + default "target" + } + layer "file" id="hk.toml" { + value "exclude" "vendor" + } + expect { + list "exclude" "target" "vendor" + } +} + +vector "union-of-lists-keeps-a-repeat" doc="A list keeps duplicates — that is what makes it a list rather than a set — so an item two layers both name appears twice." { + setting "exclude" type="list" merge="union" parse="list_by_comma" + layer "env" id="EX_EXCLUDE" { + value "exclude" "target,extra" + } + layer "file" id="hk.toml" { + value "exclude" "target,vendor" + } + expect { + list "exclude" "target" "vendor" "target" "extra" + } +} + +vector "a-set-drops-repeats-within-one-layer" doc="A `set` drops duplicates, within one layer's value as much as across two." { + setting "tags" type="set" merge="union" parse="list_by_comma" + layer "env" id="EX_TAGS" { + value "tags" "a,b,a" + } + expect { + list "tags" "a" "b" + } +} + +vector "deep-merges-a-table-key-by-key" doc="A `deep` map takes every layer's keys, with the higher layer winning a key they share." { + setting "urls" type="map" merge="deep" + layer "env" id="EX_URLS" { + shaped "urls" { + value "github" "https://github.com/" + } + } + layer "file" id="hk.toml" { + shaped "urls" { + value "github" "git@github.com:" + value "gitlab" "https://gitlab.com/" + } + } + expect { + map "urls" { + value "github" "https://github.com/" + value "gitlab" "https://gitlab.com/" + } + } +} + +vector "an-emptied-list-clears-a-default" doc="An empty value is a value: it is how a user turns a declared default off, and a union has nothing left to add to." { + setting "exclude" type="list" merge="union" parse="list_by_comma" { + default "target" + } + layer "env" id="EX_EXCLUDE" { + value "exclude" "" + } + expect { + list "exclude" + } +} + +vector "union-of-sets-drops-a-repeat" doc="The same two layers under a `set`: the item appears once, keeping the position the lower layer gave it." { + setting "tags" type="set" merge="union" parse="list_by_comma" + layer "env" id="EX_TAGS" { + value "tags" "a,extra" + } + layer "file" id="hk.toml" { + value "tags" "a,b" + } + expect { + list "tags" "a" "b" "extra" + } +} diff --git a/corpus/config/03-scope.kdl b/corpus/config/03-scope.kdl new file mode 100644 index 000000000..9ac9bc403 --- /dev/null +++ b/corpus/config/03-scope.kdl @@ -0,0 +1,78 @@ +section "scope" +about "Which places may set which settings. mise treats this as a security property rather than a preference: a setting that decides whether code runs must not be settable by a file a pull request can add. The question the merge asks is how far a place is trusted, not what kind of place it is — a pkl file and a git config inside a checkout are exactly as much a thing a repository carries as a TOML file is." + +vector "global-refuses-a-project-file" doc="A `global` setting is not settable by anything a checkout can carry, and the refusal costs that value alone." { + setting "trusted" type="bool" scope="global" default=#false + setting "jobs" type="uint" + layer "file" id="./hk.toml" trust="project" { + value "trusted" "true" + value "jobs" "2" + } + expect { + value "trusted" #false + value "jobs" 2 + origin "trusted" "the default" + warning "out-of-scope" + } +} + +vector "global-takes-an-operators-file" doc="The same setting from a file only the machine's owner can write is allowed." { + setting "trusted" type="bool" scope="global" default=#false + layer "file" id="~/.config/hk.toml" trust="operator" { + value "trusted" "true" + } + expect { + value "trusted" #true + } +} + +vector "global-takes-the-environment" doc="The environment is the user's own, so it may set a `global` setting." { + setting "trusted" type="bool" scope="global" default=#false + layer "env" id="EX_TRUSTED" { + value "trusted" "yes" + } + expect { + value "trusted" #true + } +} + +vector "env-scope-refuses-any-file" doc="An `env` setting is never settable by a file, however much that file is trusted." { + setting "config_file" type="path" scope="env" + layer "file" id="~/.config/hk.toml" trust="operator" { + value "config_file" "/etc/other.toml" + } + expect { + warning "out-of-scope" + } +} + +vector "env-scope-takes-the-environment" doc="Which is the one place it does come from." { + setting "config_file" type="path" scope="env" + layer "env" id="EX_CONFIG_FILE" { + value "config_file" "/etc/other.toml" + } + expect { + value "config_file" "/etc/other.toml" + } +} + +vector "a-source-usage-does-not-know-is-not-trusted-by-default" doc="A kind usage has no name for — a git config, a pkl file — is taken to be something a repository could carry until its layer says otherwise, because a check each new layer has to remember is one a new layer will forget." { + setting "trusted" type="bool" scope="global" default=#false + layer "git" id="hk.trusted" { + value "trusted" "true" + } + expect { + value "trusted" #false + warning "out-of-scope" + } +} + +vector "a-source-usage-does-not-know-can-say-it-is-trusted" doc="And says so itself when it is: a git config read from the user's home is theirs, not a checkout's." { + setting "trusted" type="bool" scope="global" default=#false + layer "git" id="~/.gitconfig hk.trusted" trust="operator" { + value "trusted" "true" + } + expect { + value "trusted" #true + } +} diff --git a/corpus/config/04-renames.kdl b/corpus/config/04-renames.kdl new file mode 100644 index 000000000..c5a71cd0d --- /dev/null +++ b/corpus/config/04-renames.kdl @@ -0,0 +1,80 @@ +section "renames" +about "Living through a rename. An old name keeps working — an upgrade that silently changed what a machine's config meant would be worse than the rename — and the user is told, once, what to write instead. The old name is not a second setting: it has no value of its own, and every read of it answers about the one that replaced it." + +vector "an-old-name-sets-the-new-one" doc="A value written under a name that has been renamed lands on the setting that replaced it." { + setting "jobs" type="uint" default=4 + setting "concurrency" type="uint" renamed-to="jobs" + layer "file" id="hk.toml" { + value "concurrency" "8" + } + expect { + value "jobs" 8 + warning "renamed" + } +} + +vector "a-deprecated-old-name-says-why" doc="With a notice on it, the user is told what to write instead — and still told what it was read as." { + setting "jobs" type="uint" default=4 + setting "concurrency" type="uint" renamed-to="jobs" deprecated="Use jobs instead." + layer "file" id="hk.toml" { + value "concurrency" "8" + } + expect { + value "jobs" 8 + warning "deprecated" + warning "renamed" + } +} + +vector "the-new-name-beats-the-old-one-in-a-lower-layer" doc="Precedence is unchanged by a rename: the higher layer wins whichever name it used." { + setting "jobs" type="uint" + setting "concurrency" type="uint" renamed-to="jobs" + layer "env" id="EX_JOBS" { + value "jobs" "6" + } + layer "file" id="hk.toml" { + value "concurrency" "8" + } + expect { + value "jobs" 6 + warning "renamed" + } +} + +vector "an-old-name-in-a-higher-layer-still-wins" doc="And the other way round, because the fold happens before precedence rather than instead of it." { + setting "jobs" type="uint" + setting "concurrency" type="uint" renamed-to="jobs" + layer "env" id="EX_CONCURRENCY" { + value "concurrency" "8" + } + layer "file" id="hk.toml" { + value "jobs" "6" + } + expect { + value "jobs" 8 + warning "renamed" + } +} + +vector "an-old-name-is-deprecated-without-being-renamed" doc="A setting can be on its way out without having a replacement: the value applies and the notice is given." { + setting "old_jobs" type="uint" deprecated="This will be removed." + layer "file" id="hk.toml" { + value "old_jobs" "3" + } + expect { + value "old_jobs" 3 + warning "deprecated" + } +} + +vector "an-old-names-scope-is-the-new-ones" doc="A refusal is reported under the name the user wrote, since that is the one in the file they would go and edit — but the rule applied is the replacement's." { + setting "trusted" type="bool" scope="global" default=#false + setting "old_trusted" type="bool" renamed-to="trusted" + layer "file" id="./hk.toml" trust="project" { + value "old_trusted" "true" + } + expect { + value "trusted" #false + warning "out-of-scope" + } +} diff --git a/corpus/config/05-types.kdl b/corpus/config/05-types.kdl new file mode 100644 index 000000000..4538ff406 --- /dev/null +++ b/corpus/config/05-types.kdl @@ -0,0 +1,129 @@ +section "types" +about "What a declared type does to a value on its way in. Every layer that reads text — an 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 list of one. A value the type cannot read costs its own key and nothing else: a typo in a system-wide file must not stop a CLI from starting for every user on the machine." + +vector "text-becomes-the-declared-type" doc="The same text reads as three different values under three different declarations." { + setting "jobs" type="uint" + setting "name" type="string" + setting "ratio" type="float" + layer "env" { + value "jobs" "8" + value "name" "8" + value "ratio" "8" + } + expect { + value "jobs" 8 + value "name" "8" + value "ratio" 8.0 + } +} + +vector "the-boolean-spellings" doc="The spellings every registry in the fleet accepts. Deliberately not `anything non-empty is true`: `FOO=false` meaning true is the kind of surprise a config system exists to prevent." { + setting "a" type="bool" + setting "b" type="bool" + setting "c" type="bool" + setting "d" type="bool" + setting "e" type="bool" + setting "f" type="bool" + layer "env" { + value "a" "true" + value "b" "1" + value "c" "yes" + value "d" "false" + value "e" "0" + value "f" "off" + } + expect { + value "a" #true + value "b" #true + value "c" #true + value "d" #false + value "e" #false + value "f" #false + } +} + +vector "one-value-for-a-list-is-a-list-of-one" doc="A list-typed setting given a bare value means a list of one, which is what every registry in the fleet relies on for `MISE_ENV=production`." { + setting "env" type="list" + layer "env" id="MISE_ENV" { + value "env" "production" + } + expect { + list "env" "production" + } +} + +vector "an-empty-string-is-no-items" doc="And an empty string is no items rather than one empty item, which is what turning a declared default off relies on." { + setting "env" type="list" { + default "dev" + } + layer "env" id="MISE_ENV" { + value "env" "" + } + expect { + list "env" + } +} + +vector "a-named-parser-splits-before-the-type-reads" doc="A declared parser turns one string into several values, so no layer has to know that a setting is comma-separated." { + setting "exclude" type="list" parse="list_by_comma" + setting "path" type="list" parse="list_by_colon" + layer "env" { + value "exclude" "target,vendor" + value "path" "/usr/bin:/bin" + } + expect { + list "exclude" "target" "vendor" + list "path" "/usr/bin" "/bin" + } +} + +vector "a-value-the-type-cannot-read-costs-its-own-key" doc="The setting keeps the value it had, everything else in the layer applies, and the user is told." { + setting "jobs" type="uint" default=4 + setting "name" type="string" + layer "file" id="hk.toml" { + value "jobs" "lots" + value "name" "hk" + } + expect { + value "jobs" 4 + value "name" "hk" + origin "jobs" "the default" + warning "wrong-type" + } +} + +vector "a-negative-number-is-not-a-uint" doc="`uint` is what a setting says when a value below zero is not one of its values." { + setting "jobs" type="uint" + layer "env" { + value "jobs" "-1" + } + expect { + warning "wrong-type" + } +} + +vector "a-table-where-a-scalar-was-declared-is-refused" doc="A structured value out of a file is held to the declared type too: rendering a table as text would give a value nobody wrote." { + setting "name" type="string" + layer "file" id="hk.toml" { + shaped "name" { + value "a" "b" + } + } + expect { + warning "wrong-type" + } +} + +vector "a-type-only-the-tool-understands-takes-what-it-is-given" doc="A union, or a name usage does not know, coerces nothing: the spec has said usage cannot decide what belongs there." { + setting "either" type="bool|string" + layer "file" id="hk.toml" { + shaped "either" { + value "a" "b" + } + } + expect { + map "either" { + value "a" "b" + } + } +} diff --git a/corpus/config/06-choices.kdl b/corpus/config/06-choices.kdl new file mode 100644 index 000000000..a767117ac --- /dev/null +++ b/corpus/config/06-choices.kdl @@ -0,0 +1,107 @@ +section "choices" +about "A setting that names the values it takes. The declaration reaches the docs, the schema and the completions, and it has to reach resolution too, or a CLI documents three values and accepts a fourth in silence. A refused value costs its own key, like a value of the wrong type, and the message says what the setting will take." + +vector "a-declared-choice-is-taken" doc="One of the values a setting names is simply its value." { + setting "stash" type="string" default="git" { + choice "git" + choice "patch-file" + choice "none" + } + layer "env" id="HK_STASH" { + value "stash" "none" + } + expect { + value "stash" "none" + } +} + +vector "anything-else-is-refused" doc="And a value it does not name is refused, leaving the value it had." { + setting "stash" type="string" default="git" { + choice "git" + choice "patch-file" + choice "none" + } + layer "env" id="HK_STASH" { + value "stash" "svn" + } + expect { + value "stash" "git" + origin "stash" "the default" + warning "not-allowed" + } +} + +vector "a-collection-is-held-to-them-item-by-item" doc="Choices on a list mean each item is one of them — the rule the JSON schema follows, where the enum goes on every value position rather than on the container." { + setting "skip" type="list" parse="list_by_comma" { + choice "lint" + choice "test" + } + layer "env" id="HK_SKIP" { + value "skip" "lint,fmt" + } + expect { + warning "not-allowed" + } +} + +vector "every-item-being-one-of-them-is-allowed" doc="The same setting with items it names." { + setting "skip" type="list" parse="list_by_comma" { + choice "lint" + choice "test" + } + layer "env" id="HK_SKIP" { + value "skip" "test,lint" + } + expect { + list "skip" "test" "lint" + } +} + +vector "a-choice-is-read-as-the-declared-type" doc="A spec writes `choice \"yes\"` under a boolean as readily as `choice #true`, and a value is coerced before it is compared — so the two have to be read the same way." { + setting "colour" type="bool" { + choice "yes" + choice "no" + } + layer "env" id="HK_COLOUR" { + value "colour" "true" + } + expect { + value "colour" #true + } +} + +vector "a-type-nothing-coerces-compares-its-choices-as-written" doc="A union coerces nothing, so a choice and a value can only be compared as they are written." { + setting "level" type="int|string" { + choice 1 + choice 2 + } + layer "env" id="HK_LEVEL" { + value "level" "2" + } + expect { + value "level" "2" + } +} + +vector "the-type-is-asked-before-the-choices" doc="A value that is not the declared type at all is reported as that, because there is nothing useful to say about which choice it is not." { + setting "jobs" type="uint" { + choice 1 + choice 2 + } + layer "env" { + value "jobs" "lots" + } + expect { + warning "wrong-type" + } +} + +vector "a-setting-with-no-choices-takes-what-its-type-takes" doc="Most settings name no values, and the check costs them nothing and refuses them nothing." { + setting "name" type="string" + layer "env" { + value "name" "anything at all" + } + expect { + value "name" "anything at all" + } +} diff --git a/corpus/config/README.md b/corpus/config/README.md new file mode 100644 index 000000000..b42e99f6d --- /dev/null +++ b/corpus/config/README.md @@ -0,0 +1,113 @@ +# The config conformance corpus + +Test vectors for configuration resolution. Each one pairs a set of settings with +the layers that supply values, and the resolution the two must produce. + +The [`config` block](https://usage.jdx.dev/spec/reference/config) is how a spec +_declares_ settings. What a resolution does with them has no prose page yet, so +until it has one these vectors are the definition — which is the wrong way round, +and the page is worth writing. + +The vectors are KDL because KDL is usage's canonical format. If you are writing a +usage config resolver in another language, this directory is the definition of +correct; consuming usage's format is part of implementing compatibility. + +## Why a registry, not a spec + +An argv vector carries a KDL spec, because parsing a command line is a question +about a spec. A resolution is not: it is a question about a **registry** — keys, +types, defaults, merge policies — which a CLI's build step produces from its spec +long before anything is resolved. + +So a vector describes the registry directly in KDL. How a spec becomes a registry +is `usage-config-build`'s question, and its own tests answer it. + +## Format + +One file per area of resolution, each with a `section`, an `about`, and `vector` +nodes: + +```kdl +vector "cli-beats-env-beats-file" doc="The declared order decides." { + setting "jobs" type="uint" default=4 + layer "cli" id="--jobs" { + value "jobs" "8" + } + layer "env" id="EX_JOBS" { + value "jobs" "6" + } + layer "file" id="hk.toml" { + value "jobs" "2" + } + expect { + value "jobs" 8 + origin "jobs" "--jobs" + } +} +``` + +| field | meaning | +| --------- | --------------------------------------------------------------------------------------------- | +| `id` | unique across the corpus, and stable — reports quote it | +| `doc` | what this vector pins down, in one sentence | +| `setting` | the registry: what exists, and what each setting says about itself | +| `layer` | what supplies values, **highest precedence first**; absent means none, so only defaults apply | +| `expect` | the values, where they came from, and what the resolution had to say | + +### `settings` + +`key` and `type` are the whole of most of them. `type` is the spec's own grammar: +`bool`, `int`, `uint`, `float`, `string`, `path`, `url`, `duration`, `object`, +`list`, `set`, `map`, `option`, and `any` — which is also what +a union like `bool|string` means here, since a union is precisely the spec saying +that nothing is decided. + +The rest are what the setting declares about itself: `default`, `merge` +(`replace`, `union`, `deep`), `scope` (`any`, `global`, `env`), `parse` (a named +splitter such as `list_by_comma`), `choice`, `renamed-to`, `deprecated`. Scalar +defaults are properties; list defaults and choices are child nodes. + +### `layers` + +A layer is described by what it _supplies_, not by where it read it — nothing in +this corpus opens a file or reads an environment, so no vector's result can depend +on the machine running it. + +- `source` — `cli`, `env`, `file`, or a name only the tool knows, like `git`. +- `id` — what a report calls it: the variable's name, the file's path. +- `trust` — `invocation`, `operator`, or `project`, which is what the scope rules + read. Left out, it is what the kind implies: the command line and the environment + are the user's own, and anything else is taken to be something a repository could + carry until it says otherwise. +- `value` — a key and text, the way a layer that reads an environment hands it over. +- `shaped` — a key and a value with a shape of its own, the way a layer that reads + TOML or JSON hands them over. + +### `expect` + +`value`, `list`, and `map` nodes are the whole resolution: a setting the vector +leaves out is expected to have no value at all, so a vector says what it means +rather than only what it is interested in. `origin` is checked where a vector names +a key and ignored where it does not, since most vectors are not about where a value +came from. + +`warning` names the _kinds_ of thing the resolution had to say, in order — +`unknown-setting`, `wrong-type`, `not-allowed`, `out-of-scope`, `deprecated`, +`renamed`, `not-read`. Kinds and never messages: wording is a +quality-of-implementation concern and is expected to differ between +implementations, which is the same line the argv corpus holds for its error codes. + +## No divergence field + +The argv corpus records where usage-lib disagrees with the grammar, because the +grammar predates it and has two implementations. Resolution has one, and a +disagreement would be a bug to fix rather than a fact to record. If you implement +this elsewhere, every vector applies to you: there is no layer of a resolution that +can be left to somebody else, the way a binding-only argv parser can leave +`required` to the layer above it. + +## Running them + +```sh +cargo test -p usage-conformance --test config +```