Skip to main content

sim_config/
merge.rs

1//! Layer merging and field-level provenance.
2
3use sim_kernel::{Expr, Symbol};
4use sim_value::access::field_any;
5
6use crate::{ConfigDir, ConfigSource, ConfigTable};
7
8/// One configuration layer and the source that produced it.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ConfigLayer {
11    /// Source metadata for this layer.
12    pub source: ConfigSource,
13    /// Dir data carried by this layer.
14    pub dir: ConfigDir,
15}
16
17impl ConfigLayer {
18    /// Creates a new layer from source metadata and Dir data.
19    pub fn new(source: ConfigSource, dir: ConfigDir) -> Self {
20        Self { source, dir }
21    }
22}
23
24/// Provenance for one effective top-level field.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct MergeTrace {
27    /// Library table that contains the field.
28    pub lib: Symbol,
29    /// Top-level field key.
30    pub key: String,
31    /// Source that supplied the effective value.
32    pub source: ConfigSource,
33}
34
35/// Result of merging config layers.
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct EffectiveConfig {
38    /// Effective Dir after layer precedence is applied.
39    pub dir: ConfigDir,
40    /// Field-level provenance for effective top-level fields.
41    pub trace: Vec<MergeTrace>,
42}
43
44impl EffectiveConfig {
45    fn record_trace(&mut self, lib: &Symbol, key: String, source: &ConfigSource) {
46        if let Some(trace) = self
47            .trace
48            .iter_mut()
49            .find(|trace| &trace.lib == lib && trace.key == key)
50        {
51            trace.source = source.clone();
52        } else {
53            self.trace.push(MergeTrace {
54                lib: lib.clone(),
55                key,
56                source: source.clone(),
57            });
58        }
59    }
60}
61
62/// Merges config layers from lowest to highest precedence.
63pub fn merge_layers(layers: &[ConfigLayer]) -> EffectiveConfig {
64    let mut effective = EffectiveConfig::default();
65    for layer in layers {
66        for table in &layer.dir.entries {
67            overlay_table(&mut effective, table, &layer.source);
68        }
69    }
70    effective
71}
72
73fn overlay_table(effective: &mut EffectiveConfig, table: &ConfigTable, source: &ConfigSource) {
74    let Some(index) = effective
75        .dir
76        .entries
77        .iter()
78        .position(|entry| entry.lib == table.lib)
79    else {
80        effective.dir.entries.push(table.clone());
81        for key in top_level_keys(&table.table) {
82            effective.record_trace(&table.lib, key, source);
83        }
84        return;
85    };
86
87    let changed_keys = merge_table_expr(&mut effective.dir.entries[index].table, &table.table);
88    for key in changed_keys {
89        effective.record_trace(&table.lib, key, source);
90    }
91}
92
93fn merge_table_expr(base: &mut Expr, overlay: &Expr) -> Vec<String> {
94    match (base, overlay) {
95        (Expr::Map(base_entries), Expr::Map(overlay_entries)) => {
96            let mut changed = Vec::new();
97            for (overlay_key, overlay_value) in overlay_entries {
98                let key = key_label(overlay_key);
99                if let Some((_, base_value)) = base_entries
100                    .iter_mut()
101                    .find(|(base_key, _)| base_key == overlay_key)
102                {
103                    *base_value = merge_value(base_value, overlay_value);
104                } else {
105                    base_entries.push((overlay_key.clone(), overlay_value.clone()));
106                }
107                changed.push(key);
108            }
109            changed
110        }
111        (base, _) => {
112            *base = overlay.clone();
113            top_level_keys(overlay)
114        }
115    }
116}
117
118fn merge_value(base: &Expr, overlay: &Expr) -> Expr {
119    match (base, overlay) {
120        (Expr::Map(_), Expr::Map(_)) => {
121            let mut merged = base.clone();
122            merge_table_expr(&mut merged, overlay);
123            merged
124        }
125        (Expr::List(base_items), Expr::List(overlay_items))
126            if id_keyed_items(base_items) && id_keyed_items(overlay_items) =>
127        {
128            merge_id_keyed_items(base_items, overlay_items)
129        }
130        _ => overlay.clone(),
131    }
132}
133
134fn merge_id_keyed_items(base_items: &[Expr], overlay_items: &[Expr]) -> Expr {
135    let mut merged = base_items.to_vec();
136    for overlay in overlay_items {
137        let Some(overlay_id) = item_id(overlay) else {
138            continue;
139        };
140        if let Some(slot) = merged
141            .iter_mut()
142            .find(|item| item_id(item).as_deref() == Some(overlay_id.as_str()))
143        {
144            *slot = overlay.clone();
145        } else {
146            merged.push(overlay.clone());
147        }
148    }
149    Expr::List(merged)
150}
151
152fn id_keyed_items(items: &[Expr]) -> bool {
153    !items.is_empty() && items.iter().all(|item| item_id(item).is_some())
154}
155
156fn item_id(item: &Expr) -> Option<String> {
157    match field_any(item, "id") {
158        Some(Expr::String(id)) => Some(id.clone()),
159        Some(Expr::Symbol(id)) => Some(id.as_qualified_str()),
160        _ => None,
161    }
162}
163
164fn top_level_keys(table: &Expr) -> Vec<String> {
165    match table {
166        Expr::Map(entries) => entries.iter().map(|(key, _)| key_label(key)).collect(),
167        _ => Vec::new(),
168    }
169}
170
171fn key_label(key: &Expr) -> String {
172    match key {
173        Expr::Symbol(symbol) => symbol.as_qualified_str(),
174        Expr::String(text) => text.clone(),
175        other => format!("{other:?}"),
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use sim_value::build::{entry, int, map, text};
182
183    use super::*;
184
185    fn lib() -> Symbol {
186        Symbol::qualified("sim", "cookbook")
187    }
188
189    #[test]
190    fn higher_layers_replace_scalars_and_preserve_absent_fields() {
191        let lower = ConfigDir::one(
192            lib(),
193            map(vec![("mode", text("built-in")), ("keep", int(1))]),
194        )
195        .unwrap();
196        let upper = ConfigDir::one(lib(), map(vec![("mode", text("work"))])).unwrap();
197
198        let effective = merge_layers(&[
199            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
200            ConfigLayer::new(
201                ConfigSource::Explicit {
202                    label: "work".to_owned(),
203                },
204                upper,
205            ),
206        ]);
207
208        let table = effective.dir.table(&lib()).unwrap();
209        assert_eq!(field_any(&table.table, "mode"), Some(&text("work")));
210        assert_eq!(field_any(&table.table, "keep"), Some(&int(1)));
211        assert_eq!(
212            effective
213                .trace
214                .iter()
215                .find(|trace| trace.key == "mode")
216                .unwrap()
217                .source,
218            ConfigSource::Explicit {
219                label: "work".to_owned()
220            }
221        );
222    }
223
224    #[test]
225    fn id_keyed_repeated_tables_replace_by_id() {
226        let lower = ConfigDir::one(
227            lib(),
228            map(vec![(
229                "loadable_lib",
230                Expr::List(vec![
231                    map(vec![("id", text("numbers")), ("source", text("stable"))]),
232                    map(vec![("id", text("shape")), ("source", text("stable"))]),
233                ]),
234            )]),
235        )
236        .unwrap();
237        let upper = ConfigDir::one(
238            lib(),
239            map(vec![(
240                "loadable_lib",
241                Expr::List(vec![
242                    map(vec![("id", text("shape")), ("source", text("work"))]),
243                    map(vec![("id", text("music")), ("source", text("work"))]),
244                ]),
245            )]),
246        )
247        .unwrap();
248
249        let effective = merge_layers(&[
250            ConfigLayer::new(ConfigSource::BuiltIn { lib: lib() }, lower),
251            ConfigLayer::new(
252                ConfigSource::Explicit {
253                    label: "work".to_owned(),
254                },
255                upper,
256            ),
257        ]);
258
259        let list = match field_any(&effective.dir.table(&lib()).unwrap().table, "loadable_lib") {
260            Some(Expr::List(items)) => items,
261            other => panic!("expected merged list, got {other:?}"),
262        };
263        assert_eq!(list.len(), 3);
264        assert_eq!(field_any(&list[1], "source"), Some(&text("work")));
265        assert_eq!(field_any(&list[2], "id"), Some(&text("music")));
266        let _ = entry("checked", Expr::Bool(true));
267    }
268}