1use crate::{
2 ByteFormat,
3 snapshot::{Replay, ReplayEntries},
4};
5use anyhow::{Context, Result};
6use owo_colors::OwoColorize;
7use std::{
8 borrow::Cow,
9 cmp::Ordering,
10 io::{self, Read, Seek},
11 path::{Path, PathBuf},
12};
13
14#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
15struct KeyPart {
16 name: Vec<u8>,
17 sibling_ordinal: u64,
18}
19
20#[derive(Clone, Copy)]
21struct Current {
22 depth: usize,
23 size: u128,
24 is_dir: bool,
25}
26
27struct Cursor<'a, R> {
28 entries: ReplayEntries<'a, R>,
29 current: Option<Current>,
30 root_ordinal: usize,
31 roots_seen: usize,
32 key: Vec<KeyPart>,
33 key_depth: usize,
34 path: PathBuf,
35 path_depth: usize,
36}
37
38#[derive(Clone, Copy)]
39enum Change {
40 Added(u128),
41 Removed(u128),
42 Modified { sign: char, magnitude: u128 },
43}
44
45struct Location<'a> {
46 root_ordinal: usize,
47 key: &'a [KeyPart],
48 path: &'a Path,
49 depth: usize,
50 is_dir: bool,
51}
52
53#[derive(Default)]
54struct TreeState {
55 root_ordinal: Option<usize>,
56 key: Vec<KeyPart>,
57 collapsed: Option<CollapsedChange>,
58}
59
60struct CollapsedChange {
61 root_ordinal: usize,
62 key: Vec<KeyPart>,
63 path: PathBuf,
64 depth: usize,
65 additions: Option<u128>,
66 removals: Option<u128>,
67}
68
69impl CollapsedChange {
70 fn matches(&self, location: &Location<'_>, visible_depth: usize) -> bool {
71 self.root_ordinal == location.root_ordinal && self.key == location.key[..=visible_depth]
72 }
73
74 fn record(&mut self, change: Change) -> Result<()> {
75 let (total, magnitude, direction) = match change {
76 Change::Added(magnitude)
77 | Change::Modified {
78 sign: '+',
79 magnitude,
80 } => (&mut self.additions, magnitude, "addition"),
81 Change::Removed(magnitude) | Change::Modified { magnitude, .. } => {
82 (&mut self.removals, magnitude, "removal")
83 }
84 };
85 *total = Some(
86 (*total)
87 .unwrap_or(0)
88 .checked_add(magnitude)
89 .with_context(|| format!("collapsed {direction} total overflows u128"))?,
90 );
91 Ok(())
92 }
93}
94
95#[derive(Default)]
96struct Summary {
97 additions: Vec<SummaryEntry>,
98 removals: Vec<SummaryEntry>,
99 additions_total: u64,
100 removals_total: u64,
101 changes_total: u64,
102}
103
104struct SummaryEntry {
105 size: u128,
106 path: PathBuf,
107 is_dir: bool,
108}
109
110impl Summary {
111 fn record(&mut self, change: Change, entry: Option<SummaryEntry>, limit: usize) {
112 self.changes_total = self.changes_total.saturating_add(1);
113 let (entries, total) = match change {
114 Change::Added(_) => (&mut self.additions, &mut self.additions_total),
115 Change::Removed(_) => (&mut self.removals, &mut self.removals_total),
116 Change::Modified { .. } => return,
117 };
118 *total = total.saturating_add(1);
119 if limit == 0 {
120 return;
121 }
122 entries.push(entry.expect("additions and removals carry their summary entry"));
123 entries.sort_by(|left, right| {
124 right
125 .size
126 .cmp(&left.size)
127 .then_with(|| left.path.cmp(&right.path))
128 });
129 entries.truncate(limit);
130 }
131}
132
133impl<'a, R: Read> Cursor<'a, R> {
134 fn new(entries: ReplayEntries<'a, R>, prefix: Option<&Path>) -> Result<Self> {
135 let mut cursor = Self {
136 entries,
137 current: None,
138 root_ordinal: 0,
139 roots_seen: 0,
140 key: Vec::new(),
141 key_depth: 0,
142 path: PathBuf::new(),
143 path_depth: 0,
144 };
145 cursor.advance(prefix)?;
146 Ok(cursor)
147 }
148
149 fn advance(&mut self, prefix: Option<&Path>) -> Result<()> {
150 self.advance_raw()?;
151 self.skip_to_prefix(prefix)
152 }
153
154 fn skip_to_prefix(&mut self, prefix: Option<&Path>) -> Result<()> {
155 while self
156 .current
157 .is_some_and(|_| prefix.is_some_and(|prefix| !self.path.starts_with(prefix)))
158 {
159 self.advance_raw()?;
160 }
161 Ok(())
162 }
163
164 fn advance_raw(&mut self) -> Result<()> {
165 let Some(entry) = self.entries.next_entry()? else {
166 self.current = None;
167 return Ok(());
168 };
169 let depth = entry.depth;
170 let data = entry.data;
171 let native_name = entry.native_name;
172 let sibling_ordinal = entry.sibling_ordinal;
173 let name = entry.name();
174
175 if depth == 0 {
176 self.root_ordinal = self.roots_seen;
177 self.roots_seen = self
178 .roots_seen
179 .checked_add(1)
180 .context("snapshot contains too many roots")?;
181 self.key_depth = 0;
182 self.path.clear();
183 self.path.push(name.as_ref());
184 self.path_depth = 0;
185 } else {
186 while self.path_depth >= depth {
187 self.path.pop();
188 self.path_depth -= 1;
189 }
190 self.path.push(name.as_ref());
191 self.path_depth = depth;
192 }
193 if self.key.len() <= depth {
194 self.key.push(KeyPart {
195 name: Vec::new(),
196 sibling_ordinal,
197 });
198 }
199 let key = &mut self.key[depth];
200 key.name.clear();
201 key.name.extend_from_slice(native_name);
202 key.sibling_ordinal = sibling_ordinal;
203 self.key_depth = depth + 1;
204 self.current = Some(Current {
205 depth,
206 size: data.size,
207 is_dir: data.is_dir,
208 });
209 Ok(())
210 }
211
212 fn cmp_key<Rhs: Read>(&self, rhs: &Cursor<'_, Rhs>) -> Ordering {
213 self.root_ordinal
214 .cmp(&rhs.root_ordinal)
215 .then_with(|| self.key[..self.key_depth].cmp(&rhs.key[..rhs.key_depth]))
216 }
217
218 fn advance_past_current(&mut self, prefix: Option<&Path>) -> Result<()> {
219 let Some(current) = self.current else {
220 return Ok(());
221 };
222 self.advance_raw()?;
223 if current.is_dir {
224 while self
225 .current
226 .is_some_and(|entry| entry.depth > current.depth)
227 {
228 self.advance_raw()?;
229 }
230 }
231 self.skip_to_prefix(prefix)
232 }
233
234 fn location(&self, current: Current) -> Location<'_> {
235 Location {
236 root_ordinal: self.root_ordinal,
237 key: &self.key[..self.key_depth],
238 path: &self.path,
239 depth: current.depth,
240 is_dir: current.is_dir,
241 }
242 }
243}
244
245#[allow(clippy::too_many_arguments)]
254pub fn diff_snapshots<Old: Read + Seek, New: Read + Seek>(
255 out: (impl io::Write, bool),
256 old: &mut Replay<Old>,
257 new: &mut Replay<New>,
258 byte_format: ByteFormat,
259 directories_only: bool,
260 prefix: Option<&Path>,
261 max_depth: Option<usize>,
262 summary_limit: usize,
263) -> Result<()> {
264 let (mut out, out_is_terminal) = out;
265 let (send, receive) = std::sync::mpsc::channel();
266 let summary = std::thread::spawn(move || {
267 let mut summary = Summary::default();
268 for (change, entry) in receive {
269 summary.record(change, entry, summary_limit);
270 }
271 summary
272 });
273
274 let mut state = TreeState::default();
275 let walk_result = walk_changes(old, new, directories_only, prefix, |change, location| {
276 let summary_entry = match change {
277 _ if summary_limit == 0 => None,
278 Change::Added(size) | Change::Removed(size) => Some(SummaryEntry {
279 size,
280 path: location.path.to_owned(),
281 is_dir: location.is_dir,
282 }),
283 Change::Modified { .. } => None,
284 };
285 send.send((change, summary_entry))
286 .map_err(|_| anyhow::anyhow!("summary collector stopped"))?;
287 write_tree_entry(
288 &mut out,
289 &mut state,
290 change,
291 location,
292 byte_format,
293 out_is_terminal,
294 prefix,
295 max_depth,
296 !directories_only,
297 )?;
298 Ok(())
299 });
300 drop(send);
301 let summary = summary
302 .join()
303 .map_err(|_| anyhow::anyhow!("summary collector panicked"))?;
304 walk_result?;
305 flush_collapsed(&mut out, &mut state, byte_format, out_is_terminal, prefix)?;
306 if summary.changes_total == 0 {
307 return Ok(());
308 }
309 writeln!(out)?;
310 write_summary(&mut out, &summary, byte_format, out_is_terminal)?;
311 Ok(())
312}
313
314fn write_summary(
315 out: &mut impl io::Write,
316 summary: &Summary,
317 byte_format: ByteFormat,
318 out_is_terminal: bool,
319) -> io::Result<()> {
320 let mut wrote_entries = false;
321 for (title, entries, total, change) in [
322 (
323 "removals",
324 &summary.removals,
325 summary.removals_total,
326 Change::Removed as fn(u128) -> Change,
327 ),
328 (
329 "additions",
330 &summary.additions,
331 summary.additions_total,
332 Change::Added as fn(u128) -> Change,
333 ),
334 ] {
335 if entries.is_empty() {
336 continue;
337 }
338 if wrote_entries {
339 writeln!(out)?;
340 }
341 writeln!(
342 out,
343 "Largest {title} (showing {} of {total}):",
344 entries.len()
345 )?;
346 for entry in entries {
347 write_change(
348 out,
349 change(entry.size),
350 &entry.path,
351 entry.is_dir,
352 0,
353 false,
354 (byte_format, out_is_terminal),
355 )?;
356 }
357 wrote_entries = true;
358 }
359 if wrote_entries {
360 writeln!(out)?;
361 }
362 writeln!(out, "Changes: {}", summary.changes_total)
363}
364
365fn walk_changes<Old: Read + Seek, New: Read + Seek>(
366 old: &mut Replay<Old>,
367 new: &mut Replay<New>,
368 directories_only: bool,
369 prefix: Option<&Path>,
370 mut on_change: impl FnMut(Change, Location<'_>) -> Result<()>,
371) -> Result<()> {
372 let mut old = Cursor::new(old.entries()?, prefix)?;
373 let mut new = Cursor::new(new.entries()?, prefix)?;
374
375 loop {
376 let ordering = match (old.current, new.current) {
377 (Some(_), Some(_)) => Some(old.cmp_key(&new)),
378 (Some(_), None) => Some(Ordering::Less),
379 (None, Some(_)) => Some(Ordering::Greater),
380 (None, None) => None,
381 };
382 match ordering {
383 Some(Ordering::Less) => {
384 let Some(entry) = old.current else {
385 return Ok(());
386 };
387 if !directories_only || entry.is_dir {
388 on_change(Change::Removed(entry.size), old.location(entry))?;
389 }
390 old.advance_past_current(prefix)?;
391 }
392 Some(Ordering::Greater) => {
393 let Some(entry) = new.current else {
394 return Ok(());
395 };
396 if !directories_only || entry.is_dir {
397 on_change(Change::Added(entry.size), new.location(entry))?;
398 }
399 new.advance_past_current(prefix)?;
400 }
401 Some(Ordering::Equal) => {
402 let (Some(old_entry), Some(new_entry)) = (old.current, new.current) else {
403 return Ok(());
404 };
405 if old_entry.is_dir == new_entry.is_dir {
406 if old_entry.is_dir == directories_only && old_entry.size != new_entry.size {
407 let (sign, magnitude) = if new_entry.size > old_entry.size {
408 ('+', new_entry.size - old_entry.size)
409 } else {
410 ('-', old_entry.size - new_entry.size)
411 };
412 on_change(
413 Change::Modified { sign, magnitude },
414 new.location(new_entry),
415 )?;
416 }
417 old.advance(prefix)?;
418 new.advance(prefix)?;
419 } else {
420 if !directories_only || old_entry.is_dir {
421 on_change(Change::Removed(old_entry.size), old.location(old_entry))?;
422 }
423 if !directories_only || new_entry.is_dir {
424 on_change(Change::Added(new_entry.size), new.location(new_entry))?;
425 }
426 old.advance_past_current(prefix)?;
427 new.advance_past_current(prefix)?;
428 }
429 }
430 None => return Ok(()),
431 }
432 }
433}
434
435#[allow(clippy::too_many_arguments)]
436fn write_tree_entry(
437 out: &mut impl io::Write,
438 state: &mut TreeState,
439 change: Change,
440 location: Location<'_>,
441 byte_format: ByteFormat,
442 out_is_terminal: bool,
443 prefix: Option<&Path>,
444 max_depth: Option<usize>,
445 aggregate_hidden: bool,
446) -> Result<()> {
447 let mut hierarchy = location
448 .path
449 .ancestors()
450 .take(location.depth + 1)
451 .collect::<Vec<_>>();
452 hierarchy.reverse();
453 let base_depth = prefix
454 .and_then(|prefix| hierarchy.iter().position(|path| *path == prefix))
455 .unwrap_or(0);
456 let visible_depth = location
457 .depth
458 .min(base_depth.saturating_add(max_depth.unwrap_or(usize::MAX)));
459 let change_is_visible = visible_depth == location.depth;
460
461 if aggregate_hidden && !change_is_visible {
462 if state
463 .collapsed
464 .as_ref()
465 .is_none_or(|collapsed| !collapsed.matches(&location, visible_depth))
466 {
467 flush_collapsed(out, state, byte_format, out_is_terminal, prefix)?;
468 state.collapsed = Some(CollapsedChange {
469 root_ordinal: location.root_ordinal,
470 key: location.key[..=visible_depth].to_vec(),
471 path: hierarchy[visible_depth].to_owned(),
472 depth: visible_depth,
473 additions: None,
474 removals: None,
475 });
476 }
477 state
478 .collapsed
479 .as_mut()
480 .expect("collapsed change was initialized")
481 .record(change)?;
482 return Ok(());
483 }
484
485 flush_collapsed(out, state, byte_format, out_is_terminal, prefix)?;
486 let common_depth = if state.root_ordinal == Some(location.root_ordinal) {
487 state
488 .key
489 .iter()
490 .zip(location.key)
491 .take_while(|(left, right)| left == right)
492 .count()
493 } else {
494 0
495 };
496 let context_count = if change_is_visible {
497 location.depth
498 } else {
499 visible_depth + 1
500 };
501
502 for (depth, path) in hierarchy
503 .iter()
504 .enumerate()
505 .take(context_count)
506 .skip(common_depth.max(base_depth))
507 {
508 write_context(
509 out,
510 display_path(path, depth == base_depth),
511 depth - base_depth,
512 !change_is_visible && depth == visible_depth,
513 out_is_terminal,
514 )?;
515 }
516
517 if change_is_visible {
518 write_change(
519 out,
520 change,
521 display_path(location.path, location.depth == base_depth),
522 location.is_dir,
523 location.depth - base_depth,
524 location.is_dir
525 && matches!(change, Change::Modified { .. })
526 && max_depth.is_some_and(|max_depth| location.depth - base_depth == max_depth),
527 (byte_format, out_is_terminal),
528 )?;
529 }
530 state.root_ordinal = Some(location.root_ordinal);
531 state.key.clear();
532 state.key.extend_from_slice(&location.key[..=visible_depth]);
533 Ok(())
534}
535
536fn flush_collapsed(
537 out: &mut impl io::Write,
538 state: &mut TreeState,
539 byte_format: ByteFormat,
540 out_is_terminal: bool,
541 prefix: Option<&Path>,
542) -> io::Result<()> {
543 let Some(collapsed) = state.collapsed.take() else {
544 return Ok(());
545 };
546 let mut hierarchy = collapsed
547 .path
548 .ancestors()
549 .take(collapsed.depth + 1)
550 .collect::<Vec<_>>();
551 hierarchy.reverse();
552 let base_depth = prefix
553 .and_then(|prefix| hierarchy.iter().position(|path| *path == prefix))
554 .unwrap_or(0);
555 let common_depth = if state.root_ordinal == Some(collapsed.root_ordinal) {
556 state
557 .key
558 .iter()
559 .zip(&collapsed.key)
560 .take_while(|(left, right)| left == right)
561 .count()
562 } else {
563 0
564 };
565 for (depth, path) in hierarchy
566 .iter()
567 .enumerate()
568 .take(collapsed.depth)
569 .skip(common_depth.max(base_depth))
570 {
571 write_context(
572 out,
573 display_path(path, depth == base_depth),
574 depth - base_depth,
575 false,
576 out_is_terminal,
577 )?;
578 }
579 write_collapsed_change(
580 out,
581 display_path(&collapsed.path, collapsed.depth == base_depth),
582 collapsed.depth - base_depth,
583 collapsed.additions,
584 collapsed.removals,
585 byte_format,
586 out_is_terminal,
587 )?;
588 state.root_ordinal = Some(collapsed.root_ordinal);
589 state.key = collapsed.key;
590 Ok(())
591}
592
593fn write_context(
594 out: &mut impl io::Write,
595 path: &Path,
596 depth: usize,
597 has_hidden_changes: bool,
598 out_is_terminal: bool,
599) -> io::Result<()> {
600 let path = path_for_output(path);
601 let suffix = directory_suffix(path.as_ref());
602 let hidden = if has_hidden_changes { " …" } else { "" };
603 let line = format!("{}{path}{suffix}{hidden}", " ".repeat(depth));
604 if out_is_terminal {
605 writeln!(out, "{}", line.cyan())
606 } else {
607 writeln!(out, "{line}")
608 }
609}
610
611#[allow(clippy::too_many_arguments)]
612fn write_collapsed_change(
613 out: &mut impl io::Write,
614 path: &Path,
615 depth: usize,
616 additions: Option<u128>,
617 removals: Option<u128>,
618 byte_format: ByteFormat,
619 out_is_terminal: bool,
620) -> io::Result<()> {
621 let path = path_for_output(path);
622 let suffix = directory_suffix(path.as_ref());
623 let prefix = format!("{}{path}{suffix} …", " ".repeat(depth));
624 if out_is_terminal {
625 write!(out, "{} (", prefix.cyan())?;
626 if let Some(size) = additions {
627 write!(out, "{}", format!("+{}", byte_format.display(size)).green())?;
628 }
629 if additions.is_some() && removals.is_some() {
630 write!(out, " ")?;
631 }
632 if let Some(size) = removals {
633 write!(out, "{}", format!("-{}", byte_format.display(size)).red())?;
634 }
635 writeln!(out, ")")
636 } else {
637 write!(out, "{prefix} (")?;
638 if let Some(size) = additions {
639 write!(out, "+{}", byte_format.display(size))?;
640 }
641 if additions.is_some() && removals.is_some() {
642 write!(out, " ")?;
643 }
644 if let Some(size) = removals {
645 write!(out, "-{}", byte_format.display(size))?;
646 }
647 writeln!(out, ")")
648 }
649}
650
651fn write_change(
652 out: &mut impl io::Write,
653 change: Change,
654 path: &Path,
655 is_dir: bool,
656 depth: usize,
657 has_hidden_changes: bool,
658 (byte_format, out_is_terminal): (ByteFormat, bool),
659) -> io::Result<()> {
660 let (kind, sign, magnitude) = match change {
661 Change::Added(size) => ('+', "", size),
662 Change::Removed(size) => ('-', "", size),
663 Change::Modified { sign, magnitude } => {
664 ('~', if sign == '+' { "+" } else { "-" }, magnitude)
665 }
666 };
667 let path = path_for_output(path);
668 let suffix = if is_dir {
669 directory_suffix(path.as_ref())
670 } else {
671 ""
672 };
673 let hidden = if has_hidden_changes { " …" } else { "" };
674 let line = format!(
675 "{}{kind} {sign}{} {path}{suffix}{hidden}",
676 " ".repeat(depth),
677 byte_format.display(magnitude)
678 );
679 if !out_is_terminal {
680 return writeln!(out, "{line}");
681 }
682 match change {
683 Change::Added(_) => writeln!(out, "{}", line.green()),
684 Change::Removed(_) => writeln!(out, "{}", line.red()),
685 Change::Modified { .. } => writeln!(out, "{}", line.yellow()),
686 }
687}
688
689fn display_path(path: &Path, full: bool) -> &Path {
690 if full {
691 path
692 } else {
693 path.file_name().map_or(path, Path::new)
694 }
695}
696
697fn directory_suffix(path: &str) -> &'static str {
698 if path.chars().last().is_some_and(std::path::is_separator) {
699 ""
700 } else {
701 std::path::MAIN_SEPARATOR_STR
702 }
703}
704
705fn path_for_output(path: &Path) -> Cow<'_, str> {
706 let path = path.to_string_lossy();
707 if path.chars().any(char::is_control) {
708 path.chars()
709 .map(|character| {
710 if character.is_control() {
711 '\u{FFFD}'
712 } else {
713 character
714 }
715 })
716 .collect::<String>()
717 .into()
718 } else {
719 path
720 }
721}