Skip to main content

hypen_engine/portable/
diff.rs

1//! JSON tree diff.
2//!
3//! Given `old` and `new` JSON values, return every leaf that changed as
4//! a `(path, new_value)` entry. Paths use dotted notation (`"user.name"`,
5//! `"items.0.title"`) so they feed directly into the engine's
6//! path-based reactive graph.
7
8use serde_json::Value;
9
10/// A single change detected by [`diff_paths`].
11#[derive(Debug, Clone, PartialEq)]
12pub struct DiffEntry {
13    /// Dotted path to the changed leaf, e.g. `"user.name"` or `"items.42.title"`.
14    pub path: String,
15    /// The new value at that path. `Value::Null` for deleted keys.
16    pub new_value: Value,
17}
18
19/// Recursively compare `old` and `new` and return every leaf that differs.
20///
21/// * Objects recurse by key; keys present only in `new` appear as
22///   `(path, new_value)`; keys present only in `old` appear as
23///   `(path, Null)`.
24/// * Arrays recurse index-by-index up to the longer length; indices
25///   beyond the shorter length appear as additions or removals.
26/// * Primitives compare by value.
27///
28/// Paths never include a leading dot; the root is the empty string and
29/// is never emitted on its own.
30pub fn diff_paths(old: &Value, new: &Value) -> Vec<DiffEntry> {
31    let mut out = Vec::new();
32    diff_into("", old, new, &mut out);
33    out
34}
35
36fn diff_into(prefix: &str, old: &Value, new: &Value, out: &mut Vec<DiffEntry>) {
37    match (old, new) {
38        (Value::Object(old_map), Value::Object(new_map)) => {
39            for (key, old_val) in old_map {
40                let path = join(prefix, key);
41                match new_map.get(key) {
42                    Some(new_val) => diff_into(&path, old_val, new_val, out),
43                    None => out.push(DiffEntry {
44                        path,
45                        new_value: Value::Null,
46                    }),
47                }
48            }
49            for (key, new_val) in new_map {
50                if !old_map.contains_key(key) {
51                    out.push(DiffEntry {
52                        path: join(prefix, key),
53                        new_value: new_val.clone(),
54                    });
55                }
56            }
57        }
58        (Value::Array(old_arr), Value::Array(new_arr)) => {
59            let max_len = old_arr.len().max(new_arr.len());
60            for i in 0..max_len {
61                let path = join(prefix, &i.to_string());
62                match (old_arr.get(i), new_arr.get(i)) {
63                    (Some(o), Some(n)) => diff_into(&path, o, n, out),
64                    (None, Some(n)) => out.push(DiffEntry {
65                        path,
66                        new_value: n.clone(),
67                    }),
68                    (Some(_), None) => out.push(DiffEntry {
69                        path,
70                        new_value: Value::Null,
71                    }),
72                    (None, None) => unreachable!(),
73                }
74            }
75        }
76        (a, b) => {
77            if a != b && !prefix.is_empty() {
78                out.push(DiffEntry {
79                    path: prefix.to_string(),
80                    new_value: b.clone(),
81                });
82            }
83        }
84    }
85}
86
87fn join(prefix: &str, segment: &str) -> String {
88    if prefix.is_empty() {
89        segment.to_string()
90    } else {
91        let mut s = String::with_capacity(prefix.len() + 1 + segment.len());
92        s.push_str(prefix);
93        s.push('.');
94        s.push_str(segment);
95        s
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use serde_json::json;
103
104    #[test]
105    fn no_change_returns_empty() {
106        let a = json!({"count": 0, "name": "Alice"});
107        assert!(diff_paths(&a, &a.clone()).is_empty());
108    }
109
110    #[test]
111    fn scalar_change_at_top_level() {
112        let entries = diff_paths(&json!({"count": 0}), &json!({"count": 1}));
113        assert_eq!(
114            entries,
115            vec![DiffEntry {
116                path: "count".into(),
117                new_value: json!(1),
118            }]
119        );
120    }
121
122    #[test]
123    fn nested_object_change() {
124        let entries = diff_paths(
125            &json!({"user": {"name": "Alice", "age": 30}}),
126            &json!({"user": {"name": "Alice", "age": 31}}),
127        );
128        assert_eq!(
129            entries,
130            vec![DiffEntry {
131                path: "user.age".into(),
132                new_value: json!(31),
133            }]
134        );
135    }
136
137    #[test]
138    fn added_and_removed_keys() {
139        let entries = diff_paths(&json!({"a": 1, "b": 2}), &json!({"a": 1, "c": 3}));
140        // `b` was removed; `c` was added.
141        let mut paths: Vec<_> = entries.iter().map(|e| e.path.clone()).collect();
142        paths.sort();
143        assert_eq!(paths, vec!["b", "c"]);
144
145        let removed = entries.iter().find(|e| e.path == "b").unwrap();
146        assert_eq!(removed.new_value, Value::Null);
147
148        let added = entries.iter().find(|e| e.path == "c").unwrap();
149        assert_eq!(added.new_value, json!(3));
150    }
151
152    /// Paths at array indices >= 10 must serialise as their full
153    /// decimal representation ("10", "11", ...), not as a single
154    /// character.
155    #[test]
156    fn array_index_past_nine_uses_full_decimal() {
157        let mut old_items: Vec<Value> = (0..12).map(|i| json!({"title": format!("t{i}")})).collect();
158        let new_items = old_items.clone();
159        // Only mutate index 10's title.
160        old_items[10] = json!({"title": "OLD"});
161
162        let entries = diff_paths(&json!({"items": old_items}), &json!({"items": new_items}));
163        assert_eq!(
164            entries,
165            vec![DiffEntry {
166                path: "items.10.title".into(),
167                new_value: json!("t10"),
168            }]
169        );
170    }
171
172    #[test]
173    fn array_length_change() {
174        // Growing array: new indices show up as additions.
175        let entries = diff_paths(&json!([1, 2]), &json!([1, 2, 3, 4]));
176        let mut paths: Vec<_> = entries.iter().map(|e| e.path.clone()).collect();
177        paths.sort();
178        assert_eq!(paths, vec!["2", "3"]);
179
180        // Shrinking array: dropped indices show up as deletions (Null).
181        let entries = diff_paths(&json!([1, 2, 3]), &json!([1]));
182        let mut entries = entries;
183        entries.sort_by(|a, b| a.path.cmp(&b.path));
184        assert_eq!(
185            entries,
186            vec![
187                DiffEntry {
188                    path: "1".into(),
189                    new_value: Value::Null,
190                },
191                DiffEntry {
192                    path: "2".into(),
193                    new_value: Value::Null,
194                },
195            ]
196        );
197    }
198
199    #[test]
200    fn type_change_emits_new_value() {
201        let entries = diff_paths(&json!({"x": 1}), &json!({"x": "one"}));
202        assert_eq!(
203            entries,
204            vec![DiffEntry {
205                path: "x".into(),
206                new_value: json!("one"),
207            }]
208        );
209    }
210
211    #[test]
212    fn root_scalar_identity_emits_nothing() {
213        // With an empty prefix we can't name the change; callers that care
214        // about root-level scalars should wrap their state in an object.
215        assert!(diff_paths(&json!(1), &json!(2)).is_empty());
216    }
217}