Skip to main content

sui_spec/
store_diff.rs

1//! Typed diff between two ParsedNar trees.
2//!
3//! Operators need a substrate-level "what changed between these
4//! two NAR archives?" answer.  This module produces a typed
5//! [`Diff`] AST that downstream tools render however they like
6//! (JSON, terminal, IDE).
7//!
8//! Composes against [`crate::store_ops::ParsedNar`]; doesn't
9//! touch the filesystem.
10
11use crate::store_ops::NarNode;
12
13/// Path-keyed diff between two NAR trees.  Paths are relative
14/// to the diff root, slash-separated.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Diff {
17    pub entries: Vec<DiffEntry>,
18}
19
20/// One diff record for a single path or pair.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DiffEntry {
23    /// Path exists only in B.
24    AddedFile { path: String, size: usize },
25    /// Path exists only in A.
26    RemovedFile { path: String, size: usize },
27    /// Path exists in both, contents differ.
28    ChangedFile { path: String, size_a: usize, size_b: usize },
29    /// Path is a file in one tree and not in the other shape.
30    KindChanged { path: String, from: String, to: String },
31    /// Symlink target changed.
32    SymlinkChanged { path: String, from: String, to: String },
33    /// Executable bit changed.
34    ExecutableChanged { path: String, executable_now: bool },
35}
36
37impl Diff {
38    /// Number of differing records.
39    #[must_use]
40    pub fn len(&self) -> usize { self.entries.len() }
41
42    /// `true` if both trees are byte-identical.
43    #[must_use]
44    pub fn is_empty(&self) -> bool { self.entries.is_empty() }
45
46    /// Group the diff by category (counts).
47    #[must_use]
48    pub fn histogram(&self) -> DiffHistogram {
49        let mut h = DiffHistogram::default();
50        for e in &self.entries {
51            match e {
52                DiffEntry::AddedFile { .. }        => h.added += 1,
53                DiffEntry::RemovedFile { .. }      => h.removed += 1,
54                DiffEntry::ChangedFile { .. }      => h.changed += 1,
55                DiffEntry::KindChanged { .. }      => h.kind_changed += 1,
56                DiffEntry::SymlinkChanged { .. }   => h.symlink_changed += 1,
57                DiffEntry::ExecutableChanged { .. } => h.executable_changed += 1,
58            }
59        }
60        h
61    }
62}
63
64/// Aggregate counts.  Useful for the operator-facing summary.
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
66pub struct DiffHistogram {
67    pub added: usize,
68    pub removed: usize,
69    pub changed: usize,
70    pub kind_changed: usize,
71    pub symlink_changed: usize,
72    pub executable_changed: usize,
73}
74
75impl DiffHistogram {
76    /// Total differing records across all categories.
77    #[must_use]
78    pub fn total(&self) -> usize {
79        self.added + self.removed + self.changed + self.kind_changed
80            + self.symlink_changed + self.executable_changed
81    }
82}
83
84/// Diff two NAR trees, producing the typed [`Diff`].
85#[must_use]
86pub fn diff(a: &NarNode, b: &NarNode) -> Diff {
87    let mut entries = Vec::new();
88    diff_nodes("", a, b, &mut entries);
89    Diff { entries }
90}
91
92fn kind_label(node: &NarNode) -> &'static str {
93    match node {
94        NarNode::File { .. }      => "file",
95        NarNode::Directory { .. } => "directory",
96        NarNode::Symlink { .. }   => "symlink",
97    }
98}
99
100fn diff_nodes(path: &str, a: &NarNode, b: &NarNode, out: &mut Vec<DiffEntry>) {
101    match (a, b) {
102        (NarNode::File { executable: ea, contents: ca },
103         NarNode::File { executable: eb, contents: cb }) => {
104            if ca != cb {
105                out.push(DiffEntry::ChangedFile {
106                    path: path.to_string(),
107                    size_a: ca.len(),
108                    size_b: cb.len(),
109                });
110            }
111            if ea != eb {
112                out.push(DiffEntry::ExecutableChanged {
113                    path: path.to_string(),
114                    executable_now: *eb,
115                });
116            }
117        }
118        (NarNode::Symlink { target: ta }, NarNode::Symlink { target: tb }) => {
119            if ta != tb {
120                out.push(DiffEntry::SymlinkChanged {
121                    path: path.to_string(),
122                    from: ta.clone(),
123                    to: tb.clone(),
124                });
125            }
126        }
127        (NarNode::Directory { entries: ea }, NarNode::Directory { entries: eb }) => {
128            // Walk both sorted child sets.  Entries are already
129            // sorted because ParsedNar::parse + NAR encoder both
130            // canonicalize.
131            let mut i = 0usize;
132            let mut j = 0usize;
133            while i < ea.len() && j < eb.len() {
134                let (na, ca) = &ea[i];
135                let (nb, cb) = &eb[j];
136                match na.cmp(nb) {
137                    std::cmp::Ordering::Equal => {
138                        let child_path = if path.is_empty() {
139                            na.clone()
140                        } else {
141                            format!("{path}/{na}")
142                        };
143                        diff_nodes(&child_path, ca, cb, out);
144                        i += 1;
145                        j += 1;
146                    }
147                    std::cmp::Ordering::Less => {
148                        // na is removed (in A but not B).
149                        let child_path = if path.is_empty() {
150                            na.clone()
151                        } else {
152                            format!("{path}/{na}")
153                        };
154                        record_subtree_only_in_one(&child_path, ca, out, /*added=*/false);
155                        i += 1;
156                    }
157                    std::cmp::Ordering::Greater => {
158                        // nb is added (in B but not A).
159                        let child_path = if path.is_empty() {
160                            nb.clone()
161                        } else {
162                            format!("{path}/{nb}")
163                        };
164                        record_subtree_only_in_one(&child_path, cb, out, /*added=*/true);
165                        j += 1;
166                    }
167                }
168            }
169            while i < ea.len() {
170                let (na, ca) = &ea[i];
171                let child_path = if path.is_empty() { na.clone() } else { format!("{path}/{na}") };
172                record_subtree_only_in_one(&child_path, ca, out, /*added=*/false);
173                i += 1;
174            }
175            while j < eb.len() {
176                let (nb, cb) = &eb[j];
177                let child_path = if path.is_empty() { nb.clone() } else { format!("{path}/{nb}") };
178                record_subtree_only_in_one(&child_path, cb, out, /*added=*/true);
179                j += 1;
180            }
181        }
182        _ => {
183            out.push(DiffEntry::KindChanged {
184                path: path.to_string(),
185                from: kind_label(a).to_string(),
186                to: kind_label(b).to_string(),
187            });
188        }
189    }
190}
191
192fn record_subtree_only_in_one(
193    path: &str,
194    node: &NarNode,
195    out: &mut Vec<DiffEntry>,
196    added: bool,
197) {
198    match node {
199        NarNode::File { contents, .. } => {
200            if added {
201                out.push(DiffEntry::AddedFile { path: path.to_string(), size: contents.len() });
202            } else {
203                out.push(DiffEntry::RemovedFile { path: path.to_string(), size: contents.len() });
204            }
205        }
206        NarNode::Directory { entries } => {
207            for (name, child) in entries {
208                let child_path = format!("{path}/{name}");
209                record_subtree_only_in_one(&child_path, child, out, added);
210            }
211        }
212        NarNode::Symlink { target } => {
213            if added {
214                out.push(DiffEntry::AddedFile { path: path.to_string(), size: target.len() });
215            } else {
216                out.push(DiffEntry::RemovedFile { path: path.to_string(), size: target.len() });
217            }
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::store_ops::{NarNode, ParsedNar};
226
227    fn file(s: &[u8]) -> NarNode {
228        NarNode::File { executable: false, contents: s.to_vec() }
229    }
230    fn dir(entries: Vec<(&str, NarNode)>) -> NarNode {
231        NarNode::Directory {
232            entries: entries.into_iter().map(|(n, c)| (n.to_string(), c)).collect(),
233        }
234    }
235
236    #[test]
237    fn identical_trees_have_empty_diff() {
238        let a = dir(vec![("x", file(b"hello")), ("y", file(b"world"))]);
239        let b = dir(vec![("x", file(b"hello")), ("y", file(b"world"))]);
240        let d = diff(&a, &b);
241        assert!(d.is_empty());
242    }
243
244    #[test]
245    fn changed_file_detected() {
246        let a = file(b"hello");
247        let b = file(b"hellp");
248        let d = diff(&a, &b);
249        assert_eq!(d.len(), 1);
250        match &d.entries[0] {
251            DiffEntry::ChangedFile { size_a, size_b, .. } => {
252                assert_eq!(*size_a, 5);
253                assert_eq!(*size_b, 5);
254            }
255            other => panic!("expected ChangedFile, got {other:?}"),
256        }
257    }
258
259    #[test]
260    fn added_and_removed_at_same_level() {
261        let a = dir(vec![("x", file(b"x")), ("y", file(b"y"))]);
262        let b = dir(vec![("y", file(b"y")), ("z", file(b"z"))]);
263        let d = diff(&a, &b);
264        let h = d.histogram();
265        assert_eq!(h.removed, 1);  // x
266        assert_eq!(h.added, 1);    // z
267    }
268
269    #[test]
270    fn nested_directory_diff() {
271        let a = dir(vec![("sub", dir(vec![("a", file(b"aa"))]))]);
272        let b = dir(vec![("sub", dir(vec![("b", file(b"bb"))]))]);
273        let d = diff(&a, &b);
274        assert_eq!(d.len(), 2);  // 1 added, 1 removed
275        let h = d.histogram();
276        assert_eq!(h.added, 1);
277        assert_eq!(h.removed, 1);
278    }
279
280    #[test]
281    fn kind_change_detected() {
282        let a = file(b"");
283        let b = NarNode::Symlink { target: "/x".into() };
284        let d = diff(&a, &b);
285        assert_eq!(d.len(), 1);
286        assert!(matches!(d.entries[0], DiffEntry::KindChanged { .. }));
287    }
288
289    #[test]
290    fn executable_change_detected() {
291        let a = NarNode::File { executable: false, contents: b"x".to_vec() };
292        let b = NarNode::File { executable: true,  contents: b"x".to_vec() };
293        let d = diff(&a, &b);
294        assert_eq!(d.len(), 1);
295        match &d.entries[0] {
296            DiffEntry::ExecutableChanged { executable_now, .. } => assert!(*executable_now),
297            _ => panic!(),
298        }
299    }
300
301    #[test]
302    fn symlink_target_change_detected() {
303        let a = NarNode::Symlink { target: "/a".into() };
304        let b = NarNode::Symlink { target: "/b".into() };
305        let d = diff(&a, &b);
306        assert_eq!(d.len(), 1);
307        match &d.entries[0] {
308            DiffEntry::SymlinkChanged { from, to, .. } => {
309                assert_eq!(from, "/a");
310                assert_eq!(to, "/b");
311            }
312            _ => panic!(),
313        }
314    }
315
316    /// Materialize-then-parse round-trip + diff against original
317    /// should be empty.  Substrate invariant.
318    #[test]
319    fn parsed_nar_self_diff_is_empty() {
320        let tmp = std::env::temp_dir().join("sui-store-diff-self-test");
321        let _ = std::fs::remove_dir_all(&tmp);
322        std::fs::create_dir_all(&tmp).unwrap();
323        std::fs::write(tmp.join("x"), b"hello").unwrap();
324        std::fs::write(tmp.join("y"), b"world").unwrap();
325        let nar = crate::nar::encode(&tmp).unwrap();
326        let parsed1 = ParsedNar::parse(&nar).unwrap();
327        let parsed2 = ParsedNar::parse(&nar).unwrap();
328        let d = diff(&parsed1.root, &parsed2.root);
329        assert!(d.is_empty());
330        let _ = std::fs::remove_dir_all(&tmp);
331    }
332}