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/// How deep a value is walked before the diff stops descending.
24///
25/// A bus carries whatever a foreign publisher sends, including deeply nested
26/// or self-referential-looking documents; the recursion is bounded for the
27/// same reason `zenkey`'s CDR resolver is. Past the bound, the subtree is
28/// compared whole and reported as one change.
29const MAX_DEPTH: usize = 32;
30
31/// One field-level difference, addressed by a dotted path (`disk.used`,
32/// `items.0.name`).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Change {
35    /// The path is present in the new value and absent from the old.
36    Added { path: String, new: Value },
37    /// The path is present in the old value and absent from the new.
38    Removed { path: String, old: Value },
39    /// The path is in both and its value moved.
40    Changed {
41        path: String,
42        old: Value,
43        new: Value,
44    },
45}
46
47impl Change {
48    pub fn path(&self) -> &str {
49        match self {
50            Change::Added { path, .. }
51            | Change::Removed { path, .. }
52            | Change::Changed { path, .. } => path,
53        }
54    }
55}
56
57/// The result of comparing two structural values.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct ValueDiff {
60    pub changes: Vec<Change>,
61    /// Changes found past `max_changes` and therefore not listed.
62    ///
63    /// Counted rather than silently cut: a bounded view that reports what it
64    /// dropped is the RFC 09 §5.1 O6 rule, and a diff that quietly stops at
65    /// twenty entries reads as "and nothing else changed".
66    pub truncated: usize,
67}
68
69impl ValueDiff {
70    /// No change at all — distinct from "we did not look".
71    pub fn is_empty(&self) -> bool {
72        self.changes.is_empty() && self.truncated == 0
73    }
74}
75
76/// Structurally compare two values, listing at most `max_changes` differences
77/// and counting the rest.
78pub fn diff(old: &Value, new: &Value, max_changes: usize) -> ValueDiff {
79    let mut out = ValueDiff::default();
80    walk(String::new(), old, new, max_changes, 0, &mut out);
81    out
82}
83
84/// Record a change, or count it as truncated once the budget is spent.
85fn push(out: &mut ValueDiff, max_changes: usize, change: Change) {
86    if out.changes.len() < max_changes {
87        out.changes.push(change);
88    } else {
89        out.truncated += 1;
90    }
91}
92
93fn join(prefix: &str, key: &str) -> String {
94    if prefix.is_empty() {
95        key.to_string()
96    } else {
97        format!("{prefix}.{key}")
98    }
99}
100
101fn walk(
102    path: String,
103    old: &Value,
104    new: &Value,
105    max_changes: usize,
106    depth: usize,
107    out: &mut ValueDiff,
108) {
109    if old == new {
110        return;
111    }
112    if depth >= MAX_DEPTH {
113        push(
114            out,
115            max_changes,
116            Change::Changed {
117                path,
118                old: old.clone(),
119                new: new.clone(),
120            },
121        );
122        return;
123    }
124    match (old, new) {
125        (Value::Object(a), Value::Object(b)) => {
126            for (k, av) in a {
127                match b.get(k) {
128                    Some(bv) => walk(join(&path, k), av, bv, max_changes, depth + 1, out),
129                    None => push(
130                        out,
131                        max_changes,
132                        Change::Removed {
133                            path: join(&path, k),
134                            old: av.clone(),
135                        },
136                    ),
137                }
138            }
139            for (k, bv) in b {
140                if !a.contains_key(k) {
141                    push(
142                        out,
143                        max_changes,
144                        Change::Added {
145                            path: join(&path, k),
146                            new: bv.clone(),
147                        },
148                    );
149                }
150            }
151        }
152        // Arrays are compared by index, not by identity: a bus payload's array
153        // is a positional field list far more often than it is a set, and
154        // guessing an element identity would invent moves that never happened.
155        (Value::Array(a), Value::Array(b)) => {
156            for (i, (av, bv)) in a.iter().zip(b.iter()).enumerate() {
157                walk(
158                    join(&path, &i.to_string()),
159                    av,
160                    bv,
161                    max_changes,
162                    depth + 1,
163                    out,
164                );
165            }
166            for (i, av) in a.iter().enumerate().skip(b.len()) {
167                push(
168                    out,
169                    max_changes,
170                    Change::Removed {
171                        path: join(&path, &i.to_string()),
172                        old: av.clone(),
173                    },
174                );
175            }
176            for (i, bv) in b.iter().enumerate().skip(a.len()) {
177                push(
178                    out,
179                    max_changes,
180                    Change::Added {
181                        path: join(&path, &i.to_string()),
182                        new: bv.clone(),
183                    },
184                );
185            }
186        }
187        // Scalars, and any shape change (object → array, number → string):
188        // one change at this path, carrying both sides.
189        _ => push(
190            out,
191            max_changes,
192            Change::Changed {
193                path,
194                old: old.clone(),
195                new: new.clone(),
196            },
197        ),
198    }
199}
200
201/// What a byte comparison can honestly say when neither side is structural.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
203pub struct ByteDiff {
204    pub common_prefix: usize,
205    pub common_suffix: usize,
206    pub old_len: usize,
207    pub new_len: usize,
208}
209
210impl ByteDiff {
211    /// True when the two byte strings are identical.
212    pub fn is_empty(&self) -> bool {
213        self.old_len == self.new_len && self.common_prefix == self.old_len
214    }
215
216    /// The half-open byte range that differs on each side: `(old, new)`.
217    ///
218    /// Both start at `common_prefix`; both end where the common suffix begins.
219    pub fn ranges(&self) -> (std::ops::Range<usize>, std::ops::Range<usize>) {
220        (
221            self.common_prefix..self.old_len - self.common_suffix,
222            self.common_prefix..self.new_len - self.common_suffix,
223        )
224    }
225}
226
227/// Compare two payloads as bytes — the fallback when no structural form exists.
228pub fn byte_diff(old: &[u8], new: &[u8]) -> ByteDiff {
229    let common_prefix = old
230        .iter()
231        .zip(new.iter())
232        .take_while(|(a, b)| a == b)
233        .count();
234    // The suffix must not overlap the prefix, or a run of one repeated byte
235    // would report more bytes in common than either payload has.
236    let room = old.len().min(new.len()) - common_prefix;
237    let common_suffix = old
238        .iter()
239        .rev()
240        .zip(new.iter().rev())
241        .take(room)
242        .take_while(|(a, b)| a == b)
243        .count();
244    ByteDiff {
245        common_prefix,
246        common_suffix,
247        old_len: old.len(),
248        new_len: new.len(),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use serde_json::json;
256
257    #[test]
258    fn a_changed_scalar_names_its_path_and_both_sides() {
259        let d = diff(
260            &json!({"value": 41.0, "unit": "percent"}),
261            &json!({"value": 42.0, "unit": "percent"}),
262            32,
263        );
264        assert_eq!(
265            d.changes,
266            [Change::Changed {
267                path: "value".into(),
268                old: json!(41.0),
269                new: json!(42.0),
270            }]
271        );
272        assert_eq!(d.truncated, 0);
273    }
274
275    #[test]
276    fn identical_values_produce_nothing() {
277        let d = diff(
278            &json!({"a": [1, 2, {"b": true}]}),
279            &json!({"a": [1, 2, {"b": true}]}),
280            32,
281        );
282        assert!(d.is_empty(), "no change is not the same as not looked");
283    }
284
285    #[test]
286    fn added_and_removed_fields_are_distinct_from_changed() {
287        let d = diff(
288            &json!({"a": 1, "gone": 2}),
289            &json!({"a": 1, "fresh": 3}),
290            32,
291        );
292        assert!(d.changes.contains(&Change::Removed {
293            path: "gone".into(),
294            old: json!(2)
295        }));
296        assert!(d.changes.contains(&Change::Added {
297            path: "fresh".into(),
298            new: json!(3)
299        }));
300        assert_eq!(d.changes.len(), 2);
301    }
302
303    #[test]
304    fn nesting_produces_dotted_paths() {
305        let d = diff(
306            &json!({"disk": {"var-log": {"used": 1}}}),
307            &json!({"disk": {"var-log": {"used": 2}}}),
308            32,
309        );
310        assert_eq!(d.changes[0].path(), "disk.var-log.used");
311    }
312
313    /// Arrays are positional: index 1 moved, and nothing claims the elements
314    /// were reordered.
315    #[test]
316    fn arrays_are_compared_by_index() {
317        let d = diff(&json!({"xs": [1, 2, 3]}), &json!({"xs": [1, 9, 3]}), 32);
318        assert_eq!(
319            d.changes,
320            [Change::Changed {
321                path: "xs.1".into(),
322                old: json!(2),
323                new: json!(9),
324            }]
325        );
326    }
327
328    #[test]
329    fn a_shorter_array_reports_the_tail_as_removed() {
330        let d = diff(&json!([1, 2, 3]), &json!([1]), 32);
331        assert_eq!(
332            d.changes,
333            [
334                Change::Removed {
335                    path: "1".into(),
336                    old: json!(2)
337                },
338                Change::Removed {
339                    path: "2".into(),
340                    old: json!(3)
341                },
342            ]
343        );
344    }
345
346    /// A type change is one change carrying both sides, not a remove plus an
347    /// add — the field is still the same field.
348    #[test]
349    fn a_shape_change_is_a_single_change() {
350        let d = diff(&json!({"a": {"b": 1}}), &json!({"a": [1]}), 32);
351        assert_eq!(d.changes.len(), 1);
352        assert_eq!(d.changes[0].path(), "a");
353    }
354
355    /// The bound is reported, never silently applied: a diff that stops at N
356    /// without saying so reads as "and nothing else changed".
357    #[test]
358    fn changes_past_the_bound_are_counted_not_dropped_silently() {
359        let old = json!({"a": 1, "b": 1, "c": 1, "d": 1, "e": 1});
360        let new = json!({"a": 2, "b": 2, "c": 2, "d": 2, "e": 2});
361        let d = diff(&old, &new, 2);
362        assert_eq!(d.changes.len(), 2);
363        assert_eq!(d.truncated, 3);
364        assert!(!d.is_empty());
365    }
366
367    #[test]
368    fn byte_diff_brackets_the_differing_run() {
369        let d = byte_diff(b"hello world", b"hello there");
370        assert_eq!(d.common_prefix, 6);
371        assert_eq!(d.old_len, 11);
372        assert_eq!(d.new_len, 11);
373        let (old, new) = d.ranges();
374        assert_eq!(&b"hello world"[old], b"world");
375        assert_eq!(&b"hello there"[new], b"there");
376        assert!(!d.is_empty());
377    }
378
379    #[test]
380    fn identical_bytes_are_empty() {
381        let d = byte_diff(b"same", b"same");
382        assert!(d.is_empty());
383        assert_eq!(d.common_prefix, 4);
384    }
385
386    /// A repeated byte must not let prefix and suffix double-count the same
387    /// bytes and claim more in common than the payload has.
388    #[test]
389    fn prefix_and_suffix_never_overlap() {
390        let d = byte_diff(b"aaaa", b"aaaaaa");
391        assert_eq!(d.common_prefix, 4);
392        assert_eq!(d.common_suffix, 0);
393        assert!(d.common_prefix + d.common_suffix <= d.old_len);
394    }
395}