Skip to main content

dua/
aggregate.rs

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