Skip to main content

synx_core/
diff.rs

1//! Structural diff between two SYNX values.
2
3use std::collections::HashMap;
4use crate::Value;
5
6/// Result of a structural diff between two SYNX objects.
7#[derive(Debug, Clone)]
8pub struct DiffResult {
9    pub added: HashMap<String, Value>,
10    pub removed: HashMap<String, Value>,
11    pub changed: HashMap<String, DiffChange>,
12    pub unchanged: Vec<String>,
13}
14
15/// A single changed key with its previous and new value.
16#[derive(Debug, Clone)]
17pub struct DiffChange {
18    pub from: Value,
19    pub to: Value,
20}
21
22/// Compute a structural diff between two SYNX objects (top-level keys).
23///
24/// Keys present only in `b` appear in `added`.
25/// Keys present only in `a` appear in `removed`.
26/// Keys present in both but with different values appear in `changed`.
27/// Keys present in both with equal values appear in `unchanged`.
28pub fn diff(a: &HashMap<String, Value>, b: &HashMap<String, Value>) -> DiffResult {
29    let mut added = HashMap::new();
30    let mut removed = HashMap::new();
31    let mut changed = HashMap::new();
32    let mut unchanged = Vec::new();
33
34    for (key, a_val) in a {
35        match b.get(key) {
36            None => { removed.insert(key.clone(), a_val.clone()); }
37            Some(b_val) => {
38                if deep_equal(a_val, b_val) {
39                    unchanged.push(key.clone());
40                } else {
41                    changed.insert(key.clone(), DiffChange {
42                        from: a_val.clone(),
43                        to: b_val.clone(),
44                    });
45                }
46            }
47        }
48    }
49
50    for (key, b_val) in b {
51        if !a.contains_key(key) {
52            added.insert(key.clone(), b_val.clone());
53        }
54    }
55
56    unchanged.sort();
57
58    DiffResult { added, removed, changed, unchanged }
59}
60
61/// Convert a `DiffResult` into a `Value::Object` suitable for JSON serialisation.
62///
63/// Shape: `{ "added": {...}, "removed": {...}, "changed": { key: { "from": ..., "to": ... } }, "unchanged": [...] }`
64pub fn diff_to_value(d: &DiffResult) -> Value {
65    let mut root = HashMap::new();
66
67    root.insert("added".into(), Value::Object(d.added.clone()));
68    root.insert("removed".into(), Value::Object(d.removed.clone()));
69
70    let mut changed_map = HashMap::new();
71    for (k, c) in &d.changed {
72        let mut entry = HashMap::new();
73        entry.insert("from".into(), c.from.clone());
74        entry.insert("to".into(), c.to.clone());
75        changed_map.insert(k.clone(), Value::Object(entry));
76    }
77    root.insert("changed".into(), Value::Object(changed_map));
78
79    let unchanged_arr: Vec<Value> = d.unchanged.iter().map(|s| Value::String(s.clone())).collect();
80    root.insert("unchanged".into(), Value::Array(unchanged_arr));
81
82    Value::Object(root)
83}
84
85fn deep_equal(a: &Value, b: &Value) -> bool {
86    match (a, b) {
87        (Value::Null, Value::Null) => true,
88        (Value::Bool(x), Value::Bool(y)) => x == y,
89        (Value::Int(x), Value::Int(y)) => x == y,
90        (Value::Float(x), Value::Float(y)) => x == y,
91        (Value::String(x), Value::String(y)) => x == y,
92        (Value::Secret(x), Value::Secret(y)) => x == y,
93        (Value::Array(x), Value::Array(y)) => {
94            x.len() == y.len() && x.iter().zip(y.iter()).all(|(a, b)| deep_equal(a, b))
95        }
96        (Value::Object(x), Value::Object(y)) => {
97            x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|yv| deep_equal(v, yv)))
98        }
99        _ => false,
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn obj(pairs: Vec<(&str, Value)>) -> HashMap<String, Value> {
108        pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
109    }
110
111    #[test]
112    fn identical_objects() {
113        let a = obj(vec![("name", Value::String("John".into())), ("age", Value::Int(25))]);
114        let b = a.clone();
115        let d = diff(&a, &b);
116        assert!(d.added.is_empty());
117        assert!(d.removed.is_empty());
118        assert!(d.changed.is_empty());
119        assert_eq!(d.unchanged.len(), 2);
120    }
121
122    #[test]
123    fn added_and_removed() {
124        let a = obj(vec![("x", Value::Int(1))]);
125        let b = obj(vec![("y", Value::Int(2))]);
126        let d = diff(&a, &b);
127        assert_eq!(d.added.len(), 1);
128        assert_eq!(d.removed.len(), 1);
129        assert!(d.changed.is_empty());
130        assert!(d.unchanged.is_empty());
131    }
132
133    #[test]
134    fn changed_value() {
135        let a = obj(vec![("name", Value::String("Alice".into()))]);
136        let b = obj(vec![("name", Value::String("Bob".into()))]);
137        let d = diff(&a, &b);
138        assert_eq!(d.changed.len(), 1);
139        assert!(d.changed.contains_key("name"));
140    }
141
142    #[test]
143    fn nested_diff() {
144        let inner_a = obj(vec![("host", Value::String("localhost".into()))]);
145        let inner_b = obj(vec![("host", Value::String("0.0.0.0".into()))]);
146        let a = obj(vec![("server", Value::Object(inner_a))]);
147        let b = obj(vec![("server", Value::Object(inner_b))]);
148        let d = diff(&a, &b);
149        assert_eq!(d.changed.len(), 1);
150    }
151
152    #[test]
153    fn to_value_roundtrip() {
154        let a = obj(vec![("x", Value::Int(1)), ("y", Value::Int(2))]);
155        let b = obj(vec![("x", Value::Int(1)), ("z", Value::Int(3))]);
156        let d = diff(&a, &b);
157        let val = diff_to_value(&d);
158        assert!(matches!(val, Value::Object(_)));
159    }
160}