Skip to main content

dua/
diff.rs

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/// Compare two verified traversal snapshots without materializing either tree.
246///
247/// If `directories_only` is true, report aggregate directory changes instead of file changes.
248/// `prefix` limits output to that stored path and its descendants.
249/// `max_depth` limits the displayed depth below the selected roots while still reading all entries;
250/// collapsed directories show their gross hidden additions and removals.
251/// `summary_limit` bounds each largest-addition and largest-removal list; zero hides both.
252/// Changes are streamed before their summary is printed.
253#[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}