Skip to main content

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}