Skip to main content

shine_core/
migration.rs

1/// Recursively syncs `doc` (a formatted TOML document) to match `target`:
2/// removes keys absent from `target`, inserts/updates keys that differ, recurses
3/// into nested tables, and leaves unchanged values (and their formatting/comments) untouched.
4pub fn sync_table(doc: &mut toml_edit::Table, target: &toml::Table) {
5    let to_remove = doc
6        .iter()
7        .map(|(k, _)| k.to_string())
8        .filter(|k| !target.contains_key(k))
9        .collect::<Vec<_>>();
10
11    for k in &to_remove {
12        doc.remove(k);
13    }
14    for (key, target_value) in target {
15        match target_value {
16            toml::Value::Table(sub_target) => {
17                let entry = doc
18                    .entry(key)
19                    .or_insert(toml_edit::Item::Table(Default::default()));
20                if let Some(sub_doc) = entry.as_table_mut() {
21                    sync_table(sub_doc, sub_target);
22                }
23            }
24            _ => {
25                if let (Some(inline), toml::Value::String(value)) = (
26                    doc.get_mut(key).and_then(|item| item.as_inline_table_mut()),
27                    target_value,
28                ) && inline.contains_key("value")
29                {
30                    let unchanged = inline
31                        .get("value")
32                        .and_then(toml_edit::Value::as_str)
33                        .is_some_and(|existing| existing == value);
34                    if !unchanged {
35                        inline.insert("value", toml_edit::Value::from(value.as_str()));
36                    }
37                    continue;
38                }
39                if let Some(existing) = doc.get(key).and_then(|t| t.as_value())
40                    && values_equal(existing, target_value)
41                {
42                    continue;
43                }
44                doc.insert(
45                    key,
46                    toml_edit::value(convert_to_edit_toml_value(target_value)),
47                );
48            }
49        }
50    }
51}
52
53fn values_equal(edit: &toml_edit::Value, target: &toml::Value) -> bool {
54    match (edit, target) {
55        (toml_edit::Value::String(a), toml::Value::String(b)) => a.value() == b,
56        (toml_edit::Value::Integer(a), toml::Value::Integer(b)) => a.value() == b,
57        (toml_edit::Value::Float(a), toml::Value::Float(b)) => a.value() == b,
58        (toml_edit::Value::Boolean(a), toml::Value::Boolean(b)) => a.value() == b,
59        (toml_edit::Value::Array(a), toml::Value::Array(b)) => {
60            a.len() == b.len() && a.iter().zip(b).all(|(a, b)| values_equal(a, b))
61        }
62        _ => false,
63    }
64}
65
66fn convert_to_edit_toml_value(v: &toml::Value) -> toml_edit::Value {
67    match v {
68        toml::Value::String(s) => toml_edit::Value::from(s.as_str()),
69        toml::Value::Integer(i) => toml_edit::Value::from(*i),
70        toml::Value::Float(f) => toml_edit::Value::from(*f),
71        toml::Value::Boolean(b) => toml_edit::Value::from(*b),
72        toml::Value::Datetime(d) => toml_edit::Value::from(*d),
73        toml::Value::Array(arr) => {
74            toml_edit::Value::Array(arr.iter().map(convert_to_edit_toml_value).collect())
75        }
76        toml::Value::Table(table) => {
77            let mut inline = toml_edit::InlineTable::new();
78            for (k, v) in table {
79                inline.insert(k, convert_to_edit_toml_value(v));
80            }
81            toml_edit::Value::InlineTable(inline)
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_convert_to_edit_toml_value_string() {
92        let v = toml::Value::String("hello".to_string());
93        let result = convert_to_edit_toml_value(&v);
94        assert!(matches!(result, toml_edit::Value::String(_)));
95        if let toml_edit::Value::String(s) = result {
96            assert_eq!(s.value(), "hello");
97        }
98    }
99
100    #[test]
101    fn test_convert_to_edit_toml_value_integer() {
102        let v = toml::Value::Integer(42);
103        let result = convert_to_edit_toml_value(&v);
104        assert!(matches!(result, toml_edit::Value::Integer(_)));
105        if let toml_edit::Value::Integer(i) = result {
106            assert_eq!(*i.value(), 42);
107        }
108    }
109
110    #[test]
111    fn test_convert_to_edit_toml_value_float() {
112        let v = toml::Value::Float(2.5);
113        let result = convert_to_edit_toml_value(&v);
114        assert!(matches!(result, toml_edit::Value::Float(_)));
115        if let toml_edit::Value::Float(f) = result {
116            assert_eq!(*f.value(), 2.5);
117        }
118    }
119
120    #[test]
121    fn test_convert_to_edit_toml_value_boolean() {
122        for b in [true, false] {
123            let v = toml::Value::Boolean(b);
124            let result = convert_to_edit_toml_value(&v);
125            assert!(matches!(result, toml_edit::Value::Boolean(_)));
126            if let toml_edit::Value::Boolean(val) = result {
127                assert_eq!(*val.value(), b);
128            }
129        }
130    }
131
132    #[test]
133    fn test_convert_to_edit_toml_value_datetime() {
134        let dt: toml::value::Datetime = "1979-05-27T07:32:00Z".parse().unwrap();
135        let v = toml::Value::Datetime(dt);
136        let result = convert_to_edit_toml_value(&v);
137        assert!(matches!(result, toml_edit::Value::Datetime(_)));
138        if let toml_edit::Value::Datetime(d) = result {
139            assert_eq!(d.value().to_string(), dt.to_string());
140        }
141    }
142
143    #[test]
144    fn test_convert_to_edit_toml_value_array() {
145        let v = toml::Value::Array(vec![
146            toml::Value::String("a".to_string()),
147            toml::Value::Integer(1),
148            toml::Value::Boolean(false),
149        ]);
150        let result = convert_to_edit_toml_value(&v);
151        assert!(matches!(result, toml_edit::Value::Array(_)));
152        if let toml_edit::Value::Array(arr) = result {
153            assert_eq!(arr.len(), 3);
154            assert!(matches!(
155                arr.iter().next().unwrap(),
156                toml_edit::Value::String(_)
157            ));
158        }
159    }
160
161    #[test]
162    fn test_convert_to_edit_toml_value_nested_array() {
163        let v = toml::Value::Array(vec![
164            toml::Value::Array(vec![toml::Value::Integer(1), toml::Value::Integer(2)]),
165            toml::Value::Array(vec![toml::Value::Integer(3)]),
166        ]);
167        let result = convert_to_edit_toml_value(&v);
168        assert!(matches!(result, toml_edit::Value::Array(_)));
169        if let toml_edit::Value::Array(outer) = result {
170            assert_eq!(outer.len(), 2);
171            assert!(matches!(
172                outer.iter().next().unwrap(),
173                toml_edit::Value::Array(_)
174            ));
175        }
176    }
177
178    #[test]
179    fn test_convert_to_edit_toml_value_table() {
180        let mut table = toml::value::Table::new();
181        table.insert("key1".to_string(), toml::Value::String("val".to_string()));
182        table.insert("key2".to_string(), toml::Value::Integer(99));
183        let v = toml::Value::Table(table);
184        let result = convert_to_edit_toml_value(&v);
185        assert!(matches!(result, toml_edit::Value::InlineTable(_)));
186        if let toml_edit::Value::InlineTable(inline) = result {
187            assert_eq!(inline.len(), 2);
188            let v1 = inline.get("key1").unwrap();
189            assert!(matches!(v1, toml_edit::Value::String(_)));
190            if let toml_edit::Value::String(s) = v1 {
191                assert_eq!(s.value(), "val");
192            }
193            let v2 = inline.get("key2").unwrap();
194            assert!(matches!(v2, toml_edit::Value::Integer(_)));
195            if let toml_edit::Value::Integer(i) = v2 {
196                assert_eq!(*i.value(), 99);
197            }
198        }
199    }
200
201    #[test]
202    fn test_convert_to_edit_toml_value_nested_table() {
203        let mut inner = toml::value::Table::new();
204        inner.insert("x".to_string(), toml::Value::Boolean(true));
205        let mut outer = toml::value::Table::new();
206        outer.insert("inner".to_string(), toml::Value::Table(inner));
207        let v = toml::Value::Table(outer);
208        let result = convert_to_edit_toml_value(&v);
209        assert!(matches!(result, toml_edit::Value::InlineTable(_)));
210        if let toml_edit::Value::InlineTable(inline) = result {
211            let nested = inline.get("inner").unwrap();
212            assert!(matches!(nested, toml_edit::Value::InlineTable(_)));
213            if let toml_edit::Value::InlineTable(inner_inline) = nested {
214                assert_eq!(inner_inline.len(), 1);
215                let xv = inner_inline.get("x").unwrap();
216                assert!(matches!(xv, toml_edit::Value::Boolean(_)));
217                if let toml_edit::Value::Boolean(b) = xv {
218                    assert!(*b.value());
219                }
220            }
221        }
222    }
223
224    fn make_doc(toml: &str) -> toml_edit::DocumentMut {
225        toml.parse().unwrap()
226    }
227
228    fn make_target(toml: &str) -> toml::Table {
229        toml::from_str(toml).unwrap()
230    }
231
232    #[test]
233    fn sync_table_adds_new_keys() {
234        let mut doc = make_doc("");
235        let target = make_target("name = \"shine\"\nversion = 1\n");
236        sync_table(doc.as_table_mut(), &target);
237        assert_eq!(doc["name"].as_str(), Some("shine"));
238        assert_eq!(doc["version"].as_integer(), Some(1));
239    }
240
241    #[test]
242    fn sync_table_removes_stale_keys() {
243        let mut doc = make_doc("old = \"gone\"\nkeep = true\n");
244        let target = make_target("keep = true");
245        sync_table(doc.as_table_mut(), &target);
246        assert!(doc.get("old").is_none());
247        assert!(doc.get("keep").is_some());
248    }
249
250    #[test]
251    fn sync_table_updates_changed_value() {
252        let mut doc = make_doc("version = 1");
253        let target = make_target("version = 2");
254        sync_table(doc.as_table_mut(), &target);
255        assert_eq!(doc["version"].as_integer(), Some(2));
256    }
257
258    #[test]
259    fn sync_table_preserves_unchanged_value() {
260        let mut doc = make_doc(r#"name = "shine" # important comment"#);
261        let target = make_target(r#"name = "shine""#);
262        let before = doc.to_string();
263        sync_table(doc.as_table_mut(), &target);
264        assert_eq!(doc.to_string(), before);
265    }
266
267    #[test]
268    fn sync_table_updates_inline_env_value_and_preserves_description() {
269        let mut doc = make_doc(
270            r#"[env]
271TOKEN = { value = "old", description = "API token" }
272"#,
273        );
274        let target = make_target("[env]\nTOKEN = \"new\"\n");
275
276        sync_table(doc.as_table_mut(), &target);
277
278        let inline = doc["env"]["TOKEN"].as_inline_table().unwrap();
279        assert_eq!(inline["value"].as_str(), Some("new"));
280        assert_eq!(inline["description"].as_str(), Some("API token"));
281    }
282
283    #[test]
284    fn sync_table_recurses_into_nested_tables() {
285        let mut doc = make_doc("[db]\nhost = \"old\"\nport = 5432\n");
286        let target = make_target("[db]\nhost = \"new\"\nport = 5432\n");
287        sync_table(doc.as_table_mut(), &target);
288        assert_eq!(doc["db"]["host"].as_str(), Some("new"));
289        assert_eq!(doc["db"]["port"].as_integer(), Some(5432));
290    }
291
292    #[test]
293    fn sync_table_removes_nested_stale_keys() {
294        let mut doc = make_doc("[section]\nkeep = 1\nstale = 2\n");
295        let target = make_target("[section]\nkeep = 1\n");
296        sync_table(doc.as_table_mut(), &target);
297        assert!(doc["section"].get("stale").is_none());
298        assert_eq!(doc["section"]["keep"].as_integer(), Some(1));
299    }
300
301    #[test]
302    fn sync_table_empty_target_clears_doc() {
303        let mut doc = make_doc("a = 1\nb = 2\n");
304        let target = make_target("");
305        sync_table(doc.as_table_mut(), &target);
306        assert!(doc.get("a").is_none());
307        assert!(doc.get("b").is_none());
308    }
309
310    #[test]
311    fn sync_table_empty_doc_fills_from_target() {
312        let mut doc = make_doc("");
313        let target = make_target("x = true\ny = 3.14\n");
314        sync_table(doc.as_table_mut(), &target);
315        assert_eq!(doc["x"].as_bool(), Some(true));
316    }
317
318    #[test]
319    fn test_values_equal() {
320        use toml::Value as TargetValue;
321        use toml_edit::Value as EditValue;
322
323        assert!(values_equal(
324            &EditValue::from("hello"),
325            &TargetValue::from("hello")
326        ));
327        assert!(values_equal(
328            &EditValue::from(42i64),
329            &TargetValue::from(42i64)
330        ));
331        assert!(values_equal(
332            &EditValue::from(2.5f64),
333            &TargetValue::from(2.5f64)
334        ));
335        assert!(values_equal(
336            &EditValue::from(true),
337            &TargetValue::from(true)
338        ));
339
340        let edit_array = EditValue::Array(
341            vec![
342                EditValue::from("a"),
343                EditValue::from(1i64),
344                EditValue::from(false),
345            ]
346            .into_iter()
347            .collect(),
348        );
349        let target_array = TargetValue::Array(vec![
350            TargetValue::from("a"),
351            TargetValue::from(1i64),
352            TargetValue::from(false),
353        ]);
354
355        assert!(values_equal(&edit_array, &target_array));
356
357        let different_string = TargetValue::from("world");
358        assert!(!values_equal(&EditValue::from("hello"), &different_string));
359
360        let different_int = TargetValue::from(7i64);
361        assert!(!values_equal(&EditValue::from(42i64), &different_int));
362
363        let different_type = TargetValue::from(42i64);
364        assert!(!values_equal(&EditValue::from("42"), &different_type));
365
366        let shorter_array =
367            TargetValue::Array(vec![TargetValue::from("a"), TargetValue::from(1i64)]);
368        assert!(!values_equal(&edit_array, &shorter_array));
369
370        let nested_edit_array = EditValue::Array(
371            vec![
372                EditValue::Array(vec![EditValue::from("x")].into_iter().collect()),
373                EditValue::from(false),
374            ]
375            .into_iter()
376            .collect(),
377        );
378        let nested_target_array = TargetValue::Array(vec![
379            TargetValue::Array(vec![TargetValue::from("x")]),
380            TargetValue::from(false),
381        ]);
382        assert!(values_equal(&nested_edit_array, &nested_target_array));
383    }
384}