Skip to main content

rigg_diff/
semantic.rs

1//! Semantic JSON diff algorithm for Azure AI Search resources
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{BTreeMap, HashSet};
6
7/// Result of comparing two JSON values
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DiffResult {
10    /// Whether the values are identical
11    pub is_equal: bool,
12    /// List of changes
13    pub changes: Vec<Change>,
14}
15
16/// A single change between two JSON values
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Change {
19    /// JSON path to the changed value
20    pub path: String,
21    /// Type of change
22    pub kind: ChangeKind,
23    /// Old value (for modifications and deletions)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub old_value: Option<Value>,
26    /// New value (for modifications and additions)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub new_value: Option<Value>,
29    /// Human-readable description of this change (set by higher layers, not by diff engine)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub description: Option<String>,
32}
33
34/// Type of change
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ChangeKind {
38    Added,
39    Removed,
40    Modified,
41}
42
43/// Compute semantic diff between two JSON values
44///
45/// This differs from a standard JSON diff by:
46/// - Using key-based matching for arrays (by identity_key, typically "name")
47/// - Producing human-readable paths
48/// - Ignoring order for objects
49pub fn diff(old: &Value, new: &Value, identity_key: &str) -> DiffResult {
50    let mut changes = Vec::new();
51    diff_values(old, new, "", identity_key, &mut changes);
52
53    DiffResult {
54        is_equal: changes.is_empty(),
55        changes,
56    }
57}
58
59fn diff_values(
60    old: &Value,
61    new: &Value,
62    path: &str,
63    identity_key: &str,
64    changes: &mut Vec<Change>,
65) {
66    match (old, new) {
67        (Value::Object(old_obj), Value::Object(new_obj)) => {
68            diff_objects(old_obj, new_obj, path, identity_key, changes);
69        }
70        (Value::Array(old_arr), Value::Array(new_arr)) => {
71            diff_arrays(old_arr, new_arr, path, identity_key, changes);
72        }
73        _ if old != new => {
74            changes.push(Change {
75                path: if path.is_empty() {
76                    ".".to_string()
77                } else {
78                    path.to_string()
79                },
80                kind: ChangeKind::Modified,
81                old_value: Some(old.clone()),
82                new_value: Some(new.clone()),
83                description: None,
84            });
85        }
86        _ => {}
87    }
88}
89
90fn diff_objects(
91    old: &serde_json::Map<String, Value>,
92    new: &serde_json::Map<String, Value>,
93    path: &str,
94    identity_key: &str,
95    changes: &mut Vec<Change>,
96) {
97    let old_keys: HashSet<_> = old.keys().collect();
98    let new_keys: HashSet<_> = new.keys().collect();
99
100    // Removed keys
101    for key in old_keys.difference(&new_keys) {
102        let key_path = format_path(path, key);
103        changes.push(Change {
104            path: key_path,
105            kind: ChangeKind::Removed,
106            old_value: old.get(*key).cloned(),
107            new_value: None,
108            description: None,
109        });
110    }
111
112    // Added keys
113    for key in new_keys.difference(&old_keys) {
114        let key_path = format_path(path, key);
115        changes.push(Change {
116            path: key_path,
117            kind: ChangeKind::Added,
118            old_value: None,
119            new_value: new.get(*key).cloned(),
120            description: None,
121        });
122    }
123
124    // Modified keys
125    for key in old_keys.intersection(&new_keys) {
126        let old_val = old.get(*key).unwrap();
127        let new_val = new.get(*key).unwrap();
128        let key_path = format_path(path, key);
129        diff_values(old_val, new_val, &key_path, identity_key, changes);
130    }
131}
132
133fn diff_arrays(
134    old: &[Value],
135    new: &[Value],
136    path: &str,
137    identity_key: &str,
138    changes: &mut Vec<Change>,
139) {
140    // Try semantic matching by identity key
141    let old_has_keys = old.iter().all(|v| v.get(identity_key).is_some());
142    let new_has_keys = new.iter().all(|v| v.get(identity_key).is_some());
143
144    if old_has_keys && new_has_keys {
145        diff_arrays_by_key(old, new, path, identity_key, changes);
146    } else {
147        diff_arrays_positional(old, new, path, identity_key, changes);
148    }
149}
150
151fn diff_arrays_by_key(
152    old: &[Value],
153    new: &[Value],
154    path: &str,
155    identity_key: &str,
156    changes: &mut Vec<Change>,
157) {
158    let old_map: BTreeMap<&str, &Value> = old
159        .iter()
160        .filter_map(|v| v.get(identity_key).and_then(|k| k.as_str()).map(|k| (k, v)))
161        .collect();
162
163    let new_map: BTreeMap<&str, &Value> = new
164        .iter()
165        .filter_map(|v| v.get(identity_key).and_then(|k| k.as_str()).map(|k| (k, v)))
166        .collect();
167
168    let old_keys: HashSet<_> = old_map.keys().cloned().collect();
169    let new_keys: HashSet<_> = new_map.keys().cloned().collect();
170
171    // Removed items
172    for key in old_keys.difference(&new_keys) {
173        let item_path = format!("{}[{}]", path, key);
174        changes.push(Change {
175            path: item_path,
176            kind: ChangeKind::Removed,
177            old_value: old_map.get(key).cloned().cloned(),
178            new_value: None,
179            description: None,
180        });
181    }
182
183    // Added items
184    for key in new_keys.difference(&old_keys) {
185        let item_path = format!("{}[{}]", path, key);
186        changes.push(Change {
187            path: item_path,
188            kind: ChangeKind::Added,
189            old_value: None,
190            new_value: new_map.get(key).cloned().cloned(),
191            description: None,
192        });
193    }
194
195    // Modified items
196    for key in old_keys.intersection(&new_keys) {
197        let old_val = old_map.get(key).unwrap();
198        let new_val = new_map.get(key).unwrap();
199        let item_path = format!("{}[{}]", path, key);
200        diff_values(old_val, new_val, &item_path, identity_key, changes);
201    }
202}
203
204fn diff_arrays_positional(
205    old: &[Value],
206    new: &[Value],
207    path: &str,
208    identity_key: &str,
209    changes: &mut Vec<Change>,
210) {
211    let max_len = old.len().max(new.len());
212
213    for i in 0..max_len {
214        let item_path = format!("{}[{}]", path, i);
215
216        match (old.get(i), new.get(i)) {
217            (Some(old_val), Some(new_val)) => {
218                diff_values(old_val, new_val, &item_path, identity_key, changes);
219            }
220            (Some(old_val), None) => {
221                changes.push(Change {
222                    path: item_path,
223                    kind: ChangeKind::Removed,
224                    old_value: Some(old_val.clone()),
225                    new_value: None,
226                    description: None,
227                });
228            }
229            (None, Some(new_val)) => {
230                changes.push(Change {
231                    path: item_path,
232                    kind: ChangeKind::Added,
233                    old_value: None,
234                    new_value: Some(new_val.clone()),
235                    description: None,
236                });
237            }
238            (None, None) => unreachable!(),
239        }
240    }
241}
242
243fn format_path(base: &str, key: &str) -> String {
244    if base.is_empty() {
245        key.to_string()
246    } else {
247        format!("{}.{}", base, key)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use serde_json::json;
255
256    #[test]
257    fn test_equal_values() {
258        let old = json!({"name": "test", "value": 42});
259        let new = json!({"name": "test", "value": 42});
260
261        let result = diff(&old, &new, "name");
262        assert!(result.is_equal);
263        assert!(result.changes.is_empty());
264    }
265
266    #[test]
267    fn test_added_field() {
268        let old = json!({"name": "test"});
269        let new = json!({"name": "test", "value": 42});
270
271        let result = diff(&old, &new, "name");
272        assert!(!result.is_equal);
273        assert_eq!(result.changes.len(), 1);
274        assert_eq!(result.changes[0].kind, ChangeKind::Added);
275        assert_eq!(result.changes[0].path, "value");
276    }
277
278    #[test]
279    fn test_removed_field() {
280        let old = json!({"name": "test", "value": 42});
281        let new = json!({"name": "test"});
282
283        let result = diff(&old, &new, "name");
284        assert!(!result.is_equal);
285        assert_eq!(result.changes.len(), 1);
286        assert_eq!(result.changes[0].kind, ChangeKind::Removed);
287        assert_eq!(result.changes[0].path, "value");
288    }
289
290    #[test]
291    fn test_modified_field() {
292        let old = json!({"name": "test", "value": 42});
293        let new = json!({"name": "test", "value": 100});
294
295        let result = diff(&old, &new, "name");
296        assert!(!result.is_equal);
297        assert_eq!(result.changes.len(), 1);
298        assert_eq!(result.changes[0].kind, ChangeKind::Modified);
299        assert_eq!(result.changes[0].path, "value");
300    }
301
302    #[test]
303    fn test_array_by_key() {
304        let old = json!({
305            "items": [
306                {"name": "a", "value": 1},
307                {"name": "b", "value": 2}
308            ]
309        });
310        let new = json!({
311            "items": [
312                {"name": "b", "value": 2},
313                {"name": "c", "value": 3}
314            ]
315        });
316
317        let result = diff(&old, &new, "name");
318        assert!(!result.is_equal);
319
320        // Should detect: removed "a", added "c"
321        let removed: Vec<_> = result
322            .changes
323            .iter()
324            .filter(|c| c.kind == ChangeKind::Removed)
325            .collect();
326        let added: Vec<_> = result
327            .changes
328            .iter()
329            .filter(|c| c.kind == ChangeKind::Added)
330            .collect();
331
332        assert_eq!(removed.len(), 1);
333        assert!(removed[0].path.contains("[a]"));
334
335        assert_eq!(added.len(), 1);
336        assert!(added[0].path.contains("[c]"));
337    }
338}