1use crate::store_ops::NarNode;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Diff {
17 pub entries: Vec<DiffEntry>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DiffEntry {
23 AddedFile { path: String, size: usize },
25 RemovedFile { path: String, size: usize },
27 ChangedFile { path: String, size_a: usize, size_b: usize },
29 KindChanged { path: String, from: String, to: String },
31 SymlinkChanged { path: String, from: String, to: String },
33 ExecutableChanged { path: String, executable_now: bool },
35}
36
37impl Diff {
38 #[must_use]
40 pub fn len(&self) -> usize { self.entries.len() }
41
42 #[must_use]
44 pub fn is_empty(&self) -> bool { self.entries.is_empty() }
45
46 #[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#[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 #[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#[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 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 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, false);
155 i += 1;
156 }
157 std::cmp::Ordering::Greater => {
158 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, 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, 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, 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); assert_eq!(h.added, 1); }
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); 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 #[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}