diff --git a/Cargo.lock b/Cargo.lock index 733e73731c4..5235c0b64de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2838,6 +2838,18 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "serde", + "uncased", + "version_check", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -3556,6 +3568,11 @@ dependencies = [ name = "hash-config" version = "0.0.0" dependencies = [ + "error-stack", + "figment", + "serde", + "serde_core", + "serde_json", "simple-mermaid", ] @@ -10844,6 +10861,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index a05077f3adf..25b912d2fcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ ena = { version = "0.14.3", default-features = fa enum-iterator = { version = "2.1.0", default-features = false } enumflags2 = { version = "0.7.12", default-features = false } expect-test = { version = "1.5.1", default-features = false } +figment = { version = "0.10.19", default-features = false } foldhash = { version = "0.2.0", default-features = false } frunk = { version = "0.4.4", default-features = false } frunk_core = { version = "0.4.4", default-features = false } diff --git a/libs/@local/config/Cargo.toml b/libs/@local/config/Cargo.toml index 3aab908cc90..0405870c4ae 100644 --- a/libs/@local/config/Cargo.toml +++ b/libs/@local/config/Cargo.toml @@ -7,8 +7,24 @@ publish.workspace = true version.workspace = true [dependencies] +# Public workspace dependencies +error-stack = { workspace = true, public = true, features = ["std"] } + +# Public third-party dependencies +serde_core = { workspace = true, public = true } + # Private third-party dependencies +figment = { workspace = true } simple-mermaid = { workspace = true } +[dev-dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[[example]] +doc-scrape-examples = true +name = "defaults" +test = true + [lints] workspace = true diff --git a/libs/@local/config/docs/dependency-diagram.mmd b/libs/@local/config/docs/dependency-diagram.mmd index 40c3712b03f..d703870ca86 100644 --- a/libs/@local/config/docs/dependency-diagram.mmd +++ b/libs/@local/config/docs/dependency-diagram.mmd @@ -10,3 +10,5 @@ graph TD %% ---> : Build dependency 0[hash-config] class 0 root + 1[error-stack] + 0 --> 1 diff --git a/libs/@local/config/examples/defaults.rs b/libs/@local/config/examples/defaults.rs new file mode 100644 index 00000000000..15eaccd3386 --- /dev/null +++ b/libs/@local/config/examples/defaults.rs @@ -0,0 +1,96 @@ +#![expect(clippy::print_stdout, clippy::use_debug)] +//! Builds a store configuration from two layers of programmatic defaults. + +use error_stack::Report; +use hash_config::{LoadError, Loader}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Deserialize)] +struct Config { + store: Store, + routes: Vec, +} + +#[derive(Debug, Deserialize)] +struct Store { + host: String, + port: u16, +} + +#[derive(Serialize)] +struct StoreDefaults { + host: &'static str, + port: u16, +} + +#[derive(Serialize)] +struct Defaults { + store: StoreDefaults, + routes: [&'static str; 2], +} + +/// The values the binary ships with. +const SHIPPED: Defaults = Defaults { + store: StoreDefaults { + host: "localhost", + port: 5432, + }, + routes: ["api", "health"], +}; + +/// A deployment moves the store to another port and leaves the rest alone. +fn deployed() -> Result> { + Loader::new() + .with_defaults(SHIPPED) + .with_defaults(json!({ "store": { "port": 6543 } })) + .load() +} + +/// A deployment sets the port to a password by mistake. +fn misconfigured() -> Result> { + Loader::new() + .with_defaults(SHIPPED) + .with_defaults(json!({ "store": { "port": "hunter2" } })) + .load() +} + +fn main() { + let config = deployed().expect("the deployed defaults should load"); + println!( + "{}:{} serving {:?}", + config.store.host, config.store.port, config.routes + ); + + let report = misconfigured().expect_err("a password should not load as a port"); + println!("\n{report:?}"); +} + +#[test] +fn deployment_overrides_shipped_port() { + let config = deployed().expect("the deployed defaults should load"); + + assert_eq!( + config.store.host, "localhost", + "the shipped host should survive the deployment layer" + ); + assert_eq!( + config.store.port, 6543, + "the deployment layer should replace the shipped port" + ); +} + +#[test] +fn report_names_key_not_password() { + let report = misconfigured().expect_err("a password should not load as a port"); + let rendered = format!("{report:?}"); + + assert!( + rendered.contains("store.port"), + "the report should name the key: {report:?}" + ); + assert!( + !rendered.contains("hunter2"), + "the report should omit the value: {report:?}" + ); +} diff --git a/libs/@local/config/package.json b/libs/@local/config/package.json index 7ee0f892473..81a0f523343 100644 --- a/libs/@local/config/package.json +++ b/libs/@local/config/package.json @@ -8,5 +8,8 @@ "fix:clippy": "just clippy --fix", "lint:clippy": "just clippy", "test:unit": "mise run test:unit @rust/hash-config" + }, + "dependencies": { + "@rust/error-stack": "workspace:*" } } diff --git a/libs/@local/config/src/defaults.rs b/libs/@local/config/src/defaults.rs new file mode 100644 index 00000000000..e9ab87a2807 --- /dev/null +++ b/libs/@local/config/src/defaults.rs @@ -0,0 +1,34 @@ +use figment::{ + Metadata, Profile, Provider, + error::Error as FigmentError, + providers::Serialized, + value::{Dict, Map}, +}; +use serde_core::Serialize; + +/// The programmatic default layer. +pub(crate) struct Defaults(Serialized); + +impl Defaults { + #[track_caller] + pub(crate) fn new(values: T) -> Self { + Self(Serialized::defaults(values)) + } +} + +impl Provider for Defaults +where + T: Serialize, +{ + fn metadata(&self) -> Metadata { + let mut metadata = self.0.metadata(); + // `Serialized` names itself after the Rust type it was handed. + metadata.name = "defaults".into(); + // Figment's default notation prefixes the profile a key was found under. + metadata.interpolater(|_profile, keys| keys.join(".")) + } + + fn data(&self) -> Result, FigmentError> { + self.0.data() + } +} diff --git a/libs/@local/config/src/error.rs b/libs/@local/config/src/error.rs new file mode 100644 index 00000000000..1f371d833ad --- /dev/null +++ b/libs/@local/config/src/error.rs @@ -0,0 +1,302 @@ +use core::{error::Error, fmt}; + +use error_stack::Report; +use figment::{ + Profile, + error::{Actual, Error as FigmentError, Kind as FigmentKind}, +}; + +/// What prevented a configuration from loading. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum LoadError { + /// The merged values do not deserialize into the requested configuration type. + Invalid, +} + +impl fmt::Display for LoadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid => formatter.write_str("the configuration could not be loaded"), + } + } +} + +impl Error for LoadError {} + +#[derive(Debug)] +struct LoadDiagnostic { + provider: Option, + location: Option, + profile: Option>, + path: Option>, + kind: LoadDiagnosticKind, +} + +impl From for LoadDiagnostic { + fn from(error: FigmentError) -> Self { + // Each provider renders paths in the notation of its own source, so a file layer reports + // `store.port` where an environment layer reports the variable it read. + let path = (!error.path.is_empty()).then(|| { + match (&error.metadata, &error.profile) { + (Some(metadata), Some(profile)) => metadata.interpolate(profile, &error.path), + _ => error.path.join("."), + } + .into_boxed_str() + }); + + // A missing field is reported against its parent, so the two join into the whole key. + let (kind, path) = match (LoadDiagnosticKind::from(error.kind), path) { + (LoadDiagnosticKind::MissingField(field), Some(parent)) => ( + LoadDiagnosticKind::MissingField(format!("{parent}.{field}").into_boxed_str()), + None, + ), + (kind, path) => (kind, path), + }; + + // Every key sits under the default profile until one is selected, which makes naming it + // noise rather than information. + let profile = error + .profile + .filter(|profile| *profile != Profile::Default) + .map(|profile| profile.to_string().into_boxed_str()); + + let (provider, location) = error.metadata.map_or((None, None), |metadata| { + ( + Some(metadata.name.into_owned()), + metadata.source.map(|source| source.to_string()), + ) + }); + + Self { + provider, + location, + profile, + path, + kind, + } + } +} + +impl fmt::Display for LoadDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind.fmt(formatter)?; + + if let Some(path) = &self.path { + write!(formatter, " at `{path}`")?; + } + + if let Some(provider) = &self.provider { + write!(formatter, " in `{provider}`")?; + } + + if let Some(profile) = &self.profile { + write!(formatter, " under profile `{profile}`")?; + } + + if let Some(location) = &self.location { + write!(formatter, " ({location})")?; + } + + Ok(()) + } +} + +#[derive(Debug)] +enum LoadDiagnosticKind { + Opaque, + InvalidType { + actual: &'static str, + expected: Box, + }, + InvalidValue { + actual: &'static str, + expected: Box, + }, + InvalidLength { + expected: Box, + }, + UnknownVariant { + expected: &'static [&'static str], + }, + UnknownField { + field: Box, + expected: &'static [&'static str], + }, + MissingField(Box), + DuplicateField(&'static str), + OutOfRange { + actual: &'static str, + }, + UnsupportedType(&'static str), + UnsupportedKeyType { + actual: &'static str, + expected: Box, + }, +} + +impl From for LoadDiagnosticKind { + fn from(kind: FigmentKind) -> Self { + match kind { + // A custom message is written by hand and may quote the value it rejected. + FigmentKind::Message(_) => Self::Opaque, + FigmentKind::InvalidType(actual, expected) => Self::InvalidType { + actual: actual_kind(&actual), + expected: expected.into_boxed_str(), + }, + FigmentKind::InvalidValue(actual, expected) => Self::InvalidValue { + actual: actual_kind(&actual), + expected: expected.into_boxed_str(), + }, + // The measured length is derived from the value it measured. + FigmentKind::InvalidLength(_, expected) => Self::InvalidLength { + expected: expected.into_boxed_str(), + }, + // A variant name is a configured value rather than a key. + FigmentKind::UnknownVariant(_, expected) => Self::UnknownVariant { expected }, + FigmentKind::UnknownField(field, expected) => Self::UnknownField { + field: field.into_boxed_str(), + expected, + }, + FigmentKind::MissingField(field) => Self::MissingField(field.into()), + FigmentKind::DuplicateField(field) => Self::DuplicateField(field), + FigmentKind::ISizeOutOfRange(_) => Self::OutOfRange { + actual: "signed integer", + }, + FigmentKind::USizeOutOfRange(_) => Self::OutOfRange { + actual: "unsigned integer", + }, + FigmentKind::Unsupported(actual) => Self::UnsupportedType(actual_kind(&actual)), + FigmentKind::UnsupportedKey(actual, expected) => Self::UnsupportedKeyType { + actual: actual_kind(&actual), + expected: expected.into(), + }, + } + } +} + +impl fmt::Display for LoadDiagnosticKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Opaque => formatter.write_str("the configuration data is invalid"), + Self::InvalidType { actual, expected } => { + write!( + formatter, + "invalid type: found {actual}, expected {expected}" + ) + } + Self::InvalidValue { actual, expected } => { + write!( + formatter, + "invalid value: found {actual}, expected {expected}" + ) + } + Self::InvalidLength { expected } => { + write!(formatter, "invalid length, expected {expected}") + } + Self::UnknownVariant { expected } => { + formatter.write_str("unknown variant, expected one of ")?; + write_expected(formatter, expected) + } + Self::UnknownField { field, expected } => { + write!(formatter, "unknown field `{field}`, expected one of ")?; + write_expected(formatter, expected) + } + Self::MissingField(field) => write!(formatter, "missing field `{field}`"), + Self::DuplicateField(field) => write!(formatter, "duplicate field `{field}`"), + Self::OutOfRange { actual } => write!(formatter, "{actual} is out of range"), + Self::UnsupportedType(actual) => { + write!(formatter, "unsupported type `{actual}`") + } + Self::UnsupportedKeyType { actual, expected } => { + write!( + formatter, + "unsupported key type `{actual}`, expected `{expected}`" + ) + } + } + } +} + +fn write_expected(formatter: &mut fmt::Formatter<'_>, expected: &[&str]) -> fmt::Result { + for (index, value) in expected.iter().enumerate() { + if index > 0 { + formatter.write_str(", ")?; + } + write!(formatter, "`{value}`")?; + } + Ok(()) +} + +const fn actual_kind(actual: &Actual) -> &'static str { + match actual { + Actual::Bool(_) => "boolean", + Actual::Unsigned(_) => "unsigned integer", + Actual::Signed(_) => "signed integer", + Actual::Float(_) => "floating-point number", + Actual::Char(_) => "character", + Actual::Str(_) => "string", + Actual::Bytes(_) => "bytes", + Actual::Unit => "unit", + Actual::Option => "option", + Actual::NewtypeStruct => "newtype struct", + Actual::Seq => "sequence", + Actual::Map => "map", + Actual::Enum => "enum", + Actual::UnitVariant => "unit variant", + Actual::NewtypeVariant => "newtype variant", + Actual::TupleVariant => "tuple variant", + Actual::StructVariant => "struct variant", + Actual::Other(_) => "other", + } +} + +#[track_caller] +pub(crate) fn load_report(error: FigmentError) -> Report { + let mut report = Report::new(LoadError::Invalid); + for error in error { + report = report.attach(LoadDiagnostic::from(error)); + } + report +} + +#[cfg(test)] +mod tests { + use figment::{Profile, error::Kind as FigmentKind}; + + use super::LoadDiagnostic; + + fn rendered(profile: Option) -> String { + let mut error = figment::Error::from(FigmentKind::MissingField("port".into())); + error.profile = profile; + + LoadDiagnostic::from(error).to_string() + } + + #[test] + fn diagnostic_names_selected_profile() { + assert_eq!( + rendered(Some(Profile::new("production"))), + "missing field `port` under profile `production`", + "a selected profile should reach the diagnostic" + ); + } + + #[test] + fn diagnostic_omits_default_profile() { + assert_eq!( + rendered(Some(Profile::Default)), + "missing field `port`", + "the default profile should not be named" + ); + } + + #[test] + fn diagnostic_omits_absent_profile() { + assert_eq!( + rendered(None), + "missing field `port`", + "an error without a profile should render without one" + ); + } +} diff --git a/libs/@local/config/src/lib.rs b/libs/@local/config/src/lib.rs index 6e5fea997aa..d81c5f26027 100644 --- a/libs/@local/config/src/lib.rs +++ b/libs/@local/config/src/lib.rs @@ -1,12 +1,93 @@ -//! Layered configuration for HASH binaries +//! Layered configuration for HASH binaries. //! //! # Workspace dependencies #![doc = simple_mermaid::mermaid!("../docs/dependency-diagram.mmd")] -#[cfg(test)] -mod tests { - #[test] - fn crate_uses_expected_package_name() { - assert_eq!(env!("CARGO_PKG_NAME"), "hash-config"); +mod defaults; +mod error; + +use core::fmt; + +use error_stack::Report; +use figment::Figment; +use serde_core::{Serialize, de::DeserializeOwned}; + +pub use self::error::LoadError; +use self::{defaults::Defaults, error::load_report}; + +/// Builds a configuration from layered sources. +/// +/// # Examples +/// +/// ``` +/// #[derive(serde::Deserialize)] +/// struct Config { +/// host: String, +/// } +/// +/// let config = hash_config::Loader::new() +/// .with_defaults(serde_json::json!({ "host": "localhost" })) +/// .load::()?; +/// +/// assert_eq!(config.host, "localhost"); +/// # Ok::<(), error_stack::Report>(()) +/// ``` +#[derive(Default)] +pub struct Loader { + defaults: Figment, +} + +impl fmt::Debug for Loader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Loader") + .field( + "layers", + &self + .defaults + .metadata() + .map(|metadata| &metadata.name) + .collect::>(), + ) + .finish_non_exhaustive() + } +} + +impl Loader { + /// Creates a loader with no configuration values. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds values to the programmatic default layer. + /// + /// Repeated calls are applied in order: later values replace earlier scalars and arrays, while + /// maps merge recursively. Each value must serialize to a map; serialization and shape errors + /// are reported by [`load`](Self::load) and name this call. + #[must_use] + #[track_caller] + pub fn with_defaults(mut self, values: impl Serialize) -> Self { + self.defaults = self.defaults.merge(Defaults::new(values)); + self + } + + /// Deserializes the merged values into `C`. + /// + /// # Errors + /// + /// Returns [`LoadError::Invalid`] when a value does not fit `C`, when a required value is not + /// set, or when a default does not serialize to a map. The report names the key, the shape + /// that was expected, and the layer the key came from. Configuration values never appear in a + /// report, so it is safe to log one in full. + #[track_caller] + pub fn load(self) -> Result> + where + C: DeserializeOwned, + { + match self.defaults.extract::() { + Ok(value) => Ok(value), + Err(error) => Err(load_report(error)), + } } } diff --git a/libs/@local/config/tests/defaults.rs b/libs/@local/config/tests/defaults.rs new file mode 100644 index 00000000000..8a151223346 --- /dev/null +++ b/libs/@local/config/tests/defaults.rs @@ -0,0 +1,387 @@ +use core::fmt; +use std::collections::HashMap; + +use hash_config::Loader; +use serde_json::json; + +const SECRET: &str = "this-value-must-not-appear-in-an-error"; + +#[derive(Debug, serde::Deserialize, PartialEq, Eq)] +struct Config { + store: Store, + routes: Vec, +} + +#[derive(Debug, serde::Deserialize, PartialEq, Eq)] +struct Store { + host: String, + port: u16, +} + +#[derive(serde::Serialize)] +struct Defaults { + store: StoreDefaults, + routes: Vec<&'static str>, +} + +#[derive(serde::Serialize)] +struct StoreDefaults { + host: &'static str, + port: u16, +} + +fn defaults() -> Defaults { + Defaults { + store: StoreDefaults { + host: "localhost", + port: 5432, + }, + routes: vec!["api", "health"], + } +} + +#[test] +fn programmatic_defaults_load() { + let config = Loader::new() + .with_defaults(defaults()) + .load::() + .expect("the complete defaults should load"); + + assert_eq!(config.store.host, "localhost"); + assert_eq!(config.store.port, 5432); + assert_eq!(config.routes, ["api", "health"]); +} + +#[test] +fn later_defaults_merge_maps_and_replace_arrays() { + let config = Loader::new() + .with_defaults(defaults()) + .with_defaults(json!({ + "store": { "port": 6543 }, + "routes": ["metrics"], + })) + .load::() + .expect("the composed defaults should load"); + + assert_eq!(config.store.host, "localhost"); + assert_eq!(config.store.port, 6543); + assert_eq!(config.routes, ["metrics"]); +} + +#[test] +fn missing_required_value_fails() { + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct Required { + password: String, + } + + let report = Loader::new() + .load::() + .expect_err("the missing password should fail the load"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + format!("{report:?}").contains("password"), + "the report should name the missing field: {report:?}" + ); +} + +#[test] +fn defaults_require_map() { + let report = Loader::new() + .with_defaults(SECRET) + .load::() + .expect_err("a scalar default document should fail the load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("invalid type: found string"), + "the report should identify the rejected value kind: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the rejected value: {report:?}" + ); +} + +#[test] +fn defaults_require_string_keys() { + let report = Loader::new() + .with_defaults(HashMap::from([(4096_u16, "value")])) + .load::() + .expect_err("a numeric map key should fail the load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("expected `string`"), + "the report should explain the supported key shape: {report:?}" + ); + assert!( + !rendered.contains("4096"), + "the report should redact the rejected key: {report:?}" + ); +} + +#[test] +fn load_redacts_mistyped_values() { + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct SecretConfig { + api_key: u16, + } + + let report = Loader::new() + .with_defaults(json!({ "api_key": SECRET })) + .load::() + .expect_err("a string API key should not deserialize as a number"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("invalid type: found string"), + "the report should identify the rejected value kind: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the rejected value: {report:?}" + ); +} + +#[test] +fn load_redacts_numeric_overflow() { + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct SmallConfig { + retries: i8, + } + + let report = Loader::new() + .with_defaults(json!({ "retries": 4096 })) + .load::() + .expect_err("an overflowing number should not deserialize as i8"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("invalid value: found unsigned integer"), + "the report should name the rejected value kind: {report:?}" + ); + assert!( + !rendered.contains("4096"), + "the report should redact the rejected number: {report:?}" + ); +} + +#[test] +fn load_redacts_unknown_variants() { + #[derive(Debug, serde::Deserialize)] + enum Level { + Debug, + Info, + } + + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct LevelConfig { + level: Level, + } + + let report = Loader::new() + .with_defaults(json!({ "level": SECRET })) + .load::() + .expect_err("an unknown variant name should fail the load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("`Debug`") && rendered.contains("`Info`"), + "the report should list the expected variants: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the rejected variant name: {report:?}" + ); +} + +#[test] +fn load_names_map_keys() { + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct MapConfig { + values: HashMap, + } + + let values = HashMap::from([("alpha", SECRET)]); + let report = Loader::new() + .with_defaults(json!({ "values": values })) + .load::() + .expect_err("a string map value should not deserialize as a number"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("values.alpha"), + "the report should name the key a map entry sits under: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the rejected value: {report:?}" + ); +} + +#[test] +fn load_names_unknown_fields() { + #[derive(Debug, serde::Deserialize)] + #[serde(deny_unknown_fields)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct StrictConfig { + enabled: bool, + } + + let values = HashMap::from([("enabled", json!(true)), ("enalbed", json!(false))]); + let report = Loader::new() + .with_defaults(values) + .load::() + .expect_err("an unknown field should fail the load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("`enalbed`"), + "the report should name the field it did not recognise: {report:?}" + ); + assert!( + rendered.contains("`enabled`"), + "the report should list the fields it accepts: {report:?}" + ); +} + +#[test] +fn load_reports_serde_expectations() { + const EXPECTATION: &str = "a port number the registry has not claimed"; + + #[derive(Debug)] + struct Bespoke; + + impl<'de> serde::Deserialize<'de> for Bespoke { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct BespokeVisitor; + + impl serde::de::Visitor<'_> for BespokeVisitor { + type Value = Bespoke; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(EXPECTATION) + } + + fn visit_u64(self, _value: u64) -> Result + where + E: serde::de::Error, + { + Ok(Bespoke) + } + } + + deserializer.deserialize_u64(BespokeVisitor) + } + } + + #[derive(Debug, serde::Deserialize)] + #[expect(dead_code, reason = "the field exists to make deserialization fail")] + struct ExpectedConfig { + value: Bespoke, + } + + let report = Loader::new() + .with_defaults(json!({ "value": SECRET })) + .load::() + .expect_err("a string should not deserialize as the expected integer"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains(EXPECTATION), + "the report should carry the visitor's expectation: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the rejected value: {report:?}" + ); +} + +#[test] +fn load_reports_every_failing_layer() { + let report = Loader::new() + .with_defaults(1_u16) + .with_defaults("text") + .load::() + .expect_err("both scalar default documents should fail the load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("found unsigned integer"), + "the report should name the first failing layer: {report:?}" + ); + assert!( + rendered.contains("found string"), + "the report should name the second failing layer: {report:?}" + ); +} + +#[test] +fn load_defers_and_redacts_serialization_errors() { + struct InvalidDefaults; + + impl serde::Serialize for InvalidDefaults { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(::custom(SECRET)) + } + } + + let loader = Loader::new().with_defaults(InvalidDefaults); + let report = loader + .load::() + .expect_err("the serialization failure should be reported by load"); + let rendered = format!("{report:?}"); + + assert_eq!(report.current_context(), &hash_config::LoadError::Invalid); + assert!( + rendered.contains("tests/defaults.rs"), + "the report should name the call site of the failing layer: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should redact the serializer message: {report:?}" + ); +} + +#[test] +fn error_names_key_not_value() { + let report = Loader::new() + .with_defaults(json!({ + "store": { "host": "localhost", "port": SECRET }, + "routes": [], + })) + .load::() + .expect_err("a string port should fail the load"); + let rendered = format!("{report:?}"); + + assert!( + rendered.contains("store.port"), + "the report should name the key: {report:?}" + ); + assert!( + !rendered.contains(SECRET), + "the report should omit the value: {report:?}" + ); +} diff --git a/yarn.lock b/yarn.lock index 512fa76e2dc..fa3125533c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14941,6 +14941,8 @@ __metadata: "@rust/hash-config@workspace:libs/@local/config": version: 0.0.0-use.local resolution: "@rust/hash-config@workspace:libs/@local/config" + dependencies: + "@rust/error-stack": "workspace:*" languageName: unknown linkType: soft