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 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 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 #[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 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 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 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 assert!(diff_paths(&json!(1), &json!(2)).is_empty());
216 }
217}