Skip to main content

alp_core/
diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Structural diff between two JSON values — a port of the TS `diff` command's
3//! `collectDiffEntries` / `collectRecursiveDiff`. Used to surface what
4//! `normalize_board_model` changed relative to the parsed board.yaml.
5//!
6//! Rust serializes `Option::None` fields as `null`, whereas the TS model is
7//! sparse (absent keys are `undefined`). [`prune_nulls`] drops null-valued
8//! object entries so both sides diff the same shape the TS CLI does.
9
10use serde::Serialize;
11use serde_json::{Map, Value};
12
13/// Classifies a single `DiffEntry`. Serializes lowercase (`added`/`removed`/`changed`).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum DiffKind {
17    /// Key/element present only in `after`.
18    Added,
19    /// Key/element present only in `before`.
20    Removed,
21    /// Leaf value present in both but unequal.
22    Changed,
23}
24
25/// One difference at a given path. `before`/`after` are omitted from JSON when `None`.
26#[derive(Debug, Clone, Serialize)]
27pub struct DiffEntry {
28    /// Dotted/bracketed location, e.g. `models.foo` or `cores[0].name`; `<root>` for a top-level scalar change.
29    pub path: String,
30    /// Whether the entry was added, removed, or changed.
31    pub kind: DiffKind,
32    /// Value at `path` in `before`; `None` for `Added`.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub before: Option<Value>,
35    /// Value at `path` in `after`; `None` for `Removed`.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub after: Option<Value>,
38}
39
40/// Recursively remove `null`-valued object entries (object keys only; array
41/// elements are preserved so indices stay stable), mirroring how JS omits
42/// `undefined` object properties.
43pub fn prune_nulls(value: Value) -> Value {
44    match value {
45        Value::Object(map) => {
46            let mut pruned = Map::new();
47            for (key, val) in map {
48                if val.is_null() {
49                    continue;
50                }
51                pruned.insert(key, prune_nulls(val));
52            }
53            Value::Object(pruned)
54        }
55        Value::Array(items) => Value::Array(items.into_iter().map(prune_nulls).collect()),
56        other => other,
57    }
58}
59
60/// Collect every difference between `before` and `after`, sorted by path.
61pub fn collect_diff_entries(before: &Value, after: &Value) -> Vec<DiffEntry> {
62    let mut output = Vec::new();
63    collect_recursive(before, after, "", &mut output);
64    output.sort_by(|a, b| a.path.cmp(&b.path));
65    output
66}
67
68fn collect_recursive(
69    before: &Value,
70    after: &Value,
71    current_path: &str,
72    output: &mut Vec<DiffEntry>,
73) {
74    if before == after {
75        return;
76    }
77
78    if let (Value::Object(before_map), Value::Object(after_map)) = (before, after) {
79        let mut keys: Vec<&String> = before_map.keys().chain(after_map.keys()).collect();
80        keys.sort();
81        keys.dedup();
82
83        for key in keys {
84            let next_path = if current_path.is_empty() {
85                key.clone()
86            } else {
87                format!("{current_path}.{key}")
88            };
89            match (before_map.get(key), after_map.get(key)) {
90                (None, Some(after_val)) => output.push(DiffEntry {
91                    path: next_path,
92                    kind: DiffKind::Added,
93                    before: None,
94                    after: Some(after_val.clone()),
95                }),
96                (Some(before_val), None) => output.push(DiffEntry {
97                    path: next_path,
98                    kind: DiffKind::Removed,
99                    before: Some(before_val.clone()),
100                    after: None,
101                }),
102                (Some(before_val), Some(after_val)) => {
103                    collect_recursive(before_val, after_val, &next_path, output)
104                }
105                (None, None) => {}
106            }
107        }
108        return;
109    }
110
111    if let (Value::Array(before_arr), Value::Array(after_arr)) = (before, after) {
112        let max_len = before_arr.len().max(after_arr.len());
113        for index in 0..max_len {
114            let next_path = format!("{current_path}[{index}]");
115            match (before_arr.get(index), after_arr.get(index)) {
116                (Some(before_val), Some(after_val)) => {
117                    collect_recursive(before_val, after_val, &next_path, output)
118                }
119                (None, Some(after_val)) => output.push(DiffEntry {
120                    path: next_path,
121                    kind: DiffKind::Added,
122                    before: None,
123                    after: Some(after_val.clone()),
124                }),
125                (Some(before_val), None) => output.push(DiffEntry {
126                    path: next_path,
127                    kind: DiffKind::Removed,
128                    before: Some(before_val.clone()),
129                    after: None,
130                }),
131                (None, None) => {}
132            }
133        }
134        return;
135    }
136
137    output.push(DiffEntry {
138        path: if current_path.is_empty() {
139            "<root>".to_string()
140        } else {
141            current_path.to_string()
142        },
143        kind: DiffKind::Changed,
144        before: Some(before.clone()),
145        after: Some(after.clone()),
146    });
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153
154    #[test]
155    fn identical_values_have_no_diff() {
156        let v = json!({"a": 1, "b": [1, 2]});
157        assert!(collect_diff_entries(&v, &v).is_empty());
158    }
159
160    #[test]
161    fn detects_removed_added_changed_sorted_by_path() {
162        let before = json!({"keep": 1, "gone": {"x": 1}, "moved": "a"});
163        let after = json!({"keep": 1, "added": true, "moved": "b"});
164        let diff = collect_diff_entries(&before, &after);
165        // Paths sorted: "added", "gone", "moved"
166        assert_eq!(diff.len(), 3);
167        assert_eq!(diff[0].path, "added");
168        assert_eq!(diff[0].kind, DiffKind::Added);
169        assert_eq!(diff[1].path, "gone");
170        assert_eq!(diff[1].kind, DiffKind::Removed);
171        assert_eq!(diff[1].before, Some(json!({"x": 1})));
172        assert_eq!(diff[2].path, "moved");
173        assert_eq!(diff[2].kind, DiffKind::Changed);
174        assert_eq!(diff[2].before, Some(json!("a")));
175        assert_eq!(diff[2].after, Some(json!("b")));
176    }
177
178    #[test]
179    fn prune_nulls_drops_object_nulls_keeps_empty_objects() {
180        let pruned = prune_nulls(json!({"a": null, "b": {"c": null, "d": 1}, "e": {}}));
181        assert_eq!(pruned, json!({"b": {"d": 1}, "e": {}}));
182    }
183
184    #[test]
185    fn added_entry_omits_before_in_json() {
186        let before = json!({});
187        let after = json!({"x": 1});
188        let diff = collect_diff_entries(&before, &after);
189        let serialized = serde_json::to_string(&diff[0]).unwrap();
190        assert!(!serialized.contains("\"before\""));
191        assert!(serialized.contains("\"after\":1"));
192        assert!(serialized.contains("\"kind\":\"added\""));
193    }
194}