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