Skip to main content

philiprehberger_json_diff/
lib.rs

1//! Structural JSON diff with path tracking.
2//!
3//! # Example
4//!
5//! ```rust
6//! use philiprehberger_json_diff::diff;
7//! use serde_json::json;
8//!
9//! let a = json!({"name": "Alice", "age": 30});
10//! let b = json!({"name": "Alice", "age": 31});
11//! let changes = diff(&a, &b);
12//! assert_eq!(changes.len(), 1);
13//! ```
14
15use serde_json::Value;
16use std::fmt;
17
18/// The type of change detected between two JSON values.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ChangeType {
21    Added,
22    Removed,
23    Modified,
24}
25
26impl fmt::Display for ChangeType {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            ChangeType::Added => write!(f, "added"),
30            ChangeType::Removed => write!(f, "removed"),
31            ChangeType::Modified => write!(f, "modified"),
32        }
33    }
34}
35
36/// A single change between two JSON values.
37#[derive(Debug, Clone, PartialEq)]
38pub struct Change {
39    pub path: String,
40    pub change_type: ChangeType,
41    pub old_value: Option<Value>,
42    pub new_value: Option<Value>,
43}
44
45impl fmt::Display for Change {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self.change_type {
48            ChangeType::Added => {
49                write!(f, "+ {}: {}", self.path, self.new_value.as_ref().unwrap())
50            }
51            ChangeType::Removed => {
52                write!(f, "- {}: {}", self.path, self.old_value.as_ref().unwrap())
53            }
54            ChangeType::Modified => {
55                write!(
56                    f,
57                    "~ {}: {} -> {}",
58                    self.path,
59                    self.old_value.as_ref().unwrap(),
60                    self.new_value.as_ref().unwrap()
61                )
62            }
63        }
64    }
65}
66
67/// Summary counts of changes by type.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct DiffSummary {
70    pub added: usize,
71    pub removed: usize,
72    pub modified: usize,
73}
74
75/// Compute a structural diff between two JSON values.
76///
77/// Returns a list of changes with path tracking. Paths use dot notation
78/// for object keys and bracket notation for array indices.
79pub fn diff(a: &Value, b: &Value) -> Vec<Change> {
80    let mut changes = Vec::new();
81    diff_values(a, b, "", &mut changes);
82    changes
83}
84
85/// Summarize a list of changes by counting each type.
86pub fn diff_summary(changes: &[Change]) -> DiffSummary {
87    let mut added = 0;
88    let mut removed = 0;
89    let mut modified = 0;
90
91    for change in changes {
92        match change.change_type {
93            ChangeType::Added => added += 1,
94            ChangeType::Removed => removed += 1,
95            ChangeType::Modified => modified += 1,
96        }
97    }
98
99    DiffSummary {
100        added,
101        removed,
102        modified,
103    }
104}
105
106fn build_path(prefix: &str, key: &str) -> String {
107    if prefix.is_empty() {
108        key.to_string()
109    } else {
110        format!("{}.{}", prefix, key)
111    }
112}
113
114fn build_array_path(prefix: &str, index: usize) -> String {
115    format!("{}[{}]", prefix, index)
116}
117
118fn diff_values(a: &Value, b: &Value, path: &str, changes: &mut Vec<Change>) {
119    match (a, b) {
120        (Value::Object(map_a), Value::Object(map_b)) => {
121            // Keys in a
122            for (key, val_a) in map_a {
123                let child_path = build_path(path, key);
124                match map_b.get(key) {
125                    Some(val_b) => diff_values(val_a, val_b, &child_path, changes),
126                    None => changes.push(Change {
127                        path: child_path,
128                        change_type: ChangeType::Removed,
129                        old_value: Some(val_a.clone()),
130                        new_value: None,
131                    }),
132                }
133            }
134            // Keys only in b
135            for (key, val_b) in map_b {
136                if !map_a.contains_key(key) {
137                    let child_path = build_path(path, key);
138                    changes.push(Change {
139                        path: child_path,
140                        change_type: ChangeType::Added,
141                        old_value: None,
142                        new_value: Some(val_b.clone()),
143                    });
144                }
145            }
146        }
147        (Value::Array(arr_a), Value::Array(arr_b)) => {
148            let max_len = arr_a.len().max(arr_b.len());
149            for i in 0..max_len {
150                let child_path = build_array_path(path, i);
151                match (arr_a.get(i), arr_b.get(i)) {
152                    (Some(val_a), Some(val_b)) => {
153                        diff_values(val_a, val_b, &child_path, changes);
154                    }
155                    (Some(val_a), None) => {
156                        changes.push(Change {
157                            path: child_path,
158                            change_type: ChangeType::Removed,
159                            old_value: Some(val_a.clone()),
160                            new_value: None,
161                        });
162                    }
163                    (None, Some(val_b)) => {
164                        changes.push(Change {
165                            path: child_path,
166                            change_type: ChangeType::Added,
167                            old_value: None,
168                            new_value: Some(val_b.clone()),
169                        });
170                    }
171                    (None, None) => unreachable!(),
172                }
173            }
174        }
175        _ => {
176            if a != b {
177                changes.push(Change {
178                    path: path.to_string(),
179                    change_type: ChangeType::Modified,
180                    old_value: Some(a.clone()),
181                    new_value: Some(b.clone()),
182                });
183            }
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192
193    #[test]
194    fn no_changes() {
195        let a = json!({"name": "Alice", "age": 30});
196        let b = json!({"name": "Alice", "age": 30});
197        let changes = diff(&a, &b);
198        assert!(changes.is_empty());
199    }
200
201    #[test]
202    fn added_key() {
203        let a = json!({"name": "Alice"});
204        let b = json!({"name": "Alice", "age": 30});
205        let changes = diff(&a, &b);
206        assert_eq!(changes.len(), 1);
207        assert_eq!(changes[0].path, "age");
208        assert_eq!(changes[0].change_type, ChangeType::Added);
209        assert_eq!(changes[0].new_value, Some(json!(30)));
210        assert_eq!(changes[0].old_value, None);
211    }
212
213    #[test]
214    fn removed_key() {
215        let a = json!({"name": "Alice", "age": 30});
216        let b = json!({"name": "Alice"});
217        let changes = diff(&a, &b);
218        assert_eq!(changes.len(), 1);
219        assert_eq!(changes[0].path, "age");
220        assert_eq!(changes[0].change_type, ChangeType::Removed);
221        assert_eq!(changes[0].old_value, Some(json!(30)));
222        assert_eq!(changes[0].new_value, None);
223    }
224
225    #[test]
226    fn modified_value() {
227        let a = json!({"name": "Alice"});
228        let b = json!({"name": "Bob"});
229        let changes = diff(&a, &b);
230        assert_eq!(changes.len(), 1);
231        assert_eq!(changes[0].path, "name");
232        assert_eq!(changes[0].change_type, ChangeType::Modified);
233        assert_eq!(changes[0].old_value, Some(json!("Alice")));
234        assert_eq!(changes[0].new_value, Some(json!("Bob")));
235    }
236
237    #[test]
238    fn nested_diff() {
239        let a = json!({"user": {"name": "Alice", "age": 30}});
240        let b = json!({"user": {"name": "Alice", "age": 31}});
241        let changes = diff(&a, &b);
242        assert_eq!(changes.len(), 1);
243        assert_eq!(changes[0].path, "user.age");
244        assert_eq!(changes[0].change_type, ChangeType::Modified);
245    }
246
247    #[test]
248    fn array_diff() {
249        let a = json!({"tags": ["rust", "dev"]});
250        let b = json!({"tags": ["rust", "senior"]});
251        let changes = diff(&a, &b);
252        assert_eq!(changes.len(), 1);
253        assert_eq!(changes[0].path, "tags[1]");
254        assert_eq!(changes[0].change_type, ChangeType::Modified);
255    }
256
257    #[test]
258    fn array_added() {
259        let a = json!({"items": [1, 2]});
260        let b = json!({"items": [1, 2, 3]});
261        let changes = diff(&a, &b);
262        assert_eq!(changes.len(), 1);
263        assert_eq!(changes[0].path, "items[2]");
264        assert_eq!(changes[0].change_type, ChangeType::Added);
265        assert_eq!(changes[0].new_value, Some(json!(3)));
266    }
267
268    #[test]
269    fn array_removed() {
270        let a = json!({"items": [1, 2, 3]});
271        let b = json!({"items": [1, 2]});
272        let changes = diff(&a, &b);
273        assert_eq!(changes.len(), 1);
274        assert_eq!(changes[0].path, "items[2]");
275        assert_eq!(changes[0].change_type, ChangeType::Removed);
276        assert_eq!(changes[0].old_value, Some(json!(3)));
277    }
278
279    #[test]
280    fn type_change() {
281        let a = json!({"value": "hello"});
282        let b = json!({"value": 42});
283        let changes = diff(&a, &b);
284        assert_eq!(changes.len(), 1);
285        assert_eq!(changes[0].path, "value");
286        assert_eq!(changes[0].change_type, ChangeType::Modified);
287        assert_eq!(changes[0].old_value, Some(json!("hello")));
288        assert_eq!(changes[0].new_value, Some(json!(42)));
289    }
290
291    #[test]
292    fn summary() {
293        let a = json!({"a": 1, "b": 2, "c": 3});
294        let b = json!({"a": 1, "b": 5, "d": 4});
295        let changes = diff(&a, &b);
296        let summary = diff_summary(&changes);
297        assert_eq!(summary.added, 1);
298        assert_eq!(summary.removed, 1);
299        assert_eq!(summary.modified, 1);
300    }
301
302    #[test]
303    fn display_change_type() {
304        assert_eq!(format!("{}", ChangeType::Added), "added");
305        assert_eq!(format!("{}", ChangeType::Removed), "removed");
306        assert_eq!(format!("{}", ChangeType::Modified), "modified");
307    }
308
309    #[test]
310    fn display_change() {
311        let added = Change {
312            path: "name".to_string(),
313            change_type: ChangeType::Added,
314            old_value: None,
315            new_value: Some(json!("Alice")),
316        };
317        assert_eq!(format!("{}", added), "+ name: \"Alice\"");
318
319        let removed = Change {
320            path: "age".to_string(),
321            change_type: ChangeType::Removed,
322            old_value: Some(json!(30)),
323            new_value: None,
324        };
325        assert_eq!(format!("{}", removed), "- age: 30");
326
327        let modified = Change {
328            path: "score".to_string(),
329            change_type: ChangeType::Modified,
330            old_value: Some(json!(10)),
331            new_value: Some(json!(20)),
332        };
333        assert_eq!(format!("{}", modified), "~ score: 10 -> 20");
334    }
335
336    #[test]
337    fn complex_nested() {
338        let a = json!({
339            "users": [
340                {"name": "Alice", "roles": ["admin"]},
341                {"name": "Bob", "roles": ["user"]}
342            ],
343            "config": {
344                "debug": false,
345                "version": "1.0"
346            }
347        });
348        let b = json!({
349            "users": [
350                {"name": "Alice", "roles": ["admin", "super"]},
351                {"name": "Charlie", "roles": ["user"]}
352            ],
353            "config": {
354                "debug": true,
355                "version": "1.0",
356                "env": "prod"
357            }
358        });
359        let changes = diff(&a, &b);
360        let summary = diff_summary(&changes);
361
362        // roles[1] added, name modified (Bob->Charlie), debug modified, env added
363        assert!(summary.added >= 2);
364        assert!(summary.modified >= 2);
365
366        let paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
367        assert!(paths.contains(&"users[0].roles[1]"));
368        assert!(paths.contains(&"users[1].name"));
369        assert!(paths.contains(&"config.debug"));
370        assert!(paths.contains(&"config.env"));
371    }
372}