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            // Shrinking arrays emit a whole-array replacement at the
60            // prefix path rather than per-index nulls for dropped
61            // indices. `set_value_at_path` only mutates individual
62            // indices — it never truncates — so per-index nulls would
63            // leave phantom null slots at the tail of the array.
64            // Downstream ForEach reconciliation would iterate over those
65            // nulls, fail item-binding substitution (`item.id` on null
66            // has no field), and render rows showing the raw unresolved
67            // binding string ("null" / "@{item.…}"). Replacing the
68            // whole array sidesteps that and is cheaper anyway.
69            //
70            // Growing and same-length cases stay granular so a single
71            // leaf edit still produces the minimal `foods.K.field = …`
72            // delta, and `push("d")` still emits only `items.<newIdx>`
73            // (which matches the cross-SDK canonical contract exercised
74            // by `state.test.ts::identifies array element and length changes`).
75            if new_arr.len() < old_arr.len() && !prefix.is_empty() {
76                out.push(DiffEntry {
77                    path: prefix.to_string(),
78                    new_value: Value::Array(new_arr.clone()),
79                });
80                return;
81            }
82            let max_len = old_arr.len().max(new_arr.len());
83            for i in 0..max_len {
84                let path = join(prefix, &i.to_string());
85                match (old_arr.get(i), new_arr.get(i)) {
86                    (Some(o), Some(n)) => diff_into(&path, o, n, out),
87                    (None, Some(n)) => out.push(DiffEntry {
88                        path,
89                        new_value: n.clone(),
90                    }),
91                    (Some(_), None) => out.push(DiffEntry {
92                        path,
93                        new_value: Value::Null,
94                    }),
95                    (None, None) => unreachable!(),
96                }
97            }
98        }
99        (a, b) => {
100            if a != b && !prefix.is_empty() {
101                out.push(DiffEntry {
102                    path: prefix.to_string(),
103                    new_value: b.clone(),
104                });
105            }
106        }
107    }
108}
109
110fn join(prefix: &str, segment: &str) -> String {
111    if prefix.is_empty() {
112        segment.to_string()
113    } else {
114        let mut s = String::with_capacity(prefix.len() + 1 + segment.len());
115        s.push_str(prefix);
116        s.push('.');
117        s.push_str(segment);
118        s
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use serde_json::json;
126
127    #[test]
128    fn no_change_returns_empty() {
129        let a = json!({"count": 0, "name": "Alice"});
130        assert!(diff_paths(&a, &a.clone()).is_empty());
131    }
132
133    #[test]
134    fn scalar_change_at_top_level() {
135        let entries = diff_paths(&json!({"count": 0}), &json!({"count": 1}));
136        assert_eq!(
137            entries,
138            vec![DiffEntry {
139                path: "count".into(),
140                new_value: json!(1),
141            }]
142        );
143    }
144
145    #[test]
146    fn nested_object_change() {
147        let entries = diff_paths(
148            &json!({"user": {"name": "Alice", "age": 30}}),
149            &json!({"user": {"name": "Alice", "age": 31}}),
150        );
151        assert_eq!(
152            entries,
153            vec![DiffEntry {
154                path: "user.age".into(),
155                new_value: json!(31),
156            }]
157        );
158    }
159
160    #[test]
161    fn added_and_removed_keys() {
162        let entries = diff_paths(&json!({"a": 1, "b": 2}), &json!({"a": 1, "c": 3}));
163        // `b` was removed; `c` was added.
164        let mut paths: Vec<_> = entries.iter().map(|e| e.path.clone()).collect();
165        paths.sort();
166        assert_eq!(paths, vec!["b", "c"]);
167
168        let removed = entries.iter().find(|e| e.path == "b").unwrap();
169        assert_eq!(removed.new_value, Value::Null);
170
171        let added = entries.iter().find(|e| e.path == "c").unwrap();
172        assert_eq!(added.new_value, json!(3));
173    }
174
175    /// Paths at array indices >= 10 must serialise as their full
176    /// decimal representation ("10", "11", ...), not as a single
177    /// character.
178    #[test]
179    fn array_index_past_nine_uses_full_decimal() {
180        let mut old_items: Vec<Value> =
181            (0..12).map(|i| json!({"title": format!("t{i}")})).collect();
182        let new_items = old_items.clone();
183        // Only mutate index 10's title.
184        old_items[10] = json!({"title": "OLD"});
185
186        let entries = diff_paths(&json!({"items": old_items}), &json!({"items": new_items}));
187        assert_eq!(
188            entries,
189            vec![DiffEntry {
190                path: "items.10.title".into(),
191                new_value: json!("t10"),
192            }]
193        );
194    }
195
196    #[test]
197    fn array_length_change() {
198        // Growing array: new indices show up as additions.
199        let entries = diff_paths(&json!([1, 2]), &json!([1, 2, 3, 4]));
200        let mut paths: Vec<_> = entries.iter().map(|e| e.path.clone()).collect();
201        paths.sort();
202        assert_eq!(paths, vec!["2", "3"]);
203
204        // Shrinking array: dropped indices show up as deletions (Null).
205        let entries = diff_paths(&json!([1, 2, 3]), &json!([1]));
206        let mut entries = entries;
207        entries.sort_by(|a, b| a.path.cmp(&b.path));
208        assert_eq!(
209            entries,
210            vec![
211                DiffEntry {
212                    path: "1".into(),
213                    new_value: Value::Null,
214                },
215                DiffEntry {
216                    path: "2".into(),
217                    new_value: Value::Null,
218                },
219            ]
220        );
221    }
222
223    #[test]
224    fn nested_array_shrink_emits_whole_array() {
225        // A nested array that shrinks must emit a single whole-array
226        // entry at the parent path — not per-index nulls. Per-index
227        // nulls leak through `set_value_at_path`, which doesn't truncate
228        // arrays, leaving phantom null slots that break ForEach
229        // item-binding substitution downstream.
230        let old = json!({"foods": [
231            {"id": "1", "name": "A"},
232            {"id": "2", "name": "B"},
233            {"id": "3", "name": "C"}
234        ]});
235        let new = json!({"foods": [
236            {"id": "1", "name": "A"}
237        ]});
238
239        let entries = diff_paths(&old, &new);
240        assert_eq!(
241            entries.len(),
242            1,
243            "Shrinking nested array should emit exactly one whole-array entry, got {entries:?}"
244        );
245        assert_eq!(entries[0].path, "foods");
246        assert_eq!(
247            entries[0].new_value,
248            json!([{"id": "1", "name": "A"}]),
249            "Entry value must be the full new array"
250        );
251
252        // Same-length nested updates stay granular.
253        let old = json!({"foods": [
254            {"id": "1", "name": "A"},
255            {"id": "2", "name": "B"}
256        ]});
257        let new = json!({"foods": [
258            {"id": "1", "name": "A2"},
259            {"id": "2", "name": "B"}
260        ]});
261        let entries = diff_paths(&old, &new);
262        assert_eq!(
263            entries,
264            vec![DiffEntry {
265                path: "foods.0.name".into(),
266                new_value: json!("A2"),
267            }],
268            "Same-length update should produce a single granular leaf change"
269        );
270
271        // Growing nested arrays stay on the per-index path — matches the
272        // cross-SDK canonical contract exercised by
273        // `hypen-web/tests/state.test.ts::identifies array element and
274        // length changes`, which asserts `state.items.push("d")` emits
275        // a single `items.<newIdx>` path.
276        let old = json!({"items": ["a", "b"]});
277        let new = json!({"items": ["a", "b", "c"]});
278        let entries = diff_paths(&old, &new);
279        assert_eq!(
280            entries,
281            vec![DiffEntry {
282                path: "items.2".into(),
283                new_value: json!("c"),
284            }],
285            "Growing array should emit the new index only, not the whole array"
286        );
287    }
288
289    #[test]
290    fn type_change_emits_new_value() {
291        let entries = diff_paths(&json!({"x": 1}), &json!({"x": "one"}));
292        assert_eq!(
293            entries,
294            vec![DiffEntry {
295                path: "x".into(),
296                new_value: json!("one"),
297            }]
298        );
299    }
300
301    #[test]
302    fn root_scalar_identity_emits_nothing() {
303        // With an empty prefix we can't name the change; callers that care
304        // about root-level scalars should wrap their state in an object.
305        assert!(diff_paths(&json!(1), &json!(2)).is_empty());
306    }
307}