Skip to main content

kobold_json/
diff.rs

1//! `KOBOLD.JSON.DIFF.1` -- a per-path structural diff between two packets (or any two JSON values).
2//!
3//! The diff walks both trees in `actual`'s order (then reports keys present only in `expected`), emitting a
4//! [`DiffEntry`] per leaf difference with a JSON-pointer-ish path (`/fields/AMT/value`). It is deterministic
5//! and independent of GnuCOBOL/libcob. Useful to prove two evidence packets agree -- or to show exactly
6//! where a migration's actual output diverges from the expected golden packet.
7
8use crate::json::JsonValue;
9
10/// One difference between two values at a path.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DiffEntry {
13    /// A path like `/fields/AMT/value`.
14    pub path: String,
15    /// The kind of difference.
16    pub kind: DiffKind,
17}
18
19/// The kind of a [`DiffEntry`].
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DiffKind {
22    /// The path exists in both but the (leaf) values differ. Holds the rendered `actual` and `expected`.
23    Changed { actual: String, expected: String },
24    /// The path exists in `actual` but not in `expected`.
25    AddedInActual { actual: String },
26    /// The path exists in `expected` but not in `actual`.
27    MissingFromActual { expected: String },
28    /// The two values are different JSON kinds at this path (e.g. object vs string).
29    TypeChanged { actual: String, expected: String },
30}
31
32/// `KOBOLD.JSON.DIFF.1` -- list every per-path difference between `actual` and `expected`. An empty result
33/// means the two values are structurally identical.
34pub fn diff(actual: &JsonValue, expected: &JsonValue) -> Vec<DiffEntry> {
35    let mut out = Vec::new();
36    walk("", actual, expected, &mut out);
37    out
38}
39
40fn kind_name(v: &JsonValue) -> &'static str {
41    match v {
42        JsonValue::Null => "null",
43        JsonValue::Bool(_) => "bool",
44        JsonValue::Number(_) => "number",
45        JsonValue::String(_) => "string",
46        JsonValue::Array(_) => "array",
47        JsonValue::Object(_) => "object",
48    }
49}
50
51fn render(v: &JsonValue) -> String {
52    crate::json::to_string(v)
53}
54
55fn walk(path: &str, a: &JsonValue, e: &JsonValue, out: &mut Vec<DiffEntry>) {
56    match (a, e) {
57        (JsonValue::Object(am), JsonValue::Object(em)) => {
58            // Keys in actual order.
59            for (k, av) in am {
60                let child = format!("{}/{}", path, k);
61                match em.iter().find(|(ek, _)| ek == k) {
62                    Some((_, ev)) => walk(&child, av, ev, out),
63                    None => out.push(DiffEntry {
64                        path: child,
65                        kind: DiffKind::AddedInActual { actual: render(av) },
66                    }),
67                }
68            }
69            // Keys only in expected.
70            for (k, ev) in em {
71                if !am.iter().any(|(ak, _)| ak == k) {
72                    out.push(DiffEntry {
73                        path: format!("{}/{}", path, k),
74                        kind: DiffKind::MissingFromActual { expected: render(ev) },
75                    });
76                }
77            }
78        }
79        (JsonValue::Array(aa), JsonValue::Array(ea)) => {
80            let n = aa.len().max(ea.len());
81            for i in 0..n {
82                let child = format!("{}/{}", path, i);
83                match (aa.get(i), ea.get(i)) {
84                    (Some(av), Some(ev)) => walk(&child, av, ev, out),
85                    (Some(av), None) => out.push(DiffEntry {
86                        path: child,
87                        kind: DiffKind::AddedInActual { actual: render(av) },
88                    }),
89                    (None, Some(ev)) => out.push(DiffEntry {
90                        path: child,
91                        kind: DiffKind::MissingFromActual { expected: render(ev) },
92                    }),
93                    (None, None) => {}
94                }
95            }
96        }
97        _ => {
98            if kind_name(a) != kind_name(e) {
99                out.push(DiffEntry {
100                    path: path_or_root(path),
101                    kind: DiffKind::TypeChanged { actual: render(a), expected: render(e) },
102                });
103            } else if a != e {
104                out.push(DiffEntry {
105                    path: path_or_root(path),
106                    kind: DiffKind::Changed { actual: render(a), expected: render(e) },
107                });
108            }
109        }
110    }
111}
112
113fn path_or_root(path: &str) -> String {
114    if path.is_empty() {
115        "/".to_string()
116    } else {
117        path.to_string()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::json::parse;
125
126    #[test]
127    fn identical_packets_have_no_diff() {
128        let a = parse("{\"record\":\"R\",\"fields\":{\"A\":\"1\"}}").unwrap();
129        assert!(diff(&a, &a).is_empty());
130    }
131
132    #[test]
133    fn changed_value() {
134        let a = parse("{\"fields\":{\"AMT\":\"12.50\"}}").unwrap();
135        let e = parse("{\"fields\":{\"AMT\":\"99.99\"}}").unwrap();
136        let d = diff(&a, &e);
137        assert_eq!(d.len(), 1);
138        assert_eq!(d[0].path, "/fields/AMT");
139        assert_eq!(
140            d[0].kind,
141            DiffKind::Changed { actual: "\"12.50\"".into(), expected: "\"99.99\"".into() }
142        );
143    }
144
145    #[test]
146    fn added_and_missing_keys() {
147        let a = parse("{\"fields\":{\"A\":\"1\",\"B\":\"2\"}}").unwrap();
148        let e = parse("{\"fields\":{\"A\":\"1\",\"C\":\"3\"}}").unwrap();
149        let d = diff(&a, &e);
150        // B added in actual, C missing from actual
151        assert!(d.iter().any(|x| x.path == "/fields/B"
152            && matches!(x.kind, DiffKind::AddedInActual { .. })));
153        assert!(d.iter().any(|x| x.path == "/fields/C"
154            && matches!(x.kind, DiffKind::MissingFromActual { .. })));
155    }
156
157    #[test]
158    fn type_changed() {
159        let a = parse("{\"x\":\"1\"}").unwrap();
160        let e = parse("{\"x\":1}").unwrap();
161        let d = diff(&a, &e);
162        assert_eq!(d.len(), 1);
163        assert!(matches!(d[0].kind, DiffKind::TypeChanged { .. }));
164    }
165}