zenkey_fleet/report/diff.rs
1//! What a payload comparison says (issue #63; on the wire since #219).
2//!
3//! The *algorithms* — [`diff`](crate::model::diff::diff) and
4//! [`byte_diff`](crate::model::diff::byte_diff) — stay in
5//! [`crate::model::diff`], because they compute from values in hand. The
6//! *shapes* live here because a `.zsnap` diff (`zenctl snapshot diff
7//! --format json`) carries them to a script: the placement rule in
8//! [`crate::report`] has no exceptions, and a `Change` that reaches a pipe
9//! is a contract however small it is.
10//!
11//! Deliberately no notion of `Put` vs `Delete`: a tombstone is not a value and
12//! diffing it against one would be a category error. `SampleView::kind` is
13//! exact, and the frontend words the retirement.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// One field-level difference, addressed by a dotted path (`disk.used`,
19/// `items.0.name`).
20///
21/// Tagged `op` on the wire — `added | removed | changed` — so a consumer
22/// reads the kind before the fields, the way every other tagged row in this
23/// crate is read.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "op", rename_all = "snake_case")]
26pub enum Change {
27 /// The path is present in the new value and absent from the old.
28 Added { path: String, new: Value },
29 /// The path is present in the old value and absent from the new.
30 Removed { path: String, old: Value },
31 /// The path is in both and its value moved.
32 Changed {
33 path: String,
34 old: Value,
35 new: Value,
36 },
37}
38
39impl Change {
40 pub fn path(&self) -> &str {
41 match self {
42 Change::Added { path, .. }
43 | Change::Removed { path, .. }
44 | Change::Changed { path, .. } => path,
45 }
46 }
47}
48
49/// The result of comparing two structural values.
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ValueDiff {
52 pub changes: Vec<Change>,
53 /// Changes found past `max_changes` and therefore not listed.
54 ///
55 /// Counted rather than silently cut: a bounded view that reports what it
56 /// dropped is the RFC 09 §5.1 O6 rule, and a diff that quietly stops at
57 /// twenty entries reads as "and nothing else changed". Always written,
58 /// even at zero — a diff's bound is part of what the diff *is*.
59 pub truncated: usize,
60}
61
62impl ValueDiff {
63 /// No change at all — distinct from "we did not look".
64 pub fn is_empty(&self) -> bool {
65 self.changes.is_empty() && self.truncated == 0
66 }
67}
68
69/// What a byte comparison can honestly say when neither side is structural.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71pub struct ByteDiff {
72 pub common_prefix: usize,
73 pub common_suffix: usize,
74 pub old_len: usize,
75 pub new_len: usize,
76}
77
78impl ByteDiff {
79 /// True when the two byte strings are identical.
80 pub fn is_empty(&self) -> bool {
81 self.old_len == self.new_len && self.common_prefix == self.old_len
82 }
83
84 /// The half-open byte range that differs on each side: `(old, new)`.
85 ///
86 /// Both start at `common_prefix`; both end where the common suffix begins.
87 pub fn ranges(&self) -> (std::ops::Range<usize>, std::ops::Range<usize>) {
88 (
89 self.common_prefix..self.old_len - self.common_suffix,
90 self.common_prefix..self.new_len - self.common_suffix,
91 )
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use serde_json::json;
99
100 /// The wire spelling of a change, pinned: `op` leads, snake_case, and
101 /// the two sides ride under `old`/`new` exactly as the in-memory enum
102 /// names them.
103 #[test]
104 fn a_change_is_tagged_by_op() {
105 let d = ValueDiff {
106 changes: vec![
107 Change::Changed {
108 path: "value".into(),
109 old: json!(41),
110 new: json!(42),
111 },
112 Change::Added {
113 path: "fresh".into(),
114 new: json!(true),
115 },
116 Change::Removed {
117 path: "gone".into(),
118 old: json!(null),
119 },
120 ],
121 truncated: 0,
122 };
123 assert_eq!(
124 serde_json::to_value(&d).unwrap(),
125 json!({
126 "changes": [
127 {"op": "changed", "path": "value", "old": 41, "new": 42},
128 {"op": "added", "path": "fresh", "new": true},
129 {"op": "removed", "path": "gone", "old": null},
130 ],
131 "truncated": 0,
132 })
133 );
134 let back: ValueDiff = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap();
135 assert_eq!(back, d);
136 }
137
138 #[test]
139 fn a_byte_diff_round_trips() {
140 let d = ByteDiff {
141 common_prefix: 6,
142 common_suffix: 0,
143 old_len: 11,
144 new_len: 11,
145 };
146 assert_eq!(
147 serde_json::to_value(d).unwrap(),
148 json!({"common_prefix": 6, "common_suffix": 0, "old_len": 11, "new_len": 11})
149 );
150 }
151}