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    // Deterministic output: object-key iteration goes through HashSets, so
53    // change order would otherwise vary between runs — confusing in tables
54    // and noisy for scripts consuming JSON/markdown reports.
55    changes.sort_by(|a, b| a.path.cmp(&b.path));
56
57    DiffResult {
58        is_equal: changes.is_empty(),
59        changes,
60    }
61}
62
63fn diff_values(
64    old: &Value,
65    new: &Value,
66    path: &str,
67    identity_key: &str,
68    changes: &mut Vec<Change>,
69) {
70    match (old, new) {
71        (Value::Object(old_obj), Value::Object(new_obj)) => {
72            diff_objects(old_obj, new_obj, path, identity_key, changes);
73        }
74        (Value::Array(old_arr), Value::Array(new_arr)) => {
75            diff_arrays(old_arr, new_arr, path, identity_key, changes);
76        }
77        _ if old != new => {
78            changes.push(Change {
79                path: if path.is_empty() {
80                    ".".to_string()
81                } else {
82                    path.to_string()
83                },
84                kind: ChangeKind::Modified,
85                old_value: Some(old.clone()),
86                new_value: Some(new.clone()),
87                description: None,
88            });
89        }
90        _ => {}
91    }
92}
93
94fn diff_objects(
95    old: &serde_json::Map<String, Value>,
96    new: &serde_json::Map<String, Value>,
97    path: &str,
98    identity_key: &str,
99    changes: &mut Vec<Change>,
100) {
101    let old_keys: HashSet<_> = old.keys().collect();
102    let new_keys: HashSet<_> = new.keys().collect();
103
104    // Removed keys
105    for key in old_keys.difference(&new_keys) {
106        let key_path = format_path(path, key);
107        changes.push(Change {
108            path: key_path,
109            kind: ChangeKind::Removed,
110            old_value: old.get(*key).cloned(),
111            new_value: None,
112            description: None,
113        });
114    }
115
116    // Added keys
117    for key in new_keys.difference(&old_keys) {
118        let key_path = format_path(path, key);
119        changes.push(Change {
120            path: key_path,
121            kind: ChangeKind::Added,
122            old_value: None,
123            new_value: new.get(*key).cloned(),
124            description: None,
125        });
126    }
127
128    // Modified keys
129    for key in old_keys.intersection(&new_keys) {
130        let old_val = old.get(*key).unwrap();
131        let new_val = new.get(*key).unwrap();
132        let key_path = format_path(path, key);
133        diff_values(old_val, new_val, &key_path, identity_key, changes);
134    }
135}
136
137fn diff_arrays(
138    old: &[Value],
139    new: &[Value],
140    path: &str,
141    identity_key: &str,
142    changes: &mut Vec<Change>,
143) {
144    // Try semantic matching by identity key
145    let old_has_keys = old.iter().all(|v| v.get(identity_key).is_some());
146    let new_has_keys = new.iter().all(|v| v.get(identity_key).is_some());
147
148    if old_has_keys && new_has_keys {
149        diff_arrays_by_key(old, new, path, identity_key, changes);
150    } else {
151        diff_arrays_positional(old, new, path, identity_key, changes);
152    }
153}
154
155fn diff_arrays_by_key(
156    old: &[Value],
157    new: &[Value],
158    path: &str,
159    identity_key: &str,
160    changes: &mut Vec<Change>,
161) {
162    let old_map: BTreeMap<&str, &Value> = old
163        .iter()
164        .filter_map(|v| v.get(identity_key).and_then(|k| k.as_str()).map(|k| (k, v)))
165        .collect();
166
167    let new_map: BTreeMap<&str, &Value> = new
168        .iter()
169        .filter_map(|v| v.get(identity_key).and_then(|k| k.as_str()).map(|k| (k, v)))
170        .collect();
171
172    let old_keys: HashSet<_> = old_map.keys().cloned().collect();
173    let new_keys: HashSet<_> = new_map.keys().cloned().collect();
174
175    // Removed items
176    for key in old_keys.difference(&new_keys) {
177        let item_path = format!("{}[{}]", path, key);
178        changes.push(Change {
179            path: item_path,
180            kind: ChangeKind::Removed,
181            old_value: old_map.get(key).cloned().cloned(),
182            new_value: None,
183            description: None,
184        });
185    }
186
187    // Added items
188    for key in new_keys.difference(&old_keys) {
189        let item_path = format!("{}[{}]", path, key);
190        changes.push(Change {
191            path: item_path,
192            kind: ChangeKind::Added,
193            old_value: None,
194            new_value: new_map.get(key).cloned().cloned(),
195            description: None,
196        });
197    }
198
199    // Modified items
200    for key in old_keys.intersection(&new_keys) {
201        let old_val = old_map.get(key).unwrap();
202        let new_val = new_map.get(key).unwrap();
203        let item_path = format!("{}[{}]", path, key);
204        diff_values(old_val, new_val, &item_path, identity_key, changes);
205    }
206}
207
208fn diff_arrays_positional(
209    old: &[Value],
210    new: &[Value],
211    path: &str,
212    identity_key: &str,
213    changes: &mut Vec<Change>,
214) {
215    let max_len = old.len().max(new.len());
216
217    for i in 0..max_len {
218        let item_path = format!("{}[{}]", path, i);
219
220        match (old.get(i), new.get(i)) {
221            (Some(old_val), Some(new_val)) => {
222                diff_values(old_val, new_val, &item_path, identity_key, changes);
223            }
224            (Some(old_val), None) => {
225                changes.push(Change {
226                    path: item_path,
227                    kind: ChangeKind::Removed,
228                    old_value: Some(old_val.clone()),
229                    new_value: None,
230                    description: None,
231                });
232            }
233            (None, Some(new_val)) => {
234                changes.push(Change {
235                    path: item_path,
236                    kind: ChangeKind::Added,
237                    old_value: None,
238                    new_value: Some(new_val.clone()),
239                    description: None,
240                });
241            }
242            (None, None) => unreachable!(),
243        }
244    }
245}
246
247fn format_path(base: &str, key: &str) -> String {
248    if base.is_empty() {
249        key.to_string()
250    } else {
251        format!("{}.{}", base, key)
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use serde_json::json;
259
260    #[test]
261    fn test_equal_values() {
262        let old = json!({"name": "test", "value": 42});
263        let new = json!({"name": "test", "value": 42});
264
265        let result = diff(&old, &new, "name");
266        assert!(result.is_equal);
267        assert!(result.changes.is_empty());
268    }
269
270    #[test]
271    fn test_added_field() {
272        let old = json!({"name": "test"});
273        let new = json!({"name": "test", "value": 42});
274
275        let result = diff(&old, &new, "name");
276        assert!(!result.is_equal);
277        assert_eq!(result.changes.len(), 1);
278        assert_eq!(result.changes[0].kind, ChangeKind::Added);
279        assert_eq!(result.changes[0].path, "value");
280    }
281
282    #[test]
283    fn test_removed_field() {
284        let old = json!({"name": "test", "value": 42});
285        let new = json!({"name": "test"});
286
287        let result = diff(&old, &new, "name");
288        assert!(!result.is_equal);
289        assert_eq!(result.changes.len(), 1);
290        assert_eq!(result.changes[0].kind, ChangeKind::Removed);
291        assert_eq!(result.changes[0].path, "value");
292    }
293
294    #[test]
295    fn test_modified_field() {
296        let old = json!({"name": "test", "value": 42});
297        let new = json!({"name": "test", "value": 100});
298
299        let result = diff(&old, &new, "name");
300        assert!(!result.is_equal);
301        assert_eq!(result.changes.len(), 1);
302        assert_eq!(result.changes[0].kind, ChangeKind::Modified);
303        assert_eq!(result.changes[0].path, "value");
304    }
305
306    #[test]
307    fn test_array_by_key() {
308        let old = json!({
309            "items": [
310                {"name": "a", "value": 1},
311                {"name": "b", "value": 2}
312            ]
313        });
314        let new = json!({
315            "items": [
316                {"name": "b", "value": 2},
317                {"name": "c", "value": 3}
318            ]
319        });
320
321        let result = diff(&old, &new, "name");
322        assert!(!result.is_equal);
323
324        // Should detect: removed "a", added "c"
325        let removed: Vec<_> = result
326            .changes
327            .iter()
328            .filter(|c| c.kind == ChangeKind::Removed)
329            .collect();
330        let added: Vec<_> = result
331            .changes
332            .iter()
333            .filter(|c| c.kind == ChangeKind::Added)
334            .collect();
335
336        assert_eq!(removed.len(), 1);
337        assert!(removed[0].path.contains("[a]"));
338
339        assert_eq!(added.len(), 1);
340        assert!(added[0].path.contains("[c]"));
341    }
342
343    #[test]
344    fn change_order_is_deterministic_and_sorted_by_path() {
345        // Object keys iterate via HashSet internally; the public result must
346        // still be stable across runs.
347        let old = serde_json::json!({"name": "a", "zeta": 1, "alpha": 1, "mid": 1});
348        let new = serde_json::json!({"name": "a", "zeta": 2, "alpha": 2, "mid": 2});
349        let paths: Vec<String> = diff(&old, &new, "name")
350            .changes
351            .into_iter()
352            .map(|c| c.path)
353            .collect();
354        assert_eq!(paths, vec!["alpha", "mid", "zeta"]);
355    }
356}