Skip to main content

config_kit/
builder.rs

1//! The typed builder and the layered load pipeline.
2
3use std::collections::BTreeMap;
4use std::marker::PhantomData;
5use std::path::Path;
6use std::path::PathBuf;
7
8use serde::de::DeserializeOwned;
9
10use crate::error::ConfigError;
11use crate::layer::ConfigLayer;
12
13/// Builder state: no layers added yet.
14///
15/// [`load`](ConfigBuilder::load) is deliberately not callable in this
16/// state — a load with zero layers is a compile-time type error, not a
17/// runtime failure.
18#[derive(Debug, Clone, Copy)]
19pub struct Empty;
20
21/// Builder state: at least one layer has been added, so the layer stack is
22/// meaningful and loading is available.
23#[derive(Debug, Clone, Copy)]
24pub struct Ready;
25
26/// Builder for layered, typed configuration.
27///
28/// The state parameter makes loading impossible until a layer exists:
29/// [`ConfigBuilder::new`] returns [`ConfigBuilder<Empty>`](ConfigBuilder),
30/// every [`layer`](Self::layer) call returns
31/// [`ConfigBuilder<Ready>`](ConfigBuilder), and only the ready state
32/// exposes [`load`](Self::load) / [`load_strict`](Self::load_strict).
33///
34/// # Precedence
35///
36/// Layers apply in the order added; later layers win on colliding keys,
37/// with tables merging key-by-key (deep merge). Driven in the documented
38/// order this gives **overrides > env > file**:
39///
40/// ```text
41/// ConfigBuilder::new()
42///     .layer(ConfigLayer::file("app.toml"))   // base
43///     .layer(ConfigLayer::env_prefix("APP_")) // deployment env wins over file
44///     .layer(ConfigLayer::overrides(over))    // in-process wins over both
45///     .load::<AppSettings>()?;
46/// ```
47///
48/// # Example
49///
50/// ```
51/// use config_kit::{ConfigBuilder, ConfigLayer};
52/// use serde::Deserialize;
53/// use std::collections::BTreeMap;
54///
55/// #[derive(Deserialize)]
56/// struct Settings {
57///     port: u16,
58///     debug: bool,
59/// }
60///
61/// std::env::set_var("CKIT_DOC_PORT", "8080");
62/// std::env::set_var("CKIT_DOC_DEBUG", "true");
63/// let mut over: BTreeMap<String, toml::Value> = BTreeMap::new();
64/// over.insert("port".into(), toml::Value::Integer(9090));
65///
66/// let settings: Settings = ConfigBuilder::new()
67///     .layer(ConfigLayer::env_prefix("CKIT_DOC_"))
68///     .layer(ConfigLayer::overrides(over))
69///     .load()?;
70///
71/// assert_eq!(settings.port, 9090); // overrides beat env
72/// assert!(settings.debug); // env value inferred as bool
73/// # Ok::<(), config_kit::ConfigError>(())
74/// ```
75#[derive(Debug, Clone)]
76pub struct ConfigBuilder<S> {
77    layers: Vec<ConfigLayer>,
78    _state: PhantomData<S>,
79}
80
81impl Default for ConfigBuilder<Empty> {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl ConfigBuilder<Empty> {
88    /// A builder with no layers. Add at least one
89    /// [`ConfigLayer`] to unlock loading.
90    #[must_use]
91    pub fn new() -> Self {
92        Self {
93            layers: Vec::new(),
94            _state: PhantomData,
95        }
96    }
97}
98
99impl<S> ConfigBuilder<S> {
100    /// Adds a layer and transitions the builder to the [`Ready`] state.
101    ///
102    /// Layers take effect in the order added; see the type-level
103    /// documentation for precedence and merge semantics.
104    #[must_use]
105    pub fn layer(mut self, layer: ConfigLayer) -> ConfigBuilder<Ready> {
106        self.layers.push(layer);
107        ConfigBuilder {
108            layers: self.layers,
109            _state: PhantomData,
110        }
111    }
112}
113
114/// Where a merged value came from — the attribution recorded as layers
115/// merge, so a parse error can name the layer that supplied the offending
116/// value.
117#[derive(Debug, Clone)]
118enum Origin {
119    /// A file layer, identified by its path.
120    File(PathBuf),
121    /// An environment layer, identified by the full variable name
122    /// (pre-strip, e.g. `APP_PORT`).
123    Env { var: String },
124    /// The in-process overrides layer.
125    Overrides,
126}
127
128impl Origin {
129    /// The location rendered into `ConfigError::Parse::path`: the file
130    /// path itself, an `<env:VARIABLE>` pseudo-path, or `<overrides>`.
131    fn error_path(&self) -> PathBuf {
132        match self {
133            Origin::File(path) => path.clone(),
134            Origin::Env { var } => PathBuf::from(format!("<env:{var}>")),
135            Origin::Overrides => PathBuf::from("<overrides>"),
136        }
137    }
138}
139
140/// The fully merged configuration plus per-value provenance for error
141/// reporting.
142struct Merged {
143    value: toml::Value,
144    /// Dotted path of every value a layer inserted or overwrote → the
145    /// layer that last wrote it. Deep merges record at the leaf /
146    /// replacement site, so attribution follows the winning layer.
147    provenance: BTreeMap<String, Origin>,
148    last_file: Option<PathBuf>,
149}
150
151impl ConfigBuilder<Ready> {
152    /// Deserializes the merged configuration into `T`.
153    ///
154    /// Unknown keys are ignored (use [`load_strict`](Self::load_strict) to
155    /// deny them); types must line up with `T`'s fields.
156    ///
157    /// On a type mismatch the error names the **origin of the offending
158    /// value** — the file that supplied it, `<env:VARIABLE>` for an
159    /// environment-supplied value, or `<overrides>` — not merely the last
160    /// file layer read.
161    ///
162    /// # Errors
163    ///
164    /// - [`ConfigError::Io`] when a file layer cannot be read,
165    /// - [`ConfigError::Parse`] when a file layer fails to parse, names a
166    ///   format whose feature is disabled, or when the merged values do
167    ///   not fit `T` (attributed to the offending value's layer, see
168    ///   above),
169    /// - [`ConfigError::EnvVar`] when a matching environment variable has
170    ///   a non-Unicode value.
171    pub fn load<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
172        let Merged {
173            value,
174            provenance,
175            last_file,
176        } = self.merged()?;
177        match serde_path_to_error::deserialize(value) {
178            Ok(value) => Ok(value),
179            Err(source) => {
180                let path = attribute_error_path(&provenance, &last_file, source.path());
181                Err(ConfigError::Parse {
182                    path,
183                    source: Box::new(source),
184                })
185            }
186        }
187    }
188
189    /// Strict load: fails with [`ConfigError::UnknownFields`] when any
190    /// **top-level** key of the merged configuration is not consumed by a
191    /// field of `T`.
192    ///
193    /// The precise, honest contract:
194    ///
195    /// - Detection is container-agnostic — it works for any `T:
196    ///   DeserializeOwned` by instrumenting deserialization with
197    ///   [`serde_ignored`] and reporting which top-level map keys no field
198    ///   visited. It is *not* `#[serde(deny_unknown_fields)]` semantics
199    ///   (that derive is per-struct; config-kit never sees your schema
200    ///   declaration sites).
201    /// - **Top level only.** Unknown keys nested inside tables (`[db]` →
202    ///   `rogue = 1`) are passed through to serde's normal handling and
203    ///   are not reported. Keys introduced by env or override layers are
204    ///   checked identically to file keys.
205    /// - Types using `#[serde(flatten)]` collect unknown keys into the
206    ///   flattened field before instrumentation can see them, so flattened
207    ///   content is exempt from this check.
208    /// - If `T` itself derives `deny_unknown_fields`, its own error wins
209    ///   and surfaces as [`ConfigError::Parse`].
210    ///
211    /// Like [`load`](Self::load), a deserialization mismatch is attributed
212    /// to the offending value's layer (file, `<env:VARIABLE>`, or
213    /// `<overrides>`).
214    ///
215    /// On success the deserialized value is returned directly — there is
216    /// no second parse pass.
217    ///
218    /// # Errors
219    ///
220    /// Everything [`load`](Self::load) reports, plus
221    /// [`ConfigError::UnknownFields`] when unconsumed top-level keys exist.
222    pub fn load_strict<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
223        let Merged {
224            value,
225            provenance,
226            last_file,
227        } = self.merged()?;
228        let mut unknown: Vec<String> = Vec::new();
229        let mut track = serde_path_to_error::Track::new();
230        let parsed = serde_ignored::deserialize(
231            serde_path_to_error::Deserializer::new(value, &mut track),
232            |path| {
233                if let serde_ignored::Path::Map { parent, key } = path {
234                    if matches!(*parent, serde_ignored::Path::Root) {
235                        unknown.push(key);
236                    }
237                }
238            },
239        );
240        let parsed = match parsed {
241            Ok(parsed) => parsed,
242            Err(source) => {
243                let path = attribute_error_path(&provenance, &last_file, &track.path());
244                return Err(ConfigError::Parse {
245                    path,
246                    source: Box::new(source),
247                });
248            }
249        };
250        if unknown.is_empty() {
251            return Ok(parsed);
252        }
253        unknown.sort_unstable();
254        unknown.dedup();
255        Err(ConfigError::UnknownFields { keys: unknown })
256    }
257
258    /// Walks the layers in order, merging each into the accumulator.
259    ///
260    /// Every inserted or replaced value is recorded in `provenance` with
261    /// the layer that wrote it, so parse errors attribute to the winning
262    /// layer rather than to "wherever we happened to be reading last".
263    fn merged(self) -> Result<Merged, ConfigError> {
264        let mut acc = toml::Value::Table(toml::Table::new());
265        let mut provenance = BTreeMap::new();
266        let mut last_file: Option<PathBuf> = None;
267        for layer in self.layers {
268            match layer {
269                ConfigLayer::File(path) => {
270                    let value = read_layer_value(&path)?;
271                    last_file = Some(path.clone());
272                    let origin = Origin::File(path);
273                    let mut from_root = Vec::new();
274                    merge_tracked(&mut acc, value, &origin, &mut from_root, &mut provenance);
275                }
276                ConfigLayer::EnvPrefix(prefix) => {
277                    let (table, sources) = crate::env::collect(&prefix)?;
278                    for (key, value) in table {
279                        let var = sources.get(&key).cloned().unwrap_or_default();
280                        let origin = Origin::Env { var };
281                        let mut wrapper = toml::Table::new();
282                        wrapper.insert(key, value);
283                        let mut from_root = Vec::new();
284                        merge_tracked(
285                            &mut acc,
286                            toml::Value::Table(wrapper),
287                            &origin,
288                            &mut from_root,
289                            &mut provenance,
290                        );
291                    }
292                }
293                ConfigLayer::Overrides(overrides) => {
294                    let mut table = toml::Table::new();
295                    for (key, value) in overrides {
296                        table.insert(key, value);
297                    }
298                    let origin = Origin::Overrides;
299                    let mut from_root = Vec::new();
300                    merge_tracked(
301                        &mut acc,
302                        toml::Value::Table(table),
303                        &origin,
304                        &mut from_root,
305                        &mut provenance,
306                    );
307                }
308            }
309        }
310        Ok(Merged {
311            value: acc,
312            provenance,
313            last_file,
314        })
315    }
316}
317
318/// The path attributed to a deserialization mismatch at `failing`: the
319/// origin of the value at (or nearest above) the failing key path, falling
320/// back to the most recently read file layer, or empty when no file layer
321/// contributed. Free-standing over `Merged`'s fields because `value` is
322/// consumed by the deserializer before the error exists.
323fn attribute_error_path(
324    provenance: &BTreeMap<String, Origin>,
325    last_file: &Option<PathBuf>,
326    failing: &serde_path_to_error::Path,
327) -> PathBuf {
328    let segments: Vec<String> = failing
329        .iter()
330        .map(|segment| match segment {
331            serde_path_to_error::Segment::Map { key } => key.clone(),
332            serde_path_to_error::Segment::Enum { variant } => variant.clone(),
333            serde_path_to_error::Segment::Seq { index } => index.to_string(),
334            serde_path_to_error::Segment::Unknown => String::from("?"),
335        })
336        .collect();
337    for end in (1..=segments.len()).rev() {
338        let Some(prefix) = segments.get(..end) else {
339            continue;
340        };
341        if let Some(origin) = provenance.get(&prefix.join(".")) {
342            return origin.error_path();
343        }
344    }
345    last_file.clone().unwrap_or_default()
346}
347
348/// Reads and parses one file layer.
349///
350/// Used by the builder and by the hot-reload watcher, so both paths share
351/// one format-detection contract.
352pub(crate) fn read_layer_value(path: &Path) -> Result<toml::Value, ConfigError> {
353    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
354        path: path.to_path_buf(),
355        source,
356    })?;
357    parse_by_extension(path, &text)
358}
359
360/// Parses `text` as the format named by `path`'s extension: `.json` and
361/// `.yaml`/`.yml` behind their features, everything else as TOML.
362fn parse_by_extension(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
363    let ext = path
364        .extension()
365        .and_then(|ext| ext.to_str())
366        .unwrap_or("toml");
367    match ext.to_ascii_lowercase().as_str() {
368        "json" => parse_json(path, text),
369        "yaml" | "yml" => parse_yaml(path, text),
370        _ => parse_toml(path, text),
371    }
372}
373
374/// The `Parse` error for a file that failed to parse.
375fn parse_error(
376    path: &Path,
377    source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
378) -> ConfigError {
379    ConfigError::Parse {
380        path: path.to_path_buf(),
381        source: source.into(),
382    }
383}
384
385fn parse_toml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
386    toml::from_str(text).map_err(|source| parse_error(path, source))
387}
388
389#[cfg(feature = "json")]
390fn parse_json(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
391    serde_json::from_str(text).map_err(|source| parse_error(path, source))
392}
393
394#[cfg(not(feature = "json"))]
395fn parse_json(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
396    Err(parse_error(
397        path,
398        "`json` config files require the `json` feature",
399    ))
400}
401
402#[cfg(feature = "yaml")]
403fn parse_yaml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
404    serde_yaml_ng::from_str(text).map_err(|source| parse_error(path, source))
405}
406
407#[cfg(not(feature = "yaml"))]
408fn parse_yaml(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
409    Err(parse_error(
410        path,
411        "`yaml` config files require the `yaml` feature",
412    ))
413}
414
415/// Deep-merges `over` into `base`: two tables merge key-by-key, anything
416/// else (including a table over a non-table) replaces outright.
417///
418/// Every insert or replacement is recorded in `provenance` under its
419/// dotted path (relative to the merge root, tracked via `from_root`) with
420/// the `origin` that performed it — last writer wins, matching which value
421/// actually survives the merge.
422fn merge_tracked(
423    base: &mut toml::Value,
424    over: toml::Value,
425    origin: &Origin,
426    from_root: &mut Vec<String>,
427    provenance: &mut BTreeMap<String, Origin>,
428) {
429    match (base, over) {
430        (toml::Value::Table(base_table), toml::Value::Table(over_table)) => {
431            for (key, value) in over_table {
432                match base_table.get_mut(&key) {
433                    Some(slot) => {
434                        from_root.push(key);
435                        merge_tracked(slot, value, origin, from_root, provenance);
436                        from_root.pop();
437                    }
438                    None => {
439                        from_root.push(key.clone());
440                        record_provenance(from_root, origin, provenance);
441                        base_table.insert(key, value);
442                        from_root.pop();
443                    }
444                }
445            }
446        }
447        (base, over) => {
448            if !from_root.is_empty() {
449                record_provenance(from_root, origin, provenance);
450            }
451            *base = over;
452        }
453    }
454}
455
456/// Attributes the value at `path` to `origin` — the last writer wins,
457/// because that writer's value is the one that survived.
458fn record_provenance(path: &[String], origin: &Origin, provenance: &mut BTreeMap<String, Origin>) {
459    provenance.insert(path.join("."), origin.clone());
460}
461
462#[cfg(test)]
463mod tests {
464    #![allow(clippy::unwrap_used, clippy::expect_used)]
465    use super::*;
466
467    #[test]
468    fn merge_tables_deep_and_replace_scalars() {
469        let mut base: toml::Value =
470            toml::from_str("flag = [1, 2]\n\n[db]\nurl = \"u\"\nport = 1\n").expect("base");
471        let over: toml::Value = toml::from_str("flag = 3\n\n[db]\nport = 2\n").expect("over");
472        let origin = Origin::File(PathBuf::from("over.toml"));
473        let mut provenance = BTreeMap::new();
474        merge_tracked(&mut base, over, &origin, &mut Vec::new(), &mut provenance);
475        let table = base.as_table().expect("table");
476        let db = table.get("db").and_then(toml::Value::as_table).expect("db");
477        assert_eq!(db.get("url"), Some(&toml::Value::String("u".into())));
478        assert_eq!(db.get("port"), Some(&toml::Value::Integer(2)));
479        assert_eq!(table.get("flag"), Some(&toml::Value::Integer(3)));
480        // Provenance: last writer wins per replaced value, untouched
481        // values keep their earlier origin (none here, so only the two
482        // writes are recorded — keyed by dotted path).
483        assert!(matches!(provenance.get("flag"), Some(Origin::File(_))));
484        assert!(matches!(provenance.get("db.port"), Some(Origin::File(_))));
485        assert!(
486            !provenance.contains_key("db.url"),
487            "unwritten values have no provenance"
488        );
489        assert!(
490            !provenance.contains_key("db"),
491            "table recursions don't record the parent"
492        );
493    }
494
495    #[test]
496    fn parse_by_extension_detects_formats() {
497        let toml_val = parse_by_extension(Path::new("a.toml"), "x = 1").expect("toml");
498        assert_eq!(toml_val.get("x"), Some(&toml::Value::Integer(1)));
499        let no_ext = parse_by_extension(Path::new("defaults"), "x = 1").expect("toml default");
500        assert_eq!(no_ext.get("x"), Some(&toml::Value::Integer(1)));
501        let upper = parse_by_extension(Path::new("b.TOML"), "x = 2").expect("case-insensitive");
502        assert_eq!(upper.get("x"), Some(&toml::Value::Integer(2)));
503    }
504
505    #[cfg(feature = "json")]
506    #[test]
507    fn parse_by_extension_json() {
508        let val = parse_by_extension(Path::new("a.json"), "{\"x\": 1}").expect("json");
509        assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
510    }
511
512    #[cfg(not(feature = "json"))]
513    #[test]
514    fn json_without_feature_is_parse_error() {
515        let err = parse_by_extension(Path::new("a.json"), "{}").expect_err("feature gate");
516        assert!(err.to_string().contains("`json` feature"));
517    }
518
519    #[cfg(feature = "yaml")]
520    #[test]
521    fn parse_by_extension_yaml() {
522        let val = parse_by_extension(Path::new("a.yaml"), "x: 1").expect("yaml");
523        assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
524        let val = parse_by_extension(Path::new("a.yml"), "x: 2").expect("yml");
525        assert_eq!(val.get("x"), Some(&toml::Value::Integer(2)));
526    }
527
528    #[cfg(not(feature = "yaml"))]
529    #[test]
530    fn yaml_without_feature_is_parse_error() {
531        let err = parse_by_extension(Path::new("a.yaml"), "x: 1").expect_err("feature gate");
532        assert!(err.to_string().contains("`yaml` feature"));
533    }
534}