Skip to main content

knf_core/
lib.rs

1//! Layered merge over an owned [`Value`] tree.
2//!
3//! One walk, one value type. Every format parses into [`Value`] before merging,
4//! so JSON and TOML layers stack without a conversion in the middle. This crate
5//! knows nothing about files, formats or the command line; parsing, emission and
6//! provenance are all the caller's job.
7//!
8//! `thiserror` and `indexmap` are the only dependencies — neither is a format
9//! crate. `cargo tree -p knf-core --depth 1` is the enforcement.
10
11mod strict;
12mod value;
13
14pub use value::{Map, Number, Value};
15
16/// Knobs on the merge itself. Passed by reference rather than encoded as cargo
17/// features: features are additive and unify across a dependency graph, so a
18/// `strict` feature would silently change behaviour for one consumer the moment
19/// a second consumer enabled it.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct MergeOptions {
22    pub strict: bool,
23}
24
25impl MergeOptions {
26    /// The default: last layer wins, no type checking.
27    pub const LAST_WINS: Self = Self { strict: false };
28    /// Error when a layer changes the kind of an existing key.
29    pub const STRICT: Self = Self { strict: true };
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum MergeError {
34    /// A layer replaced an existing key with a value of a different kind.
35    ///
36    /// Carries a key path and nothing else — no filenames, no layer indices.
37    #[error(
38        "type conflict at `{}`: {expected} would be replaced by {found}",
39        render_path(path)
40    )]
41    TypeConflict {
42        path: Vec<String>,
43        expected: &'static str,
44        found: &'static str,
45    },
46}
47
48impl MergeError {
49    /// The dotted key path the conflict occurred at.
50    pub fn path(&self) -> &[String] {
51        match self {
52            Self::TypeConflict { path, .. } => path,
53        }
54    }
55}
56
57/// Renders a key path for display. An empty path is the document root.
58fn render_path(path: &[String]) -> String {
59    if path.is_empty() {
60        "<root>".to_string()
61    } else {
62        path.join(".")
63    }
64}
65
66/// Merges `over` into `base` in place.
67///
68/// Objects recurse per key. Arrays, scalars, datetimes and null all replace
69/// wholesale — notably arrays are never index-merged or concatenated, and null
70/// is an ordinary value that overwrites rather than a delete instruction.
71pub fn merge_into(base: &mut Value, over: Value, opts: &MergeOptions) -> Result<(), MergeError> {
72    let mut path = Vec::new();
73    merge_at(base, over, opts, &mut path)
74}
75
76/// Folds a list of layers into one document, last-wins, seeded with an empty object.
77///
78/// Equivalent to [`merge_with`] using [`MergeOptions::LAST_WINS`].
79pub fn merge(layers: impl IntoIterator<Item = Value>) -> Result<Value, MergeError> {
80    merge_with(layers, &MergeOptions::LAST_WINS)
81}
82
83/// Folds a list of layers into one document, seeded with an empty object.
84///
85/// The fold must be strictly left over the *flat* layer list. Merge is not
86/// associative — any scalar shadowing an object breaks it:
87///
88/// ```text
89/// {a:{b:1}} + {a:5} + {a:{c:2}}
90///   left-assoc  -> {a:{c:2}}
91///   right-assoc -> {a:{b:1,c:2}}
92/// ```
93///
94/// So callers must never merge subgroups and then combine the results.
95/// Flatten first, fold second.
96pub fn merge_with(
97    layers: impl IntoIterator<Item = Value>,
98    opts: &MergeOptions,
99) -> Result<Value, MergeError> {
100    let mut acc = Value::Object(Map::new());
101    let mut path = Vec::new();
102    for layer in layers {
103        merge_at(&mut acc, layer, opts, &mut path)?;
104        debug_assert!(path.is_empty(), "breadcrumb leaked between layers");
105    }
106    Ok(acc)
107}
108
109/// The recursive worker. `path` is a breadcrumb threaded by push/pop so that a
110/// conflict can report where it happened without every frame allocating.
111fn merge_at(
112    base: &mut Value,
113    over: Value,
114    opts: &MergeOptions,
115    path: &mut Vec<String>,
116) -> Result<(), MergeError> {
117    match (base, over) {
118        (Value::Object(base_map), Value::Object(over_map)) => {
119            for (k, v) in over_map {
120                if let Some(slot) = base_map.get_mut(&k) {
121                    path.push(k);
122                    merge_at(slot, v, opts, path)?;
123                    path.pop();
124                } else {
125                    base_map.insert(k, v);
126                }
127            }
128            Ok(())
129        }
130        (base, over) => replace(base, over, opts, path),
131    }
132}
133
134fn replace(
135    base: &mut Value,
136    over: Value,
137    opts: &MergeOptions,
138    path: &[String],
139) -> Result<(), MergeError> {
140    if opts.strict {
141        strict::check(base.kind(), over.kind(), path)?;
142    }
143    *base = over;
144    Ok(())
145}