Skip to main content

knf/
merge.rs

1//! Generic layered merge over native configuration values.
2
3use crate::path::render_keys;
4use crate::{ConfigObject, ConfigValue};
5
6/// Knobs on the merge itself. Passed by reference rather than encoded as cargo
7/// features: features are additive and unify across a dependency graph, so a
8/// `strict` feature would silently change behaviour for one consumer the moment
9/// a second consumer enabled it.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct MergeOptions {
12    /// Error when a layer changes the kind of an existing key.
13    pub strict: bool,
14    /// Key paths whose objects merge shallowly: each child is replaced
15    /// wholesale instead of recursed into — jq's `a + b` rather than `a * b`,
16    /// at that path. The empty path is the root. A path that is missing, or
17    /// is not an object on both sides, changes nothing.
18    pub shallow: Vec<Vec<String>>,
19}
20
21impl MergeOptions {
22    /// The default: deep merge, last layer wins, no type checking.
23    pub const LAST_WINS: Self = Self {
24        strict: false,
25        shallow: Vec::new(),
26    };
27    /// Error when a layer changes the kind of an existing key.
28    pub const STRICT: Self = Self {
29        strict: true,
30        shallow: Vec::new(),
31    };
32
33    /// Top-level keys only: a later layer's value replaces the earlier one whole.
34    pub fn shallow_root() -> Self {
35        Self {
36            strict: false,
37            shallow: vec![Vec::new()],
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum MergeError {
44    /// A layer replaced an existing key with a value of a different kind.
45    ///
46    /// Carries a key path and nothing else — no filenames, no layer indices.
47    #[error(
48        "type conflict at `{}`: {expected} would be replaced by {found}",
49        render_keys(path)
50    )]
51    TypeConflict {
52        path: Vec<String>,
53        expected: &'static str,
54        found: &'static str,
55    },
56}
57
58impl MergeError {
59    /// The dotted key path the conflict occurred at.
60    pub fn path(&self) -> &[String] {
61        match self {
62            Self::TypeConflict { path, .. } => path,
63        }
64    }
65}
66
67/// Merges `over` into `base` in place.
68///
69/// Objects recurse per key. Arrays, scalars, datetimes and null all replace
70/// wholesale — notably arrays are never index-merged or concatenated, and null
71/// is an ordinary value that overwrites rather than a delete instruction. This
72/// is jq's `a * b`.
73///
74/// At each path in [`MergeOptions::shallow`] the object is merged one level
75/// only: every colliding child is replaced whole, objects included — jq's
76/// `a + b`. The empty path makes the whole merge shallow.
77pub fn merge_into<V: ConfigValue>(
78    base: &mut V,
79    over: V,
80    opts: &MergeOptions,
81) -> Result<(), MergeError> {
82    let mut path = Vec::new();
83    merge_at(base, over, opts, &mut path)
84}
85
86/// Folds a list of layers into one document, seeded with an empty object.
87///
88/// The fold must be strictly left over the *flat* layer list. The deep merge is
89/// not associative — any scalar shadowing an object breaks it:
90///
91/// ```text
92/// {a:{b:1}} * {a:5} * {a:{c:2}}
93///   left-assoc  -> {a:{c:2}}
94///   right-assoc -> {a:{b:1,c:2}}
95/// ```
96///
97/// So callers must never merge subgroups and then combine the results.
98/// Flatten first, fold second. (A merge shallow at the root happens to be
99/// associative, but the fold does not rely on it.)
100pub fn merge<V: ConfigValue>(
101    layers: impl IntoIterator<Item = V>,
102    opts: &MergeOptions,
103) -> Result<V, MergeError> {
104    let mut acc = V::object(V::Object::new());
105    let mut path = Vec::new();
106    for layer in layers {
107        merge_at(&mut acc, layer, opts, &mut path)?;
108        debug_assert!(path.is_empty(), "breadcrumb leaked between layers");
109    }
110    Ok(acc)
111}
112
113/// The recursive worker. `path` is a breadcrumb threaded by push/pop so that a
114/// conflict can report where it happened without every frame allocating.
115///
116/// Shallow paths need no extra state: the breadcrumb already names the object
117/// being merged, so it is compared against them once per object.
118fn merge_at<V: ConfigValue>(
119    base: &mut V,
120    over: V,
121    opts: &MergeOptions,
122    path: &mut Vec<String>,
123) -> Result<(), MergeError> {
124    if let Some(base_map) = base.as_object_mut() {
125        match over.into_object() {
126            Ok(over_map) => {
127                let shallow = opts.shallow.iter().any(|p| p == path);
128                for (k, v) in over_map {
129                    if let Some(slot) = base_map.get_mut(&k) {
130                        path.push(k);
131                        if shallow {
132                            replace(slot, v, opts, path)?;
133                        } else {
134                            merge_at(slot, v, opts, path)?;
135                        }
136                        path.pop();
137                    } else {
138                        base_map.insert(k, v);
139                    }
140                }
141                return Ok(());
142            }
143            Err(over) => return replace(base, over, opts, path),
144        }
145    }
146    replace(base, over, opts, path)
147}
148
149fn replace<V: ConfigValue>(
150    base: &mut V,
151    over: V,
152    opts: &MergeOptions,
153    path: &[String],
154) -> Result<(), MergeError> {
155    if opts.strict {
156        check_kind(base.kind(), over.kind(), path)?;
157    }
158    *base = over;
159    Ok(())
160}
161
162/// Errors if a replacement would change the kind of the existing value.
163///
164/// Strict mode catches the class of mistake where a leaf accidentally shadows a
165/// subtree. Pleasant side effect: it rejects exactly the type changes that break
166/// associativity, so under strict mode the merge *is* associative.
167fn check_kind(
168    expected: &'static str,
169    found: &'static str,
170    path: &[String],
171) -> Result<(), MergeError> {
172    if expected == found {
173        return Ok(());
174    }
175    Err(MergeError::TypeConflict {
176        path: path.to_vec(),
177        expected,
178        found,
179    })
180}