1use serde_json::Value;
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct DiffEntry {
13 pub path: String,
15 pub new_value: Value,
17}
18
19pub 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 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 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 #[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 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 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 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 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 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 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 assert!(diff_paths(&json!(1), &json!(2)).is_empty());
306 }
307}