Skip to main content

sim_config/
merge.rs

1//! Layer merging and field-level provenance.
2
3use sim_kernel::{Expr, Symbol};
4
5use crate::{ConfigDir, ConfigSource, ConfigTable, config_field_name, same_config_field};
6
7/// One configuration layer and the source that produced it.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct ConfigLayer {
10    /// Source metadata for this layer.
11    pub source: ConfigSource,
12    /// Dir data carried by this layer.
13    pub dir: ConfigDir,
14}
15
16impl ConfigLayer {
17    /// Creates a new layer from source metadata and Dir data.
18    pub fn new(source: ConfigSource, dir: ConfigDir) -> Self {
19        Self { source, dir }
20    }
21}
22
23/// Provenance for one effective top-level field.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct MergeTrace {
26    /// Library table that contains the field.
27    pub lib: Symbol,
28    /// Top-level field key.
29    pub key: String,
30    /// Source that supplied the effective value.
31    pub source: ConfigSource,
32}
33
34/// Result of merging config layers.
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct EffectiveConfig {
37    /// Effective Dir after layer precedence is applied.
38    pub dir: ConfigDir,
39    /// Field-level provenance for effective top-level fields.
40    pub trace: Vec<MergeTrace>,
41}
42
43impl EffectiveConfig {
44    fn record_trace(&mut self, lib: &Symbol, key: String, source: &ConfigSource) {
45        if let Some(trace) = self
46            .trace
47            .iter_mut()
48            .find(|trace| &trace.lib == lib && trace.key == key)
49        {
50            trace.source = source.clone();
51        } else {
52            self.trace.push(MergeTrace {
53                lib: lib.clone(),
54                key,
55                source: source.clone(),
56            });
57        }
58    }
59}
60
61/// Merges config layers from lowest to highest precedence.
62pub fn merge_layers(layers: &[ConfigLayer]) -> EffectiveConfig {
63    let mut effective = EffectiveConfig::default();
64    for layer in layers {
65        for table in &layer.dir.entries {
66            overlay_table(&mut effective, table, &layer.source);
67        }
68    }
69    effective
70}
71
72fn overlay_table(effective: &mut EffectiveConfig, table: &ConfigTable, source: &ConfigSource) {
73    let Some(index) = effective
74        .dir
75        .entries
76        .iter()
77        .position(|entry| entry.lib == table.lib)
78    else {
79        effective.dir.entries.push(table.clone());
80        for key in top_level_keys(&table.table) {
81            effective.record_trace(&table.lib, key, source);
82        }
83        return;
84    };
85
86    let changed_keys = merge_table_expr(&mut effective.dir.entries[index].table, &table.table);
87    for key in changed_keys {
88        effective.record_trace(&table.lib, key, source);
89    }
90}
91
92fn merge_table_expr(base: &mut Expr, overlay: &Expr) -> Vec<String> {
93    match (base, overlay) {
94        (Expr::Map(base_entries), Expr::Map(overlay_entries)) => {
95            let mut changed = Vec::new();
96            for (overlay_key, overlay_value) in overlay_entries {
97                let key = key_label(overlay_key);
98                if let Some((_, base_value)) = base_entries
99                    .iter_mut()
100                    .find(|(base_key, _)| same_config_field(base_key, overlay_key))
101                {
102                    *base_value = merge_value(base_value, overlay_value);
103                } else {
104                    base_entries.push((overlay_key.clone(), overlay_value.clone()));
105                }
106                changed.push(key);
107            }
108            changed
109        }
110        (base, _) => {
111            *base = overlay.clone();
112            top_level_keys(overlay)
113        }
114    }
115}
116
117fn merge_value(base: &Expr, overlay: &Expr) -> Expr {
118    match (base, overlay) {
119        (Expr::Map(_), Expr::Map(_)) => {
120            let mut merged = base.clone();
121            merge_table_expr(&mut merged, overlay);
122            merged
123        }
124        (Expr::List(base_items), Expr::List(overlay_items))
125            if id_keyed_items(base_items) && id_keyed_items(overlay_items) =>
126        {
127            merge_id_keyed_items(base_items, overlay_items)
128        }
129        _ => overlay.clone(),
130    }
131}
132
133fn merge_id_keyed_items(base_items: &[Expr], overlay_items: &[Expr]) -> Expr {
134    let mut merged = base_items.to_vec();
135    for overlay in overlay_items {
136        let Some(overlay_id) = item_id(overlay) else {
137            continue;
138        };
139        if let Some(slot) = merged
140            .iter_mut()
141            .find(|item| item_id(item).as_deref() == Some(overlay_id.as_str()))
142        {
143            *slot = overlay.clone();
144        } else {
145            merged.push(overlay.clone());
146        }
147    }
148    Expr::List(merged)
149}
150
151fn id_keyed_items(items: &[Expr]) -> bool {
152    !items.is_empty() && items.iter().all(|item| item_id(item).is_some())
153}
154
155fn item_id(item: &Expr) -> Option<String> {
156    match sim_value::access::field_any(item, "id") {
157        Some(Expr::String(id)) => Some(id.clone()),
158        Some(Expr::Symbol(id)) => Some(id.as_qualified_str()),
159        _ => None,
160    }
161}
162
163fn top_level_keys(table: &Expr) -> Vec<String> {
164    match table {
165        Expr::Map(entries) => entries.iter().map(|(key, _)| key_label(key)).collect(),
166        _ => Vec::new(),
167    }
168}
169
170fn key_label(key: &Expr) -> String {
171    config_field_name(key)
172        .map(ToOwned::to_owned)
173        .unwrap_or_else(|| format!("{key:?}"))
174}
175
176#[cfg(test)]
177mod tests {
178    use sim_value::access::field_any;
179    use sim_value::build::{entry, int, list, map, sym, text};
180
181    use super::*;
182    use crate::ConfigView;
183
184    fn lib() -> Symbol {
185        Symbol::qualified("sim", "cookbook")
186    }
187
188    #[test]
189    fn higher_layers_replace_scalars_and_preserve_absent_fields() {
190        let lower = ConfigDir::one(
191            lib(),
192            map(vec![("mode", text("built-in")), ("keep", int(1))]),
193        )
194        .unwrap();
195        let upper = ConfigDir::one(lib(), map(vec![("mode", text("work"))])).unwrap();
196
197        let effective = merge_layers(&[
198            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
199            ConfigLayer::new(
200                ConfigSource::Explicit {
201                    label: "work".to_owned(),
202                },
203                upper,
204            ),
205        ]);
206
207        let table = effective.dir.table(&lib()).unwrap();
208        assert_eq!(field_any(&table.table, "mode"), Some(&text("work")));
209        assert_eq!(field_any(&table.table, "keep"), Some(&int(1)));
210        assert_eq!(
211            effective
212                .trace
213                .iter()
214                .find(|trace| trace.key == "mode")
215                .unwrap()
216                .source,
217            ConfigSource::Explicit {
218                label: "work".to_owned()
219            }
220        );
221    }
222
223    #[test]
224    fn equivalent_scalar_keys_replace_in_both_directions() {
225        let lower = ConfigDir::one(
226            lib(),
227            Expr::Map(vec![
228                (Expr::String("mode".to_owned()), text("built-in")),
229                (sym("keep"), int(1)),
230            ]),
231        )
232        .unwrap();
233        let upper = ConfigDir::one(lib(), map(vec![("mode", text("work"))])).unwrap();
234
235        let effective = merge_layers(&[
236            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
237            ConfigLayer::new(
238                ConfigSource::Explicit {
239                    label: "symbol-over-string".to_owned(),
240                },
241                upper,
242            ),
243        ]);
244        let table = effective.dir.table(&lib()).unwrap();
245        let view = ConfigView::new(table);
246
247        assert_eq!(view.string("mode"), Some("work"));
248        assert_eq!(view.i64("keep"), Some(1));
249        assert_eq!(table.entries().unwrap().len(), 2);
250        assert_eq!(
251            effective
252                .trace
253                .iter()
254                .find(|trace| trace.key == "mode")
255                .unwrap()
256                .source,
257            ConfigSource::Explicit {
258                label: "symbol-over-string".to_owned()
259            }
260        );
261
262        let lower = ConfigDir::one(lib(), map(vec![("mode", text("built-in"))])).unwrap();
263        let upper = ConfigDir::one(
264            lib(),
265            Expr::Map(vec![(Expr::String("mode".to_owned()), text("work"))]),
266        )
267        .unwrap();
268        let effective = merge_layers(&[
269            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
270            ConfigLayer::new(
271                ConfigSource::Explicit {
272                    label: "string-over-symbol".to_owned(),
273                },
274                upper,
275            ),
276        ]);
277
278        assert_eq!(
279            ConfigView::new(effective.dir.table(&lib()).unwrap()).string("mode"),
280            Some("work")
281        );
282        assert_eq!(
283            effective
284                .trace
285                .iter()
286                .find(|trace| trace.key == "mode")
287                .unwrap()
288                .source,
289            ConfigSource::Explicit {
290                label: "string-over-symbol".to_owned()
291            }
292        );
293    }
294
295    #[test]
296    fn equivalent_nested_map_keys_merge_without_duplicates() {
297        let lower = ConfigDir::one(
298            lib(),
299            map(vec![(
300                "settings",
301                Expr::Map(vec![
302                    (sym("mode"), text("built-in")),
303                    (sym("keep"), text("lower")),
304                ]),
305            )]),
306        )
307        .unwrap();
308        let upper = ConfigDir::one(
309            lib(),
310            Expr::Map(vec![(
311                Expr::String("settings".to_owned()),
312                Expr::Map(vec![(Expr::String("mode".to_owned()), text("work"))]),
313            )]),
314        )
315        .unwrap();
316
317        let effective = merge_layers(&[
318            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
319            ConfigLayer::new(
320                ConfigSource::Explicit {
321                    label: "work".to_owned(),
322                },
323                upper,
324            ),
325        ]);
326        let view = ConfigView::new(effective.dir.table(&lib()).unwrap());
327        let settings = ConfigView::from_entries(view.table("settings").unwrap());
328
329        assert_eq!(settings.string("mode"), Some("work"));
330        assert_eq!(settings.string("keep"), Some("lower"));
331        assert_eq!(view.table("settings").unwrap().len(), 2);
332        assert_eq!(
333            effective
334                .trace
335                .iter()
336                .find(|trace| trace.key == "settings")
337                .unwrap()
338                .source,
339            ConfigSource::Explicit {
340                label: "work".to_owned()
341            }
342        );
343    }
344
345    #[test]
346    fn id_keyed_repeated_tables_replace_by_id() {
347        let lower = ConfigDir::one(
348            lib(),
349            map(vec![(
350                "loadable_lib",
351                Expr::List(vec![
352                    map(vec![("id", text("numbers")), ("source", text("stable"))]),
353                    map(vec![("id", text("shape")), ("source", text("stable"))]),
354                ]),
355            )]),
356        )
357        .unwrap();
358        let upper = ConfigDir::one(
359            lib(),
360            map(vec![(
361                "loadable_lib",
362                Expr::List(vec![
363                    map(vec![("id", text("shape")), ("source", text("work"))]),
364                    map(vec![("id", text("music")), ("source", text("work"))]),
365                ]),
366            )]),
367        )
368        .unwrap();
369
370        let effective = merge_layers(&[
371            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
372            ConfigLayer::new(
373                ConfigSource::Explicit {
374                    label: "work".to_owned(),
375                },
376                upper,
377            ),
378        ]);
379
380        let list = match field_any(&effective.dir.table(&lib()).unwrap().table, "loadable_lib") {
381            Some(Expr::List(items)) => items,
382            other => panic!("expected merged list, got {other:?}"),
383        };
384        assert_eq!(list.len(), 3);
385        assert_eq!(field_any(&list[1], "source"), Some(&text("work")));
386        assert_eq!(field_any(&list[2], "id"), Some(&text("music")));
387        let _ = entry("checked", Expr::Bool(true));
388    }
389
390    #[test]
391    fn id_keyed_repeated_tables_use_config_field_identity() {
392        let lower = ConfigDir::one(
393            lib(),
394            Expr::Map(vec![(
395                Expr::String("loadable_lib".to_owned()),
396                list(vec![
397                    Expr::Map(vec![
398                        (Expr::String("id".to_owned()), text("shape")),
399                        (sym("source"), text("stable")),
400                    ]),
401                    map(vec![("id", text("numbers")), ("source", text("stable"))]),
402                ]),
403            )]),
404        )
405        .unwrap();
406        let upper = ConfigDir::one(
407            lib(),
408            map(vec![(
409                "loadable_lib",
410                list(vec![Expr::Map(vec![
411                    (sym("id"), text("shape")),
412                    (Expr::String("source".to_owned()), text("work")),
413                ])]),
414            )]),
415        )
416        .unwrap();
417
418        let effective = merge_layers(&[
419            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
420            ConfigLayer::new(
421                ConfigSource::Explicit {
422                    label: "work".to_owned(),
423                },
424                upper,
425            ),
426        ]);
427
428        let list = match ConfigView::new(effective.dir.table(&lib()).unwrap()).get("loadable_lib") {
429            Some(Expr::List(items)) => items,
430            other => panic!("expected merged list, got {other:?}"),
431        };
432        assert_eq!(list.len(), 2);
433        assert_eq!(field_any(&list[0], "id"), Some(&text("shape")));
434        assert_eq!(field_any(&list[0], "source"), Some(&text("work")));
435        assert_eq!(field_any(&list[1], "id"), Some(&text("numbers")));
436        assert_eq!(
437            effective
438                .trace
439                .iter()
440                .find(|trace| trace.key == "loadable_lib")
441                .unwrap()
442                .source,
443            ConfigSource::Explicit {
444                label: "work".to_owned()
445            }
446        );
447    }
448}