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 rules;
12mod strict;
13mod value;
14
15pub use rules::{RuleError, RuleErrors, Rules, Strategy};
16pub use value::{Map, Number, Value};
17
18/// Knobs on the merge itself. Passed by reference rather than encoded as cargo
19/// features: features are additive and unify across a dependency graph, so a
20/// `strict` feature would silently change behaviour for one consumer the moment
21/// a second consumer enabled it.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct MergeOptions {
24    pub strict: bool,
25    /// Per-path overrides of the default merge. An empty set is the default
26    /// merge everywhere.
27    pub rules: Rules,
28}
29
30impl MergeOptions {
31    /// The default: last layer wins, no type checking.
32    pub const LAST_WINS: Self = Self {
33        strict: false,
34        rules: Rules::EMPTY,
35    };
36    /// Error when a layer changes the kind of an existing key.
37    pub const STRICT: Self = Self {
38        strict: true,
39        rules: Rules::EMPTY,
40    };
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
44pub enum MergeError {
45    /// A layer replaced an existing key with a value of a different kind.
46    ///
47    /// Carries a key path and nothing else — no filenames, no layer indices.
48    #[error(
49        "type conflict at `{}`: {expected} would be replaced by {found}",
50        render_path(path)
51    )]
52    TypeConflict {
53        path: Vec<String>,
54        expected: &'static str,
55        found: &'static str,
56    },
57    /// A layer supplied a value for a path pinned by [`Strategy::Fail`].
58    #[error("`{}` is locked: an earlier layer already set it", render_path(path))]
59    Locked { path: Vec<String> },
60    /// [`Strategy::Append`] met something other than two arrays.
61    #[error("cannot append {found} to {base} at `{}`", render_path(path))]
62    AppendKind {
63        path: Vec<String>,
64        base: &'static str,
65        found: &'static str,
66    },
67}
68
69impl MergeError {
70    /// The dotted key path the conflict occurred at.
71    pub fn path(&self) -> &[String] {
72        match self {
73            Self::TypeConflict { path, .. }
74            | Self::Locked { path }
75            | Self::AppendKind { path, .. } => path,
76        }
77    }
78}
79
80/// Renders a key path for display. An empty path is the document root.
81fn render_path(path: &[String]) -> String {
82    if path.is_empty() {
83        "<root>".to_string()
84    } else {
85        path.join(".")
86    }
87}
88
89/// Merges `over` into `base` in place.
90///
91/// Objects recurse per key. Arrays, scalars, datetimes and null all replace
92/// wholesale — notably arrays are never index-merged or concatenated, and null
93/// is an ordinary value that overwrites rather than a delete instruction.
94///
95/// [`MergeOptions::rules`] overrides that at the paths it names, and only there.
96pub fn merge_into(base: &mut Value, over: Value, opts: &MergeOptions) -> Result<(), MergeError> {
97    let mut path = Vec::new();
98    apply(base, over, opts, &mut path, Some(&opts.rules))
99}
100
101/// Folds a list of layers into one document, last-wins, seeded with an empty object.
102///
103/// Equivalent to [`merge_with`] using [`MergeOptions::LAST_WINS`].
104pub fn merge(layers: impl IntoIterator<Item = Value>) -> Result<Value, MergeError> {
105    merge_with(layers, &MergeOptions::LAST_WINS)
106}
107
108/// Folds a list of layers into one document, seeded with an empty object.
109///
110/// The fold must be strictly left over the *flat* layer list. Merge is not
111/// associative — any scalar shadowing an object breaks it:
112///
113/// ```text
114/// {a:{b:1}} + {a:5} + {a:{c:2}}
115///   left-assoc  -> {a:{c:2}}
116///   right-assoc -> {a:{b:1,c:2}}
117/// ```
118///
119/// So callers must never merge subgroups and then combine the results.
120/// Flatten first, fold second.
121///
122/// [`Strategy::Append`] does not reintroduce the problem: concatenation is
123/// associative, so strict mode still buys associativity with rules in play.
124pub fn merge_with(
125    layers: impl IntoIterator<Item = Value>,
126    opts: &MergeOptions,
127) -> Result<Value, MergeError> {
128    let mut acc = Value::Object(Map::new());
129    let mut path = Vec::new();
130    for layer in layers {
131        apply(&mut acc, layer, opts, &mut path, Some(&opts.rules))?;
132        debug_assert!(path.is_empty(), "breadcrumb leaked between layers");
133    }
134    Ok(acc)
135}
136
137/// Dispatches one node to its strategy. `rules` is the subtree of rules rooted
138/// at `path`, so the lookup is one `BTreeMap` probe per level and `None`
139/// short-circuits everything below it.
140///
141/// Reached only where `base` already holds a value: a key the accumulator does
142/// not have yet is inserted without consulting any strategy, which is what
143/// keeps [`Fail`](Strategy::Fail) meaning "the first layer to define this pins
144/// it" and keeps [`Append`](Strategy::Append) from doubling a lone layer's
145/// array against the empty seed.
146fn apply(
147    base: &mut Value,
148    over: Value,
149    opts: &MergeOptions,
150    path: &mut Vec<String>,
151    rules: Option<&Rules>,
152) -> Result<(), MergeError> {
153    match rules.and_then(Rules::strategy) {
154        None => merge_at(base, over, opts, path, rules),
155        Some(Strategy::Replace) => replace(base, over, opts, path),
156        Some(Strategy::Append) => append(base, over, path),
157        Some(Strategy::Fail) => Err(MergeError::Locked { path: path.clone() }),
158    }
159}
160
161/// The recursive worker. `path` is a breadcrumb threaded by push/pop so that a
162/// conflict can report where it happened without every frame allocating;
163/// `rules` narrows on the same descent.
164fn merge_at(
165    base: &mut Value,
166    over: Value,
167    opts: &MergeOptions,
168    path: &mut Vec<String>,
169    rules: Option<&Rules>,
170) -> Result<(), MergeError> {
171    match (base, over) {
172        (Value::Object(base_map), Value::Object(over_map)) => {
173            for (k, v) in over_map {
174                let child = rules.and_then(|r| r.child(&k));
175                if let Some(slot) = base_map.get_mut(&k) {
176                    path.push(k);
177                    apply(slot, v, opts, path, child)?;
178                    path.pop();
179                } else {
180                    // No collision, so no strategy applies.
181                    base_map.insert(k, v);
182                }
183            }
184            Ok(())
185        }
186        (base, over) => {
187            if let Some(rules) = rules
188                && let Some(locked) = locked_path(base, rules)
189            {
190                let mut path = path.clone();
191                path.extend(locked);
192                return Err(MergeError::Locked { path });
193            }
194            replace(base, over, opts, path)
195        }
196    }
197}
198
199/// The path to a [`Fail`](Strategy::Fail)-protected key that `base` already
200/// holds a value at, if any is nested under `rules`.
201///
202/// This is the ancestor-replacement counterpart to the direct check in
203/// [`apply`]: `merge_at` only recurses key-by-key on an `(Object, Object)`
204/// pair, so a layer that replaces an *ancestor* of a locked path wholesale
205/// (`db.host` pinned, a later layer sets `db` itself to a string) never
206/// visits `db.host` and so never consults its rule. Without this, `--fail`
207/// would silently stop meaning "pinned" the moment a layer reached far enough
208/// up the tree. `--append`'s protection does not need the same treatment: an
209/// ancestor replacement leaves no array on either side to concatenate, so
210/// there is nothing for it to protect there.
211fn locked_path(base: &Value, rules: &Rules) -> Option<Vec<String>> {
212    if rules.strategy() == Some(Strategy::Fail) {
213        return Some(Vec::new());
214    }
215    let Value::Object(map) = base else {
216        return None;
217    };
218    rules.children().find_map(|(key, child)| {
219        let mut rest = locked_path(map.get(key)?, child)?;
220        rest.insert(0, key.to_string());
221        Some(rest)
222    })
223}
224
225fn replace(
226    base: &mut Value,
227    over: Value,
228    opts: &MergeOptions,
229    path: &[String],
230) -> Result<(), MergeError> {
231    if opts.strict {
232        strict::check(base.kind(), over.kind(), path)?;
233    }
234    *base = over;
235    Ok(())
236}
237
238/// Concatenates base ++ overlay. The one place a layer adds to a value instead
239/// of replacing it, so both sides must really be arrays.
240fn append(base: &mut Value, over: Value, path: &[String]) -> Result<(), MergeError> {
241    match (base, over) {
242        (Value::Array(base_items), Value::Array(over_items)) => {
243            base_items.extend(over_items);
244            Ok(())
245        }
246        (base, over) => Err(MergeError::AppendKind {
247            path: path.to_vec(),
248            base: base.kind(),
249            found: over.kind(),
250        }),
251    }
252}