Skip to main content

crw_diff/
json_diff.rs

1//! JSON-mode per-field diff. Walks two extractions and emits a map keyed by
2//! field path (`plans[0].price`, Firecrawl style) to `{previous, current}`
3//! pairs. Added fields have `previous: null`; removed fields `current: null`.
4
5use serde_json::{Map, Value};
6
7/// Compute the per-field diff between two extractions. Returns an empty object
8/// when nothing tracked changed.
9pub fn compute(previous: &Value, current: &Value) -> Value {
10    let mut out = Map::new();
11    walk("", previous, current, &mut out);
12    Value::Object(out)
13}
14
15/// True when the two extractions differ on any leaf.
16pub fn changed(previous: &Value, current: &Value) -> bool {
17    let mut out = Map::new();
18    walk("", previous, current, &mut out);
19    !out.is_empty()
20}
21
22fn record(path: &str, previous: Value, current: Value, out: &mut Map<String, Value>) {
23    let mut entry = Map::new();
24    entry.insert("previous".into(), previous);
25    entry.insert("current".into(), current);
26    out.insert(path.to_string(), Value::Object(entry));
27}
28
29fn walk(path: &str, prev: &Value, cur: &Value, out: &mut Map<String, Value>) {
30    match (prev, cur) {
31        (Value::Object(pm), Value::Object(cm)) => {
32            // union of keys
33            let mut keys: Vec<&String> = pm.keys().chain(cm.keys()).collect();
34            keys.sort();
35            keys.dedup();
36            for k in keys {
37                let child = if path.is_empty() {
38                    k.to_string()
39                } else {
40                    format!("{path}.{k}")
41                };
42                match (pm.get(k), cm.get(k)) {
43                    (Some(pv), Some(cv)) => walk(&child, pv, cv, out),
44                    (Some(pv), None) => record(&child, pv.clone(), Value::Null, out),
45                    (None, Some(cv)) => record(&child, Value::Null, cv.clone(), out),
46                    (None, None) => {}
47                }
48            }
49        }
50        (Value::Array(pa), Value::Array(ca)) => {
51            let max = pa.len().max(ca.len());
52            for i in 0..max {
53                let child = format!("{path}[{i}]");
54                match (pa.get(i), ca.get(i)) {
55                    (Some(pv), Some(cv)) => walk(&child, pv, cv, out),
56                    (Some(pv), None) => record(&child, pv.clone(), Value::Null, out),
57                    (None, Some(cv)) => record(&child, Value::Null, cv.clone(), out),
58                    (None, None) => {}
59                }
60            }
61        }
62        _ => {
63            if prev != cur {
64                record(path, prev.clone(), cur.clone(), out);
65            }
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use serde_json::json;
74
75    #[test]
76    fn no_change_is_empty() {
77        let a = json!({"plans": [{"price": "$19"}]});
78        assert!(!changed(&a, &a));
79        assert_eq!(compute(&a, &a), json!({}));
80    }
81
82    #[test]
83    fn leaf_change_keyed_by_path() {
84        let a = json!({"plans": [{"price": "$19"}, {"price": "$49"}]});
85        let b = json!({"plans": [{"price": "$24"}, {"price": "$49"}]});
86        let d = compute(&a, &b);
87        assert_eq!(
88            d["plans[0].price"],
89            json!({"previous": "$19", "current": "$24"})
90        );
91        assert!(d.get("plans[1].price").is_none());
92    }
93
94    #[test]
95    fn added_and_removed_fields() {
96        let a = json!({"a": 1});
97        let b = json!({"b": 2});
98        let d = compute(&a, &b);
99        assert_eq!(d["a"], json!({"previous": 1, "current": null}));
100        assert_eq!(d["b"], json!({"previous": null, "current": 2}));
101    }
102}