Skip to main content

dua/
aggregate.rs

1use crate::{ByteFormat, InodeFilter, Throttle, WalkOptions, WalkResult, WalkRoot, crossdev};
2use anyhow::Result;
3#[cfg(not(any(windows, target_os = "macos")))]
4use filesize::PathExt;
5use owo_colors::{AnsiColors as Color, OwoColorize};
6use std::path::PathBuf;
7use std::time::Duration;
8use std::{io, path::Path};
9
10#[cfg(not(any(windows, target_os = "macos")))]
11fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
12    entry.path().size_on_disk_fast(metadata)
13}
14
15#[cfg(windows)]
16#[allow(clippy::unnecessary_wraps)]
17fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
18    Ok(if entry.file_type.is_dir() {
19        0
20    } else {
21        metadata.allocated_size()
22    })
23}
24
25const CLEAR_CURRENT_LINE: &str = "\x1b[2K\r";
26
27/// Throttles transient traversal entry counts to an optional writer and clears the progress line.
28pub(crate) struct TraversalProgress<W: io::Write> {
29    writer: Option<W>,
30    throttle: Throttle,
31    // Only clear the line if progress was visible before as we printed it.
32    visible: bool,
33}
34
35impl<W: io::Write> TraversalProgress<W> {
36    pub(crate) fn new(writer: Option<W>) -> Self {
37        Self {
38            writer,
39            throttle: Throttle::new(Duration::from_millis(100), Duration::from_secs(1).into()),
40            visible: false,
41        }
42    }
43
44    pub(crate) fn update(&mut self, entries: u64) {
45        if self.throttle.can_update() {
46            self.write(entries);
47        }
48    }
49
50    fn write(&mut self, entries: u64) {
51        if let Some(writer) = self.writer.as_mut() {
52            write!(writer, "Enumerating {entries} items\r").ok();
53            self.visible = true;
54        }
55    }
56
57    pub(crate) fn clear(&mut self) {
58        if self.visible {
59            if let Some(writer) = self.writer.as_mut() {
60                write!(writer, "{CLEAR_CURRENT_LINE}").ok();
61            }
62            self.visible = false;
63        }
64    }
65}
66
67/// Accumulated output state for one input root, retained until roots can be emitted in the
68/// requested order.
69struct Aggregate {
70    /// Path printed for this root.
71    display_path: PathBuf,
72    /// Sum of the accepted entries' apparent or allocated sizes.
73    bytes: u128,
74    /// Number of root, entry, metadata, or size-query errors encountered.
75    errors: u64,
76    /// Whether the root is a file, used to distinguish file and directory output styling.
77    is_file: bool,
78}
79
80impl Aggregate {
81    fn path_color(&self) -> Option<Color> {
82        (!self.is_file).then_some(Color::Cyan)
83    }
84}
85
86/// Aggregate the given `paths` and write information about them to `out` in a human-readable format.
87/// If `compute_total` is set, it will write an additional line with the total size across all given `paths`.
88/// If `sort_by_size_in_bytes` is set, we will sort all sizes (ascending) before outputting them.
89pub fn aggregate(
90    out: (impl io::Write, bool),
91    err: Option<impl io::Write>,
92    walk_options: WalkOptions,
93    compute_total: bool,
94    sort_by_size_in_bytes: bool,
95    byte_format: ByteFormat,
96    paths: Vec<PathBuf>,
97) -> Result<(WalkResult, Statistics)> {
98    let cwd = std::env::current_dir()?;
99    aggregate_inner(
100        out,
101        err,
102        walk_options,
103        compute_total,
104        sort_by_size_in_bytes,
105        byte_format,
106        paths.into_iter().map(|display_path| {
107            let path = gix::path::normalize(display_path.as_path().into(), &cwd)
108                .map_or_else(|| display_path.clone(), |path| path.into_owned());
109            (path, display_path, None)
110        }),
111    )
112}
113
114/// Aggregate bulk-enumerated directory entries without querying their paths for metadata again.
115///
116/// Reuses each entry's existing metadata and filesystem identity while preserving the output and
117/// traversal behavior of [`aggregate`].
118#[cfg(any(windows, target_os = "macos"))]
119pub fn aggregate_entries(
120    out: (impl io::Write, bool),
121    err: Option<impl io::Write>,
122    walk_options: WalkOptions,
123    compute_total: bool,
124    sort_by_size_in_bytes: bool,
125    byte_format: ByteFormat,
126    entries: Vec<dua_core::Entry>,
127) -> Result<(WalkResult, Statistics)> {
128    aggregate_inner(
129        out,
130        err,
131        walk_options,
132        compute_total,
133        sort_by_size_in_bytes,
134        byte_format,
135        entries.into_iter().map(|entry| {
136            let path = entry.path();
137            (path.clone(), path, Some(entry))
138        }),
139    )
140}
141
142fn aggregate_inner(
143    out: (impl io::Write, bool),
144    err: Option<impl io::Write>,
145    walk_options: WalkOptions,
146    compute_total: bool,
147    sort_by_size_in_bytes: bool,
148    byte_format: ByteFormat,
149    inputs: impl ExactSizeIterator<Item = (PathBuf, PathBuf, Option<crate::walk::Entry>)>,
150) -> Result<(WalkResult, Statistics)> {
151    let (mut out, out_supports_colors) = out;
152    let output_options = (byte_format, out_supports_colors);
153    #[cfg(target_os = "macos")]
154    let apfs_clone_accounting = walk_options.metadata_options.apfs_clone_metadata;
155    let mut res = WalkResult::default();
156    let mut stats = Statistics::default();
157    let mut smallest_file_in_bytes = None;
158    let num_roots = inputs.len();
159    let mut aggregates = Vec::with_capacity(num_roots);
160    let mut device_ids = vec![0; num_roots];
161    let mut completed = vec![false; num_roots];
162    let mut roots = Vec::with_capacity(num_roots);
163    let has_ignore_patterns = walk_options.ignore_patterns.is_some();
164    for (root_idx, (path, display_path, prepared_entry)) in inputs.enumerate() {
165        #[cfg(not(any(windows, target_os = "macos")))]
166        let _ = prepared_entry;
167
168        aggregates.push(Aggregate {
169            display_path,
170            bytes: 0,
171            errors: 0,
172            is_file: false,
173        });
174        let device_id = if walk_options.cross_filesystems {
175            0
176        } else {
177            #[cfg(target_os = "macos")]
178            let root_device_id = prepared_entry
179                .as_ref()
180                .and_then(|entry| entry.metadata.as_ref().ok())
181                .map_or_else(|| crossdev::init(&path), |metadata| Ok(metadata.dev()));
182            #[cfg(not(target_os = "macos"))]
183            let root_device_id = crossdev::init(&path);
184
185            let Ok(device_id) = root_device_id else {
186                aggregates[root_idx].errors += 1;
187                completed[root_idx] = true;
188                continue;
189            };
190            device_id
191        };
192        device_ids[root_idx] = device_id;
193        roots.push(WalkRoot {
194            index: root_idx,
195            pattern_root: has_ignore_patterns.then(|| path.clone()),
196            path,
197            #[cfg(any(windows, target_os = "macos"))]
198            entry: prepared_entry,
199            device_id,
200        });
201    }
202    let mut inodes = InodeFilter::default();
203    let mut progress = TraversalProgress::new(err);
204    let mut next_output = 0;
205
206    // Shared hard links and, when enabled, cloned data belong to the first root that reaches them.
207    for (root_idx, event) in
208        walk_options.iter_from_paths(roots, false, crate::walk::Order::Completion)
209    {
210        let entry = match event {
211            crate::walk::RootEvent::Entry(entry) => entry,
212            crate::walk::RootEvent::Finished => {
213                completed[root_idx] = true;
214                if !sort_by_size_in_bytes {
215                    output_completed(
216                        &mut out,
217                        &aggregates,
218                        &completed,
219                        &mut next_output,
220                        &mut progress,
221                        output_options,
222                    )?;
223                }
224                continue;
225            }
226        };
227        let aggregate = &mut aggregates[root_idx];
228        stats.entries_traversed += 1;
229        progress.update(stats.entries_traversed);
230        match entry {
231            Ok(entry) => {
232                if entry.depth == 0 {
233                    aggregate.is_file = entry.file_type.is_file()
234                        || entry.file_type.is_symlink() && entry.path().is_file();
235                }
236                let file_size = u128::from(match &entry.metadata {
237                    Ok(m)
238                        if (walk_options.count_hard_links || inodes.add(&entry, m))
239                            && (walk_options.cross_filesystems
240                                || crossdev::is_same_device(device_ids[root_idx], m)) =>
241                    {
242                        if walk_options.apparent_size {
243                            m.len()
244                        } else {
245                            #[cfg(target_os = "macos")]
246                            if apfs_clone_accounting {
247                                inodes.allocated_size(m)
248                            } else {
249                                m.allocated_size()
250                            }
251                            #[cfg(not(target_os = "macos"))]
252                            {
253                                size_on_disk(&entry, m).unwrap_or_else(|_| {
254                                    aggregate.errors += 1;
255                                    0
256                                })
257                            }
258                        }
259                    }
260                    Ok(_) => 0,
261                    Err(_) => {
262                        aggregate.errors += 1;
263                        0
264                    }
265                });
266                stats.largest_file_in_bytes = stats.largest_file_in_bytes.max(file_size);
267                smallest_file_in_bytes = smallest_file_in_bytes
268                    .map_or(file_size, |size: u128| size.min(file_size))
269                    .into();
270                aggregate.bytes += file_size;
271            }
272            Err(_) => aggregate.errors += 1,
273        }
274    }
275
276    let total = aggregates.iter().map(|aggregate| aggregate.bytes).sum();
277    res.num_errors = aggregates.iter().map(|aggregate| aggregate.errors).sum();
278
279    stats.smallest_file_in_bytes = smallest_file_in_bytes.unwrap_or_default();
280
281    progress.clear();
282
283    if sort_by_size_in_bytes {
284        output_sorted(&mut out, aggregates, output_options)?;
285    } else {
286        // Be sure failed roots are also printed, as they lack a `Finished` event,
287        // the traversal never starts on them.
288        output_completed(
289            &mut out,
290            &aggregates,
291            &completed,
292            &mut next_output,
293            &mut progress,
294            output_options,
295        )?;
296        debug_assert_eq!(next_output, num_roots);
297    }
298
299    if num_roots > 1 && compute_total {
300        output_colored_path(
301            &mut out,
302            out_supports_colors,
303            Path::new("total"),
304            total,
305            res.num_errors,
306            None,
307            byte_format,
308        )?;
309    }
310    Ok((res, stats))
311}
312
313/// Write the contiguous run of completed roots starting at `next_output`, preserving input order.
314/// Clears a visible progress line before writing the first completed root.
315fn output_completed<W: io::Write, E: io::Write>(
316    out: &mut W,
317    aggregates: &[Aggregate],
318    completed: &[bool],
319    next_output: &mut usize,
320    progress: &mut TraversalProgress<E>,
321    (byte_format, out_supports_colors): (ByteFormat, bool),
322) -> io::Result<()> {
323    let must_report_completed_path = completed.get(*next_output).copied() == Some(true);
324    // Remove the transient progress line before writing permanent results to the terminal.
325    if must_report_completed_path {
326        progress.clear();
327    }
328    while completed.get(*next_output).copied() == Some(true) {
329        let aggregate = &aggregates[*next_output];
330        output_colored_path(
331            out,
332            out_supports_colors,
333            &aggregate.display_path,
334            aggregate.bytes,
335            aggregate.errors,
336            aggregate.path_color(),
337            byte_format,
338        )?;
339        *next_output += 1;
340    }
341    Ok(())
342}
343
344fn output_sorted(
345    out: &mut impl io::Write,
346    mut aggregates: Vec<Aggregate>,
347    (byte_format, out_supports_colors): (ByteFormat, bool),
348) -> std::result::Result<(), io::Error> {
349    aggregates.sort_by_key(|aggregate| aggregate.bytes);
350    for aggregate in aggregates {
351        output_colored_path(
352            out,
353            out_supports_colors,
354            &aggregate.display_path,
355            aggregate.bytes,
356            aggregate.errors,
357            aggregate.path_color(),
358            byte_format,
359        )?;
360    }
361    Ok(())
362}
363
364pub(crate) fn output_colored_path(
365    out: &mut impl io::Write,
366    out_supports_colors: bool,
367    path: impl AsRef<Path>,
368    num_bytes: u128,
369    num_errors: u64,
370    path_color: Option<Color>,
371    byte_format: ByteFormat,
372) -> std::result::Result<(), io::Error> {
373    let size = byte_format.display(num_bytes).to_string();
374    let size_width = byte_format.width();
375    let path = path.as_ref().display();
376
377    let errors = if num_errors != 0 {
378        format!(
379            "  <{num_errors} IO Error{plural_s}>",
380            plural_s = if num_errors > 1 { "s" } else { "" }
381        )
382    } else {
383        String::new()
384    };
385
386    if !out_supports_colors {
387        return writeln!(out, "{size:>size_width$} {path}{errors}");
388    }
389
390    let size = size.green();
391    if let Some(color) = path_color {
392        writeln!(out, "{size:>size_width$} {}{errors}", path.color(color))
393    } else {
394        writeln!(out, "{size:>size_width$} {path}{errors}")
395    }
396}
397
398/// Statistics obtained during a filesystem walk
399#[derive(Default, Debug)]
400pub struct Statistics {
401    /// The amount of entries we have seen during filesystem traversal
402    pub entries_traversed: u64,
403    /// The size of the smallest file encountered in bytes
404    pub smallest_file_in_bytes: u128,
405    /// The size of the largest file encountered in bytes
406    pub largest_file_in_bytes: u128,
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn byte_counts(out: &[u8]) -> Vec<u128> {
414        let out = std::str::from_utf8(out).unwrap();
415        out.match_indices(" b")
416            .map(|(unit, _)| {
417                out[..unit]
418                    .chars()
419                    .rev()
420                    .take_while(char::is_ascii_digit)
421                    .collect::<String>()
422                    .chars()
423                    .rev()
424                    .collect::<String>()
425                    .parse()
426                    .unwrap()
427            })
428            .collect()
429    }
430
431    #[test]
432    fn traversal_progress_writes_and_clears_one_line() {
433        let mut progress = TraversalProgress::new(Some(Vec::new()));
434        progress.write(42);
435        progress.clear();
436
437        assert_eq!(
438            progress.writer.as_deref(),
439            Some(b"Enumerating 42 items\r\x1b[2K\r".as_slice())
440        );
441        assert!(!progress.visible);
442    }
443
444    #[cfg(any(windows, target_os = "macos"))]
445    #[test]
446    fn file_as_root_keeps_cached_metadata_after_removal() {
447        let directory = tempfile::tempdir().unwrap();
448        let path = directory.path().join("prepared-file");
449
450        for sort_by_size_in_bytes in [false, true] {
451            std::fs::write(&path, b"cached metadata").unwrap();
452            let expected_size = std::fs::metadata(&path).unwrap().len();
453            let entry = dua_core::read_dir(directory.path(), dua_core::Options::default())
454                .unwrap()
455                .next()
456                .unwrap()
457                .unwrap();
458            std::fs::remove_file(&path).unwrap();
459
460            let mut out = Vec::new();
461            let (result, statistics) = aggregate_entries(
462                (&mut out, false),
463                None::<Vec<u8>>,
464                WalkOptions {
465                    threads: 1,
466                    count_hard_links: false,
467                    apparent_size: true,
468                    cross_filesystems: false,
469                    ignore_dirs: std::collections::BTreeSet::default(),
470                    ignore_patterns: None,
471                    metadata_options: crate::TraversalOptions::default(),
472                },
473                false,
474                sort_by_size_in_bytes,
475                ByteFormat::Bytes,
476                vec![entry],
477            )
478            .unwrap();
479
480            assert_eq!(result.num_errors, 0);
481            assert_eq!(statistics.entries_traversed, 1);
482            assert_eq!(byte_counts(&out), [u128::from(expected_size)]);
483            let out = String::from_utf8(out).unwrap();
484            assert!(
485                out.contains(&format!(" {}\n", path.display())),
486                "the bulk reader must preserve the cached file type after removal; querying the \
487                 path again would fail, leave `is_file` false, and incorrectly add cyan directory \
488                 coloring: {out:?}"
489            );
490        }
491    }
492
493    #[cfg(target_os = "macos")]
494    #[test]
495    fn overlapping_directory_roots_preserve_stat_link_count_cycles() {
496        use std::os::unix::fs::MetadataExt;
497
498        const OVERLAPPING_VISITS: u64 = 5;
499
500        let directory = tempfile::tempdir().unwrap();
501        let parent = directory.path().join("parent");
502        let child = parent.join("child");
503        let grandchild = child.join("grandchild");
504        std::fs::create_dir_all(&grandchild).unwrap();
505        let file = grandchild.join("file");
506        std::fs::write(&file, b"repeated directory contents").unwrap();
507
508        let parent_metadata = std::fs::symlink_metadata(&parent).unwrap();
509        let child_metadata = std::fs::symlink_metadata(&child).unwrap();
510        let grandchild_metadata = std::fs::symlink_metadata(&grandchild).unwrap();
511        let file_metadata = std::fs::symlink_metadata(&file).unwrap();
512        assert!(
513            child_metadata.nlink() > 1,
514            "expected multiple links for {child:?}, got {}",
515            child_metadata.nlink()
516        );
517        assert!(
518            grandchild_metadata.nlink() > 1,
519            "expected multiple links for {grandchild:?}, got {}",
520            grandchild_metadata.nlink()
521        );
522
523        let roots = vec![parent, child.clone(), child.clone(), child.clone(), child];
524
525        for count_hard_links in [false, true] {
526            let directory_visits = |metadata: &std::fs::Metadata| {
527                if count_hard_links || metadata.nlink() <= 1 {
528                    OVERLAPPING_VISITS
529                } else {
530                    OVERLAPPING_VISITS.div_ceil(metadata.nlink())
531                }
532            };
533            let expected = u128::from(parent_metadata.len())
534                + u128::from(directory_visits(&child_metadata) * child_metadata.len())
535                + u128::from(directory_visits(&grandchild_metadata) * grandchild_metadata.len())
536                + u128::from(OVERLAPPING_VISITS * file_metadata.len());
537
538            let mut out = Vec::new();
539            let result = aggregate(
540                (&mut out, false),
541                None::<Vec<u8>>,
542                WalkOptions {
543                    threads: 1,
544                    count_hard_links,
545                    apparent_size: true,
546                    cross_filesystems: true,
547                    ignore_dirs: std::collections::BTreeSet::default(),
548                    ignore_patterns: None,
549                    metadata_options: crate::TraversalOptions::default(),
550                },
551                true,
552                false,
553                ByteFormat::Bytes,
554                roots.clone(),
555            )
556            .unwrap();
557
558            assert_eq!(result.0.num_errors, 0);
559            assert_eq!(
560                byte_counts(&out).last().copied(),
561                Some(expected),
562                "overlapping directory totals with count_hard_links={count_hard_links}"
563            );
564        }
565    }
566
567    #[cfg(target_os = "macos")]
568    #[test]
569    fn full_apfs_clones_preserve_private_forks_hard_links_and_logical_sizes() {
570        use std::io::Write as _;
571        use std::os::unix::fs::MetadataExt;
572
573        const STAT_BLOCK_BYTES: u128 = 512;
574        const RESOURCE_FORK_BYTES: usize = 4096;
575        const DATA_FORK_BYTES: usize = RESOURCE_FORK_BYTES * 2;
576
577        let directory = tempfile::tempdir().unwrap();
578        let original = directory.path().join("original");
579        let clone = directory.path().join("clone");
580        let partial_clone = directory.path().join("partial-clone");
581        let hard_link = directory.path().join("hard-link");
582        std::fs::write(&original, vec![7; DATA_FORK_BYTES]).unwrap();
583        // On Apple platforms, std::fs::copy first tries fclonefileat(2), so these become
584        // copy-on-write APFS clones with distinct inodes and shared data blocks. A non-APFS
585        // fallback copies the bytes instead and intentionally fails the clone-ID assertions below.
586        std::fs::copy(&original, &clone).unwrap();
587        std::fs::copy(&original, &partial_clone).unwrap();
588        std::fs::write(clone.join("..namedfork/rsrc"), vec![5; RESOURCE_FORK_BYTES]).unwrap();
589        std::fs::OpenOptions::new()
590            .write(true)
591            .open(&partial_clone)
592            .unwrap()
593            .write_all(&[9])
594            .unwrap();
595        std::fs::hard_link(&original, &hard_link).unwrap();
596
597        let original_metadata = std::fs::metadata(&original).unwrap();
598        let clone_metadata = std::fs::metadata(&clone).unwrap();
599        let partial_metadata = std::fs::metadata(&partial_clone).unwrap();
600        let directory_metadata = std::fs::metadata(directory.path()).unwrap();
601        let allocated_size = u128::from(original_metadata.blocks()) * STAT_BLOCK_BYTES;
602        let clone_allocated_size = u128::from(clone_metadata.blocks()) * STAT_BLOCK_BYTES;
603        let partial_allocated_size = u128::from(partial_metadata.blocks()) * STAT_BLOCK_BYTES;
604        let directory_allocated_size = u128::from(directory_metadata.blocks()) * STAT_BLOCK_BYTES;
605        let clone_private_size = clone_allocated_size - allocated_size;
606        let apparent_size = u128::from(original_metadata.len());
607        let directory_apparent_size = u128::from(directory_metadata.len());
608        assert_ne!(
609            clone_private_size, 0,
610            "the cloned fixture must own separately allocated resource-fork blocks"
611        );
612
613        let directory_root = || vec![directory.path().to_owned()];
614        for (case, roots, apparent_size_requested, count_hard_links, expected_total) in [
615            (
616                "full and partial clones retain private forks",
617                directory_root(),
618                false,
619                false,
620                directory_allocated_size
621                    + allocated_size
622                    + partial_allocated_size
623                    + clone_private_size,
624            ),
625            (
626                "explicit hard links remain counted",
627                directory_root(),
628                false,
629                true,
630                directory_allocated_size
631                    + allocated_size * 2
632                    + partial_allocated_size
633                    + clone_private_size,
634            ),
635            (
636                "logical sizes remain independent",
637                directory_root(),
638                true,
639                false,
640                directory_apparent_size + apparent_size * 3,
641            ),
642            (
643                "logical sizes count requested hard links",
644                directory_root(),
645                true,
646                true,
647                directory_apparent_size + apparent_size * 4,
648            ),
649            (
650                "clone-first roots preserve explicit hard links",
651                vec![clone.clone(), original.clone(), hard_link],
652                false,
653                true,
654                allocated_size * 2 + clone_private_size,
655            ),
656            (
657                "repeated cloned roots are not distinct clone inodes",
658                vec![clone.clone(), clone, original],
659                false,
660                false,
661                clone_allocated_size * 2,
662            ),
663        ] {
664            let mut output = Vec::new();
665            let (result, _) = aggregate(
666                (&mut output, false),
667                None::<Vec<u8>>,
668                WalkOptions {
669                    threads: 2,
670                    count_hard_links,
671                    apparent_size: apparent_size_requested,
672                    cross_filesystems: true,
673                    ignore_dirs: std::collections::BTreeSet::default(),
674                    ignore_patterns: None,
675                    metadata_options: crate::TraversalOptions {
676                        apfs_clone_metadata: true,
677                    },
678                },
679                true,
680                true,
681                ByteFormat::Bytes,
682                roots,
683            )
684            .unwrap();
685
686            assert_eq!(result.num_errors, 0, "unexpected traversal errors: {case}");
687            assert_eq!(
688                byte_counts(&output).last().copied(),
689                Some(expected_total),
690                "incorrect aggregate for {case}"
691            );
692        }
693
694        let entries = dua_core::read_dir(
695            directory.path(),
696            dua_core::Options {
697                apfs_clone_metadata: true,
698            },
699        )
700        .unwrap()
701        .collect::<std::io::Result<Vec<_>>>()
702        .unwrap();
703        let mut output = Vec::new();
704        let (result, _) = aggregate_entries(
705            (&mut output, false),
706            None::<Vec<u8>>,
707            WalkOptions {
708                threads: 2,
709                count_hard_links: false,
710                apparent_size: false,
711                cross_filesystems: false,
712                ignore_dirs: std::collections::BTreeSet::default(),
713                ignore_patterns: None,
714                metadata_options: crate::TraversalOptions {
715                    apfs_clone_metadata: true,
716                },
717            },
718            true,
719            true,
720            ByteFormat::Bytes,
721            entries,
722        )
723        .unwrap();
724
725        assert_eq!(
726            result.num_errors, 0,
727            "prepared sibling roots should retain their cached filesystem identities"
728        );
729        assert_eq!(
730            byte_counts(&output).last().copied(),
731            Some(allocated_size + partial_allocated_size + clone_private_size),
732            "prepared sibling roots should deduplicate cloned data, retain private forks, \
733             and omit their parent directory"
734        );
735    }
736
737    #[test]
738    fn completed_roots_stream_in_input_order() {
739        let aggregates = [
740            Aggregate {
741                display_path: "first".into(),
742                bytes: 1,
743                errors: 0,
744                is_file: false,
745            },
746            Aggregate {
747                display_path: "second".into(),
748                bytes: 2,
749                errors: 0,
750                is_file: false,
751            },
752        ];
753        let mut completed = [false, true];
754        let mut next_output = 0;
755        let mut progress = TraversalProgress::new(Some(Vec::new()));
756        progress.visible = true;
757        let mut out = Vec::new();
758
759        output_completed(
760            &mut out,
761            &aggregates,
762            &completed,
763            &mut next_output,
764            &mut progress,
765            (ByteFormat::Bytes, false),
766        )
767        .unwrap();
768        assert!(
769            out.is_empty(),
770            "later roots must not overtake earlier roots"
771        );
772
773        completed[0] = true;
774        output_completed(
775            &mut out,
776            &aggregates,
777            &completed,
778            &mut next_output,
779            &mut progress,
780            (ByteFormat::Bytes, false),
781        )
782        .unwrap();
783
784        assert_eq!(byte_counts(&out), [1, 2]);
785        let out = String::from_utf8(out).unwrap();
786        assert!(
787            out.find("first").unwrap() < out.find("second").unwrap(),
788            "the first root is also emitted first"
789        );
790        assert_eq!(next_output, 2, "output stopped at root {next_output}");
791        assert_eq!(
792            progress.writer.as_deref(),
793            Some(CLEAR_CURRENT_LINE.as_bytes()),
794            "unexpected progress cleanup"
795        );
796        assert!(!progress.visible, "progress remained visible after cleanup");
797    }
798
799    #[test]
800    fn fast_roots_do_not_emit_terminal_erases() {
801        let dir = tempfile::tempdir().unwrap();
802        let paths = [dir.path().join("a"), dir.path().join("b")];
803        for path in &paths {
804            std::fs::write(path, []).unwrap();
805        }
806        let mut out = Vec::new();
807        let mut err = Vec::new();
808
809        aggregate(
810            (&mut out, false),
811            Some(&mut err),
812            WalkOptions {
813                threads: 2,
814                count_hard_links: true,
815                apparent_size: false,
816                cross_filesystems: true,
817                ignore_dirs: std::collections::BTreeSet::default(),
818                ignore_patterns: None,
819                metadata_options: crate::TraversalOptions::default(),
820            },
821            true,
822            true,
823            ByteFormat::Metric,
824            paths.into(),
825        )
826        .unwrap();
827
828        assert!(
829            err.is_empty(),
830            "fast roots should not clear unseen progress"
831        );
832    }
833
834    #[cfg(unix)]
835    #[test]
836    fn root_device_error_is_reported() {
837        use std::os::unix::fs::symlink;
838
839        let dir = tempfile::tempdir().unwrap();
840        let root = dir.path().join("dangling");
841        symlink(dir.path().join("missing"), &root).unwrap();
842
843        let (result, _) = aggregate(
844            (Vec::new(), false),
845            None::<Vec<u8>>,
846            WalkOptions {
847                threads: 1,
848                count_hard_links: true,
849                apparent_size: true,
850                cross_filesystems: false,
851                ignore_dirs: std::collections::BTreeSet::default(),
852                ignore_patterns: None,
853                metadata_options: crate::TraversalOptions::default(),
854            },
855            false,
856            true,
857            ByteFormat::Bytes,
858            vec![root],
859        )
860        .unwrap();
861
862        assert_eq!(result.num_errors, 1);
863    }
864
865    #[test]
866    fn ignored_patterns_are_left_out_of_the_reported_size() {
867        let dir = tempfile::tempdir().unwrap();
868        std::fs::create_dir(dir.path().join("cache")).unwrap();
869        std::fs::write(dir.path().join("kept"), [0; 64]).unwrap();
870        std::fs::write(dir.path().join("cache/blob"), [0; 4096]).unwrap();
871
872        // Kept outside the traversed tree so they are not counted themselves.
873        let patterns_dir = tempfile::tempdir().unwrap();
874        let ignore_cache = patterns_dir.path().join("cache-only");
875        let ignore_both = patterns_dir.path().join("cache-and-kept");
876        std::fs::write(&ignore_cache, "cache/\n").unwrap();
877        std::fs::write(&ignore_both, "cache/\nkept\n").unwrap();
878
879        let aggregate_with = |ignore_from: &[PathBuf]| -> u128 {
880            let mut out = Vec::new();
881            aggregate(
882                (&mut out, false),
883                None::<&mut Vec<u8>>,
884                WalkOptions {
885                    threads: 2,
886                    count_hard_links: true,
887                    apparent_size: true,
888                    cross_filesystems: true,
889                    ignore_dirs: std::collections::BTreeSet::default(),
890                    ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(),
891                    metadata_options: crate::TraversalOptions::default(),
892                },
893                false,
894                true,
895                ByteFormat::Bytes,
896                vec![dir.path().to_owned()],
897            )
898            .unwrap();
899            byte_counts(&out)
900                .into_iter()
901                .next()
902                .unwrap_or_else(|| panic!("expected a byte count in {out:?}"))
903        };
904
905        // Directory entries have a size of their own that differs per filesystem - 4096 bytes on
906        // ext4, next to nothing on APFS - so only differences between runs are compared here.
907        let full = aggregate_with(&[]);
908        let without_cache = aggregate_with(&[ignore_cache]);
909        let without_either = aggregate_with(&[ignore_both]);
910
911        assert!(
912            full >= 4096 + 64,
913            "without patterns both files are counted, got {full}"
914        );
915        assert!(
916            full - without_cache >= 4096,
917            "excluding `cache/` drops at least the 4096-byte file inside it, \
918             but only {} bytes disappeared",
919            full - without_cache
920        );
921        assert_eq!(
922            without_cache - without_either,
923            64,
924            "the 64-byte file is still counted until a pattern matches it too"
925        );
926    }
927    #[cfg(windows)]
928    #[test]
929    fn windows_disk_size_survives_removing_the_entry_path() {
930        let dir = tempfile::tempdir().unwrap();
931        let path = dir.path().join("file");
932        std::fs::write(&path, b"content").unwrap();
933        let entry =
934            crate::walk::Entry::from_path(&path, crate::TraversalOptions::default()).unwrap();
935        let metadata = entry.metadata.as_ref().unwrap();
936        let expected = metadata.allocated_size();
937        std::fs::remove_file(path).unwrap();
938        assert_eq!(
939            size_on_disk(&entry, metadata).unwrap(),
940            expected,
941            "Windows aggregation should use the already-enumerated allocation size"
942        );
943    }
944
945    #[cfg(windows)]
946    #[test]
947    fn windows_disk_size_preserves_zero_sized_directories() {
948        let dir = tempfile::tempdir().unwrap();
949        std::fs::write(dir.path().join("file"), b"content").unwrap();
950        let entry =
951            crate::walk::Entry::from_path(dir.path(), crate::TraversalOptions::default()).unwrap();
952        let metadata = entry.metadata.as_ref().unwrap();
953        assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0);
954    }
955}