Skip to main content

zenkey_fleet/model/
diff.rs

1//! Payload diff (issue #63): what changed between two consecutive values of
2//! one key.
3//!
4//! For a keyspace-v2 `state` key this is *the* question — state is a LWW
5//! document, so "what moved" is what a reader actually wants, and re-reading
6//! two pretty-printed payloads side by side to find it is the thing explorers
7//! are supposed to save you from.
8//!
9//! Two levels, and which one ran is never hidden:
10//!
11//! - [`diff`] over two structural values ([`crate::model::decode::structural_value`])
12//!   — named fields, added/removed/changed;
13//! - [`byte_diff`] when either side has no structural form (plain text, a
14//!   protobuf frame, an opaque blob) — the honest fallback, reporting how much
15//!   of the two byte strings is common rather than pretending to name fields.
16//!
17//! Deliberately no notion of `Put` vs `Delete`: a tombstone is not a value and
18//! diffing it against one would be a category error. `SampleView::kind` is
19//! exact, and the frontend words the retirement.
20
21use serde_json::Value;
22
23// The shapes are wire contracts since #219 (`zenctl snapshot diff`), so they
24// live under `report/` per the placement rule; re-exported here so the
25// historical spelling `model::diff::{Change, ValueDiff}` keeps resolving.
26pub use crate::report::{ByteDiff, Change, ValueDiff};
27
28/// How deep a value is walked before the diff stops descending.
29///
30/// A bus carries whatever a foreign publisher sends, including deeply nested
31/// or self-referential-looking documents; the recursion is bounded for the
32/// same reason `zenkey`'s CDR resolver is. Past the bound, the subtree is
33/// compared whole and reported as one change.
34const MAX_DEPTH: usize = 32;
35
36/// Structurally compare two values, listing at most `max_changes` differences
37/// and counting the rest.
38pub fn diff(old: &Value, new: &Value, max_changes: usize) -> ValueDiff {
39    let mut out = ValueDiff::default();
40    walk(String::new(), old, new, max_changes, 0, &mut out);
41    out
42}
43
44/// Record a change, or count it as truncated once the budget is spent.
45fn push(out: &mut ValueDiff, max_changes: usize, change: Change) {
46    if out.changes.len() < max_changes {
47        out.changes.push(change);
48    } else {
49        out.truncated += 1;
50    }
51}
52
53fn join(prefix: &str, key: &str) -> String {
54    if prefix.is_empty() {
55        key.to_string()
56    } else {
57        format!("{prefix}.{key}")
58    }
59}
60
61fn walk(
62    path: String,
63    old: &Value,
64    new: &Value,
65    max_changes: usize,
66    depth: usize,
67    out: &mut ValueDiff,
68) {
69    if old == new {
70        return;
71    }
72    if depth >= MAX_DEPTH {
73        push(
74            out,
75            max_changes,
76            Change::Changed {
77                path,
78                old: old.clone(),
79                new: new.clone(),
80            },
81        );
82        return;
83    }
84    match (old, new) {
85        (Value::Object(a), Value::Object(b)) => {
86            for (k, av) in a {
87                match b.get(k) {
88                    Some(bv) => walk(join(&path, k), av, bv, max_changes, depth + 1, out),
89                    None => push(
90                        out,
91                        max_changes,
92                        Change::Removed {
93                            path: join(&path, k),
94                            old: av.clone(),
95                        },
96                    ),
97                }
98            }
99            for (k, bv) in b {
100                if !a.contains_key(k) {
101                    push(
102                        out,
103                        max_changes,
104                        Change::Added {
105                            path: join(&path, k),
106                            new: bv.clone(),
107                        },
108                    );
109                }
110            }
111        }
112        // Arrays are compared by index, not by identity: a bus payload's array
113        // is a positional field list far more often than it is a set, and
114        // guessing an element identity would invent moves that never happened.
115        (Value::Array(a), Value::Array(b)) => {
116            for (i, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
117                walk(
118                    join(&path, &i.to_string()),
119                    av,
120                    bv,
121                    max_changes,
122                    depth + 1,
123                    out,
124                );
125            }
126            for (i, av) in a.iter().enumerate().skip(b.len()) {
127                push(
128                    out,
129                    max_changes,
130                    Change::Removed {
131                        path: join(&path, &i.to_string()),
132                        old: av.clone(),
133                    },
134                );
135            }
136            for (i, bv) in b.iter().enumerate().skip(a.len()) {
137                push(
138                    out,
139                    max_changes,
140                    Change::Added {
141                        path: join(&path, &i.to_string()),
142                        new: bv.clone(),
143                    },
144                );
145            }
146        }
147        // Scalars, and any shape change (object → array, number → string):
148        // one change at this path, carrying both sides.
149        _ => push(
150            out,
151            max_changes,
152            Change::Changed {
153                path,
154                old: old.clone(),
155                new: new.clone(),
156            },
157        ),
158    }
159}
160
161/// Compare two payloads as bytes — the fallback when no structural form exists.
162pub fn byte_diff(old: &[u8], new: &[u8]) -> ByteDiff {
163    let common_prefix = old
164        .iter()
165        .zip(new.iter())
166        .take_while(|(a, b)| a == b)
167        .count();
168    // The suffix must not overlap the prefix, or a run of one repeated byte
169    // would report more bytes in common than either payload has.
170    let room = old.len().min(new.len()) - common_prefix;
171    let common_suffix = old
172        .iter()
173        .rev()
174        .zip(new.iter().rev())
175        .take(room)
176        .take_while(|(a, b)| a == b)
177        .count();
178    ByteDiff {
179        common_prefix,
180        common_suffix,
181        old_len: old.len(),
182        new_len: new.len(),
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use serde_json::json;
190
191    #[test]
192    fn a_changed_scalar_names_its_path_and_both_sides() {
193        let d = diff(
194            &json!({"value": 41.0, "unit": "percent"}),
195            &json!({"value": 42.0, "unit": "percent"}),
196            32,
197        );
198        assert_eq!(
199            d.changes,
200            [Change::Changed {
201                path: "value".into(),
202                old: json!(41.0),
203                new: json!(42.0),
204            }]
205        );
206        assert_eq!(d.truncated, 0);
207    }
208
209    #[test]
210    fn identical_values_produce_nothing() {
211        let d = diff(
212            &json!({"a": [1, 2, {"b": true}]}),
213            &json!({"a": [1, 2, {"b": true}]}),
214            32,
215        );
216        assert!(d.is_empty(), "no change is not the same as not looked");
217    }
218
219    #[test]
220    fn added_and_removed_fields_are_distinct_from_changed() {
221        let d = diff(
222            &json!({"a": 1, "gone": 2}),
223            &json!({"a": 1, "fresh": 3}),
224            32,
225        );
226        assert!(d.changes.contains(&Change::Removed {
227            path: "gone".into(),
228            old: json!(2)
229        }));
230        assert!(d.changes.contains(&Change::Added {
231            path: "fresh".into(),
232            new: json!(3)
233        }));
234        assert_eq!(d.changes.len(), 2);
235    }
236
237    #[test]
238    fn nesting_produces_dotted_paths() {
239        let d = diff(
240            &json!({"disk": {"var-log": {"used": 1}}}),
241            &json!({"disk": {"var-log": {"used": 2}}}),
242            32,
243        );
244        assert_eq!(d.changes[0].path(), "disk.var-log.used");
245    }
246
247    /// Arrays are positional: index 1 moved, and nothing claims the elements
248    /// were reordered.
249    #[test]
250    fn arrays_are_compared_by_index() {
251        let d = diff(&json!({"xs": [1, 2, 3]}), &json!({"xs": [1, 9, 3]}), 32);
252        assert_eq!(
253            d.changes,
254            [Change::Changed {
255                path: "xs.1".into(),
256                old: json!(2),
257                new: json!(9),
258            }]
259        );
260    }
261
262    #[test]
263    fn a_shorter_array_reports_the_tail_as_removed() {
264        let d = diff(&json!([1, 2, 3]), &json!([1]), 32);
265        assert_eq!(
266            d.changes,
267            [
268                Change::Removed {
269                    path: "1".into(),
270                    old: json!(2)
271                },
272                Change::Removed {
273                    path: "2".into(),
274                    old: json!(3)
275                },
276            ]
277        );
278    }
279
280    /// A type change is one change carrying both sides, not a remove plus an
281    /// add — the field is still the same field.
282    #[test]
283    fn a_shape_change_is_a_single_change() {
284        let d = diff(&json!({"a": {"b": 1}}), &json!({"a": [1]}), 32);
285        assert_eq!(d.changes.len(), 1);
286        assert_eq!(d.changes[0].path(), "a");
287    }
288
289    /// The bound is reported, never silently applied: a diff that stops at N
290    /// without saying so reads as "and nothing else changed".
291    #[test]
292    fn changes_past_the_bound_are_counted_not_dropped_silently() {
293        let old = json!({"a": 1, "b": 1, "c": 1, "d": 1, "e": 1});
294        let new = json!({"a": 2, "b": 2, "c": 2, "d": 2, "e": 2});
295        let d = diff(&old, &new, 2);
296        assert_eq!(d.changes.len(), 2);
297        assert_eq!(d.truncated, 3);
298        assert!(!d.is_empty());
299    }
300
301    #[test]
302    fn byte_diff_brackets_the_differing_run() {
303        let d = byte_diff(b"hello world", b"hello there");
304        assert_eq!(d.common_prefix, 6);
305        assert_eq!(d.old_len, 11);
306        assert_eq!(d.new_len, 11);
307        let (old, new) = d.ranges();
308        assert_eq!(&b"hello world"[old], b"world");
309        assert_eq!(&b"hello there"[new], b"there");
310        assert!(!d.is_empty());
311    }
312
313    #[test]
314    fn identical_bytes_are_empty() {
315        let d = byte_diff(b"same", b"same");
316        assert!(d.is_empty());
317        assert_eq!(d.common_prefix, 4);
318    }
319
320    /// A repeated byte must not let prefix and suffix double-count the same
321    /// bytes and claim more in common than the payload has.
322    #[test]
323    fn prefix_and_suffix_never_overlap() {
324        let d = byte_diff(b"aaaa", b"aaaaaa");
325        assert_eq!(d.common_prefix, 4);
326        assert_eq!(d.common_suffix, 0);
327        assert!(d.common_prefix + d.common_suffix <= d.old_len);
328    }
329}