Skip to main content

tanzim_merge/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use cfg_if::cfg_if;
4use std::collections::HashMap;
5use tanzim_load::Payload;
6use tanzim_value::{LocatedValue, Map, Value};
7
8/// Merge result: entry name → (contributing payloads, merged value).
9///
10/// Keys come from [`Payload::maybe_name`]: `Some("foo")` → `Some("foo")`, `None` → unnamed bucket (`None`).
11pub type Merged = HashMap<Option<String>, (Vec<Payload>, LocatedValue)>;
12
13/// Merges parsed payloads grouped by entry name.
14///
15/// The returned map keys are derived from [`Payload::maybe_name`]: `Some("foo")` → `Some("foo")`,
16/// `None` → unnamed bucket (`None`). The value for each key is `(Vec<payload>, merged_value)`.
17///
18/// Implement this trait to define a custom merge strategy.
19pub trait Merge {
20    /// Merge `parsed_list` into a map keyed by entry name.
21    ///
22    /// Each tuple in `parsed_list` is `(payload, parsed_value)` as produced by
23    /// the load and parse stages. The merger groups entries by name and combines values.
24    fn merge(&self, parsed_list: &[(Payload, LocatedValue)]) -> Result<Merged, Error>;
25}
26
27/// Merge error type.
28#[derive(Debug, thiserror::Error)]
29pub enum Error {
30    #[error(transparent)]
31    Other(#[from] Box<dyn std::error::Error + Send + Sync>),
32}
33
34/// Last-write-wins merger: each name keeps only its last-seen value.
35///
36/// Payloads with `maybe_name == None` are grouped under the unnamed bucket (`None` key).
37pub struct LastWins;
38
39impl Merge for LastWins {
40    fn merge(&self, parsed_list: &[(Payload, LocatedValue)]) -> Result<Merged, Error> {
41        cfg_if! {
42            if #[cfg(feature = "tracing")] {
43                tracing::debug!(msg = "Merging configuration with last-wins strategy", entry_count = parsed_list.len());
44            } else if #[cfg(feature = "logging")] {
45                log::debug!("msg=\"Merging configuration with last-wins strategy\" entry_count={}", parsed_list.len());
46            }
47        }
48        let mut result: Merged = HashMap::new();
49        for (payload, value) in parsed_list {
50            let key = payload.maybe_name.clone();
51            cfg_if! {
52                if #[cfg(feature = "tracing")] {
53                    tracing::trace!(msg = "Applied last-wins merge entry", name = ?key);
54                } else if #[cfg(feature = "logging")] {
55                    log::trace!("msg=\"Applied last-wins merge entry\" name={key:?}");
56                }
57            }
58            result.insert(key, (vec![payload.clone()], value.clone()));
59        }
60        cfg_if! {
61            if #[cfg(feature = "tracing")] {
62                tracing::info!(msg = "Merged configuration with last-wins strategy", group_count = result.len());
63            } else if #[cfg(feature = "logging")] {
64                log::info!("msg=\"Merged configuration with last-wins strategy\" group_count={}", result.len());
65            }
66        }
67        Ok(result)
68    }
69}
70
71/// Deep-merge merger: maps with the same name are merged recursively.
72///
73/// For each key in a map: if both the accumulated and incoming values are maps,
74/// the merge recurses. Otherwise the incoming (overlay) value and its location win.
75/// Payloads with `maybe_name == None` are grouped under the unnamed bucket (`None` key).
76pub struct DeepMerge;
77
78fn deep_merge_value(base: LocatedValue, overlay: LocatedValue) -> LocatedValue {
79    if let (Value::Map(base_map), Value::Map(overlay_map)) = (base.value(), overlay.value()) {
80        let mut result_map = Map::new();
81        let base_entries = base_map.entries();
82        let overlay_entries = overlay_map.entries();
83
84        for (key, base_val) in base_entries {
85            if let Some(overlay_val) = overlay_map.get(key) {
86                result_map.insert(
87                    key.clone(),
88                    deep_merge_value(base_val.clone(), overlay_val.clone()),
89                );
90            } else {
91                result_map.insert(key.clone(), base_val.clone());
92            }
93        }
94
95        for (key, overlay_val) in overlay_entries {
96            if !result_map.contains_key(key) {
97                result_map.insert(key.clone(), overlay_val.clone());
98            }
99        }
100
101        return LocatedValue::new(Value::Map(result_map), overlay.location().clone());
102    }
103    overlay
104}
105
106impl Merge for DeepMerge {
107    fn merge(&self, parsed_list: &[(Payload, LocatedValue)]) -> Result<Merged, Error> {
108        cfg_if! {
109            if #[cfg(feature = "tracing")] {
110                tracing::debug!(msg = "Merging configuration with deep-merge strategy", entry_count = parsed_list.len());
111            } else if #[cfg(feature = "logging")] {
112                log::debug!("msg=\"Merging configuration with deep-merge strategy\" entry_count={}", parsed_list.len());
113            }
114        }
115        let mut result: Merged = HashMap::new();
116
117        for (payload, value) in parsed_list {
118            let key = payload.maybe_name.clone();
119
120            if let Some(existing) = result.get_mut(&key) {
121                cfg_if! {
122                    if #[cfg(feature = "tracing")] {
123                        tracing::debug!(msg = "Deep-merging into existing entry", name = ?key);
124                    } else if #[cfg(feature = "logging")] {
125                        log::debug!("msg=\"Deep-merging into existing entry\" name={key:?}");
126                    }
127                }
128                existing.0.push(payload.clone());
129                let merged = deep_merge_value(existing.1.clone(), value.clone());
130                existing.1 = merged;
131            } else {
132                cfg_if! {
133                    if #[cfg(feature = "tracing")] {
134                        tracing::trace!(msg = "Added new deep-merge entry", name = ?key);
135                    } else if #[cfg(feature = "logging")] {
136                        log::trace!("msg=\"Added new deep-merge entry\" name={key:?}");
137                    }
138                }
139                result.insert(key, (vec![payload.clone()], value.clone()));
140            }
141        }
142
143        cfg_if! {
144            if #[cfg(feature = "tracing")] {
145                tracing::info!(msg = "Merged configuration with deep-merge strategy", group_count = result.len());
146            } else if #[cfg(feature = "logging")] {
147                log::info!("msg=\"Merged configuration with deep-merge strategy\" group_count={}", result.len());
148            }
149        }
150        Ok(result)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use tanzim_load::Payload;
158    use tanzim_source::SourceBuilder;
159    use tanzim_value::{LocatedValue, Location, Map, Value};
160
161    fn source() -> tanzim_source::Source {
162        SourceBuilder::new()
163            .with_source("mock")
164            .with_resource("test")
165            .build()
166            .unwrap()
167    }
168
169    fn payload(name: Option<&str>) -> Payload {
170        Payload {
171            source: source(),
172            maybe_name: name.map(str::to_string),
173            maybe_format: Some("txt".into()),
174            content: Vec::new(),
175        }
176    }
177
178    fn string_value(text: &str) -> LocatedValue {
179        LocatedValue::new(
180            Value::String(text.to_string()),
181            Location::at("mock", "test", None, None, None),
182        )
183    }
184
185    fn map_value(entries: &[(&str, &str)]) -> LocatedValue {
186        let mut map = Map::new();
187        for (key, value) in entries {
188            map.insert(key.to_string(), string_value(value));
189        }
190        LocatedValue::new(
191            Value::Map(map),
192            Location::at("mock", "test", None, None, None),
193        )
194    }
195
196    #[test]
197    fn last_wins_empty_input() {
198        let merged = LastWins.merge(&[]).unwrap();
199        assert!(merged.is_empty());
200    }
201
202    #[test]
203    fn last_wins_keeps_last_value_for_same_name() {
204        let parsed = vec![
205            (payload(Some("app")), string_value("first")),
206            (payload(Some("app")), string_value("second")),
207        ];
208        let merged = LastWins.merge(&parsed).unwrap();
209        let (_, value) = merged.get(&Some("app".into())).unwrap();
210        assert_eq!(value.value().as_string().unwrap(), "second");
211    }
212
213    #[test]
214    fn last_wins_groups_unnamed_entries() {
215        let parsed = vec![
216            (payload(None), string_value("first")),
217            (payload(None), string_value("second")),
218        ];
219        let merged = LastWins.merge(&parsed).unwrap();
220        let (_, value) = merged.get(&None).unwrap();
221        assert_eq!(value.value().as_string().unwrap(), "second");
222    }
223
224    #[test]
225    fn last_wins_distinct_names() {
226        let parsed = vec![
227            (payload(Some("alpha")), string_value("a")),
228            (payload(Some("beta")), string_value("b")),
229        ];
230        let merged = LastWins.merge(&parsed).unwrap();
231        assert_eq!(merged.len(), 2);
232        assert_eq!(
233            merged
234                .get(&Some("alpha".into()))
235                .unwrap()
236                .1
237                .value()
238                .as_string()
239                .unwrap(),
240            "a"
241        );
242        assert_eq!(
243            merged
244                .get(&Some("beta".into()))
245                .unwrap()
246                .1
247                .value()
248                .as_string()
249                .unwrap(),
250            "b"
251        );
252    }
253
254    #[test]
255    fn deep_merge_empty_input() {
256        let merged = DeepMerge.merge(&[]).unwrap();
257        assert!(merged.is_empty());
258    }
259
260    #[test]
261    fn deep_merge_recurses_into_shared_map_keys() {
262        let parsed = vec![
263            (
264                payload(Some("app")),
265                map_value(&[("host", "localhost"), ("port", "8080")]),
266            ),
267            (
268                payload(Some("app")),
269                map_value(&[("port", "9090"), ("debug", "true")]),
270            ),
271        ];
272        let merged = DeepMerge.merge(&parsed).unwrap();
273        let (payloads, value) = merged.get(&Some("app".into())).unwrap();
274        assert_eq!(payloads.len(), 2);
275        let map = value.value().as_map().unwrap();
276        assert_eq!(
277            map.get("host").unwrap().value().as_string().unwrap(),
278            "localhost"
279        );
280        assert_eq!(
281            map.get("port").unwrap().value().as_string().unwrap(),
282            "9090"
283        );
284        assert_eq!(
285            map.get("debug").unwrap().value().as_string().unwrap(),
286            "true"
287        );
288    }
289
290    #[test]
291    fn deep_merge_scalar_overlay_replaces_map() {
292        let parsed = vec![
293            (payload(Some("app")), map_value(&[("mode", "auto")])),
294            (payload(Some("app")), string_value("override")),
295        ];
296        let merged = DeepMerge.merge(&parsed).unwrap();
297        let (_, value) = merged.get(&Some("app".into())).unwrap();
298        assert_eq!(value.value().as_string().unwrap(), "override");
299    }
300
301    #[test]
302    fn deep_merge_unnamed_bucket() {
303        let parsed = vec![
304            (payload(None), map_value(&[("a", "1")])),
305            (payload(None), map_value(&[("b", "2")])),
306        ];
307        let merged = DeepMerge.merge(&parsed).unwrap();
308        let (payloads, value) = merged.get(&None).unwrap();
309        assert_eq!(payloads.len(), 2);
310        let map = value.value().as_map().unwrap();
311        assert_eq!(map.get("a").unwrap().value().as_string().unwrap(), "1");
312        assert_eq!(map.get("b").unwrap().value().as_string().unwrap(), "2");
313    }
314}