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}