Skip to main content

config_kit/
builder.rs

1//! The typed builder and the layered load pipeline.
2
3use std::marker::PhantomData;
4use std::path::Path;
5use std::path::PathBuf;
6
7use serde::de::DeserializeOwned;
8
9use crate::error::ConfigError;
10use crate::layer::ConfigLayer;
11
12/// Builder state: no layers added yet.
13///
14/// [`load`](ConfigBuilder::load) is deliberately not callable in this
15/// state — a load with zero layers is a compile-time type error, not a
16/// runtime failure.
17#[derive(Debug, Clone, Copy)]
18pub struct Empty;
19
20/// Builder state: at least one layer has been added, so the layer stack is
21/// meaningful and loading is available.
22#[derive(Debug, Clone, Copy)]
23pub struct Ready;
24
25/// Builder for layered, typed configuration.
26///
27/// The state parameter makes loading impossible until a layer exists:
28/// [`ConfigBuilder::new`] returns [`ConfigBuilder<Empty>`](ConfigBuilder),
29/// every [`layer`](Self::layer) call returns
30/// [`ConfigBuilder<Ready>`](ConfigBuilder), and only the ready state
31/// exposes [`load`](Self::load) / [`load_strict`](Self::load_strict).
32///
33/// # Precedence
34///
35/// Layers apply in the order added; later layers win on colliding keys,
36/// with tables merging key-by-key (deep merge). Driven in the documented
37/// order this gives **overrides > env > file**:
38///
39/// ```text
40/// ConfigBuilder::new()
41///     .layer(ConfigLayer::file("app.toml"))   // base
42///     .layer(ConfigLayer::env_prefix("APP_")) // deployment env wins over file
43///     .layer(ConfigLayer::overrides(over))    // in-process wins over both
44///     .load::<AppSettings>()?;
45/// ```
46///
47/// # Example
48///
49/// ```
50/// use config_kit::{ConfigBuilder, ConfigLayer};
51/// use serde::Deserialize;
52/// use std::collections::BTreeMap;
53///
54/// #[derive(Deserialize)]
55/// struct Settings {
56///     port: u16,
57///     debug: bool,
58/// }
59///
60/// std::env::set_var("CKIT_DOC_PORT", "8080");
61/// std::env::set_var("CKIT_DOC_DEBUG", "true");
62/// let mut over: BTreeMap<String, toml::Value> = BTreeMap::new();
63/// over.insert("port".into(), toml::Value::Integer(9090));
64///
65/// let settings: Settings = ConfigBuilder::new()
66///     .layer(ConfigLayer::env_prefix("CKIT_DOC_"))
67///     .layer(ConfigLayer::overrides(over))
68///     .load()?;
69///
70/// assert_eq!(settings.port, 9090); // overrides beat env
71/// assert!(settings.debug); // env value inferred as bool
72/// # Ok::<(), config_kit::ConfigError>(())
73/// ```
74#[derive(Debug, Clone)]
75pub struct ConfigBuilder<S> {
76    layers: Vec<ConfigLayer>,
77    _state: PhantomData<S>,
78}
79
80impl Default for ConfigBuilder<Empty> {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl ConfigBuilder<Empty> {
87    /// A builder with no layers. Add at least one
88    /// [`ConfigLayer`] to unlock loading.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            layers: Vec::new(),
93            _state: PhantomData,
94        }
95    }
96}
97
98impl<S> ConfigBuilder<S> {
99    /// Adds a layer and transitions the builder to the [`Ready`] state.
100    ///
101    /// Layers take effect in the order added; see the type-level
102    /// documentation for precedence and merge semantics.
103    #[must_use]
104    pub fn layer(mut self, layer: ConfigLayer) -> ConfigBuilder<Ready> {
105        self.layers.push(layer);
106        ConfigBuilder {
107            layers: self.layers,
108            _state: PhantomData,
109        }
110    }
111}
112
113/// The fully merged configuration plus provenance for error reporting.
114struct Merged {
115    value: toml::Value,
116    last_file: Option<PathBuf>,
117}
118
119impl Merged {
120    /// The path attributed to deserialization mismatches, if any file
121    /// layer was involved.
122    fn error_path(&self) -> PathBuf {
123        self.last_file.clone().unwrap_or_default()
124    }
125}
126
127impl ConfigBuilder<Ready> {
128    /// Deserializes the merged configuration into `T`.
129    ///
130    /// Unknown keys are ignored (use [`load_strict`](Self::load_strict) to
131    /// deny them); types must line up with `T`'s fields.
132    ///
133    /// # Errors
134    ///
135    /// - [`ConfigError::Io`] when a file layer cannot be read,
136    /// - [`ConfigError::Parse`] when a file layer fails to parse, names a
137    ///   format whose feature is disabled, or when the merged values do
138    ///   not fit `T`,
139    /// - [`ConfigError::EnvVar`] when a matching environment variable has
140    ///   a non-Unicode value.
141    pub fn load<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
142        let merged = self.merged()?;
143        let path = merged.error_path();
144        T::deserialize(merged.value).map_err(|source| ConfigError::Parse {
145            path,
146            source: Box::new(source),
147        })
148    }
149
150    /// Strict load: fails with [`ConfigError::UnknownFields`] when any
151    /// **top-level** key of the merged configuration is not consumed by a
152    /// field of `T`.
153    ///
154    /// The precise, honest contract:
155    ///
156    /// - Detection is container-agnostic — it works for any `T:
157    ///   DeserializeOwned` by instrumenting deserialization with
158    ///   [`serde_ignored`] and reporting which top-level map keys no field
159    ///   visited. It is *not* `#[serde(deny_unknown_fields)]` semantics
160    ///   (that derive is per-struct; config-kit never sees your schema
161    ///   declaration sites).
162    /// - **Top level only.** Unknown keys nested inside tables (`[db]` →
163    ///   `rogue = 1`) are passed through to serde's normal handling and
164    ///   are not reported. Keys introduced by env or override layers are
165    ///   checked identically to file keys.
166    /// - Types using `#[serde(flatten)]` collect unknown keys into the
167    ///   flattened field before instrumentation can see them, so flattened
168    ///   content is exempt from this check.
169    /// - If `T` itself derives `deny_unknown_fields`, its own error wins
170    ///   and surfaces as [`ConfigError::Parse`].
171    ///
172    /// On success the deserialized value is returned directly — there is
173    /// no second parse pass.
174    ///
175    /// # Errors
176    ///
177    /// Everything [`load`](Self::load) reports, plus
178    /// [`ConfigError::UnknownFields`] when unconsumed top-level keys exist.
179    pub fn load_strict<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
180        let merged = self.merged()?;
181        let path = merged.error_path();
182        let mut unknown: Vec<String> = Vec::new();
183        let parsed = serde_ignored::deserialize(merged.value, |path| {
184            if let serde_ignored::Path::Map { parent, key } = path {
185                if matches!(*parent, serde_ignored::Path::Root) {
186                    unknown.push(key);
187                }
188            }
189        })
190        .map_err(|source| ConfigError::Parse {
191            path,
192            source: Box::new(source),
193        })?;
194        if unknown.is_empty() {
195            return Ok(parsed);
196        }
197        unknown.sort_unstable();
198        unknown.dedup();
199        Err(ConfigError::UnknownFields { keys: unknown })
200    }
201
202    /// Walks the layers in order, merging each into the accumulator.
203    fn merged(self) -> Result<Merged, ConfigError> {
204        let mut acc = toml::Value::Table(toml::Table::new());
205        let mut last_file: Option<PathBuf> = None;
206        for layer in self.layers {
207            match layer {
208                ConfigLayer::File(path) => {
209                    let value = read_layer_value(&path)?;
210                    last_file = Some(path);
211                    merge(&mut acc, value);
212                }
213                ConfigLayer::EnvPrefix(prefix) => {
214                    merge(&mut acc, toml::Value::Table(crate::env::collect(&prefix)?));
215                }
216                ConfigLayer::Overrides(overrides) => {
217                    let mut table = toml::Table::new();
218                    for (key, value) in overrides {
219                        table.insert(key, value);
220                    }
221                    merge(&mut acc, toml::Value::Table(table));
222                }
223            }
224        }
225        Ok(Merged {
226            value: acc,
227            last_file,
228        })
229    }
230}
231
232/// Reads and parses one file layer.
233///
234/// Used by the builder and by the hot-reload watcher, so both paths share
235/// one format-detection contract.
236pub(crate) fn read_layer_value(path: &Path) -> Result<toml::Value, ConfigError> {
237    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
238        path: path.to_path_buf(),
239        source,
240    })?;
241    parse_by_extension(path, &text)
242}
243
244/// Parses `text` as the format named by `path`'s extension: `.json` and
245/// `.yaml`/`.yml` behind their features, everything else as TOML.
246fn parse_by_extension(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
247    let ext = path
248        .extension()
249        .and_then(|ext| ext.to_str())
250        .unwrap_or("toml");
251    match ext.to_ascii_lowercase().as_str() {
252        "json" => parse_json(path, text),
253        "yaml" | "yml" => parse_yaml(path, text),
254        _ => parse_toml(path, text),
255    }
256}
257
258/// The `Parse` error for a file that failed to parse.
259fn parse_error(
260    path: &Path,
261    source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
262) -> ConfigError {
263    ConfigError::Parse {
264        path: path.to_path_buf(),
265        source: source.into(),
266    }
267}
268
269fn parse_toml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
270    toml::from_str(text).map_err(|source| parse_error(path, source))
271}
272
273#[cfg(feature = "json")]
274fn parse_json(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
275    serde_json::from_str(text).map_err(|source| parse_error(path, source))
276}
277
278#[cfg(not(feature = "json"))]
279fn parse_json(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
280    Err(parse_error(
281        path,
282        "`json` config files require the `json` feature",
283    ))
284}
285
286#[cfg(feature = "yaml")]
287fn parse_yaml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
288    serde_yaml_ng::from_str(text).map_err(|source| parse_error(path, source))
289}
290
291#[cfg(not(feature = "yaml"))]
292fn parse_yaml(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
293    Err(parse_error(
294        path,
295        "`yaml` config files require the `yaml` feature",
296    ))
297}
298
299/// Deep-merges `over` into `base`: two tables merge key-by-key, anything
300/// else (including a table over a non-table) replaces outright.
301fn merge(base: &mut toml::Value, over: toml::Value) {
302    match (base, over) {
303        (toml::Value::Table(base_table), toml::Value::Table(over_table)) => {
304            for (key, value) in over_table {
305                match base_table.get_mut(&key) {
306                    Some(slot) => merge(slot, value),
307                    None => {
308                        base_table.insert(key, value);
309                    }
310                }
311            }
312        }
313        (base, over) => *base = over,
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    #![allow(clippy::unwrap_used, clippy::expect_used)]
320    use super::*;
321
322    #[test]
323    fn merge_tables_deep_and_replace_scalars() {
324        let mut base: toml::Value =
325            toml::from_str("flag = [1, 2]\n\n[db]\nurl = \"u\"\nport = 1\n").expect("base");
326        let over: toml::Value = toml::from_str("flag = 3\n\n[db]\nport = 2\n").expect("over");
327        merge(&mut base, over);
328        let table = base.as_table().expect("table");
329        let db = table.get("db").and_then(toml::Value::as_table).expect("db");
330        assert_eq!(db.get("url"), Some(&toml::Value::String("u".into())));
331        assert_eq!(db.get("port"), Some(&toml::Value::Integer(2)));
332        assert_eq!(table.get("flag"), Some(&toml::Value::Integer(3)));
333    }
334
335    #[test]
336    fn parse_by_extension_detects_formats() {
337        let toml_val = parse_by_extension(Path::new("a.toml"), "x = 1").expect("toml");
338        assert_eq!(toml_val.get("x"), Some(&toml::Value::Integer(1)));
339        let no_ext = parse_by_extension(Path::new("defaults"), "x = 1").expect("toml default");
340        assert_eq!(no_ext.get("x"), Some(&toml::Value::Integer(1)));
341        let upper = parse_by_extension(Path::new("b.TOML"), "x = 2").expect("case-insensitive");
342        assert_eq!(upper.get("x"), Some(&toml::Value::Integer(2)));
343    }
344
345    #[cfg(feature = "json")]
346    #[test]
347    fn parse_by_extension_json() {
348        let val = parse_by_extension(Path::new("a.json"), "{\"x\": 1}").expect("json");
349        assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
350    }
351
352    #[cfg(not(feature = "json"))]
353    #[test]
354    fn json_without_feature_is_parse_error() {
355        let err = parse_by_extension(Path::new("a.json"), "{}").expect_err("feature gate");
356        assert!(err.to_string().contains("`json` feature"));
357    }
358
359    #[cfg(feature = "yaml")]
360    #[test]
361    fn parse_by_extension_yaml() {
362        let val = parse_by_extension(Path::new("a.yaml"), "x: 1").expect("yaml");
363        assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
364        let val = parse_by_extension(Path::new("a.yml"), "x: 2").expect("yml");
365        assert_eq!(val.get("x"), Some(&toml::Value::Integer(2)));
366    }
367
368    #[cfg(not(feature = "yaml"))]
369    #[test]
370    fn yaml_without_feature_is_parse_error() {
371        let err = parse_by_extension(Path::new("a.yaml"), "x: 1").expect_err("feature gate");
372        assert!(err.to_string().contains("`yaml` feature"));
373    }
374}