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(target_os = "macos")]
16#[allow(clippy::unnecessary_wraps)]
17fn size_on_disk(_entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
18    Ok(metadata.allocated_size())
19}
20
21#[cfg(windows)]
22#[allow(clippy::unnecessary_wraps)]
23fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
24    Ok(if entry.file_type.is_dir() {
25        0
26    } else {
27        metadata.allocated_size()
28    })
29}
30
31const CLEAR_CURRENT_LINE: &str = "\x1b[2K\r";
32
33/// Accumulated output state for one input root, retained until roots can be emitted in the
34/// requested order.
35struct Aggregate {
36    /// Path printed for this root.
37    path: PathBuf,
38    /// Sum of the accepted entries' apparent or allocated sizes.
39    bytes: u128,
40    /// Number of root, entry, metadata, or size-query errors encountered.
41    errors: u64,
42    /// Whether the root is a file, used to distinguish file and directory output styling.
43    is_file: bool,
44}
45
46impl Aggregate {
47    fn path_color(&self) -> Option<Color> {
48        (!self.is_file).then_some(Color::Cyan)
49    }
50}
51
52/// Aggregate the given `paths` and write information about them to `out` in a human-readable format.
53/// If `compute_total` is set, it will write an additional line with the total size across all given `paths`.
54/// If `sort_by_size_in_bytes` is set, we will sort all sizes (ascending) before outputting them.
55pub fn aggregate(
56    out: impl io::Write,
57    err: Option<impl io::Write>,
58    walk_options: WalkOptions,
59    compute_total: bool,
60    sort_by_size_in_bytes: bool,
61    byte_format: ByteFormat,
62    paths: Vec<PathBuf>,
63) -> Result<(WalkResult, Statistics)> {
64    aggregate_inner(
65        out,
66        err,
67        walk_options,
68        compute_total,
69        sort_by_size_in_bytes,
70        byte_format,
71        paths.into_iter().map(|path| (path, None)),
72    )
73}
74
75/// Aggregate bulk-enumerated directory entries without querying their paths for metadata again.
76///
77/// Reuses each entry's existing metadata and filesystem identity while preserving the output and
78/// traversal behavior of [`aggregate`].
79#[cfg(any(windows, target_os = "macos"))]
80pub fn aggregate_entries(
81    out: impl io::Write,
82    err: Option<impl io::Write>,
83    walk_options: WalkOptions,
84    compute_total: bool,
85    sort_by_size_in_bytes: bool,
86    byte_format: ByteFormat,
87    entries: Vec<dua_core::Entry>,
88) -> Result<(WalkResult, Statistics)> {
89    aggregate_inner(
90        out,
91        err,
92        walk_options,
93        compute_total,
94        sort_by_size_in_bytes,
95        byte_format,
96        entries.into_iter().map(|entry| (entry.path(), Some(entry))),
97    )
98}
99
100fn aggregate_inner(
101    mut out: impl io::Write,
102    mut err: Option<impl io::Write>,
103    walk_options: WalkOptions,
104    compute_total: bool,
105    sort_by_size_in_bytes: bool,
106    byte_format: ByteFormat,
107    inputs: impl ExactSizeIterator<Item = (PathBuf, Option<crate::walk::Entry>)>,
108) -> Result<(WalkResult, Statistics)> {
109    let mut res = WalkResult::default();
110    let mut stats = Statistics {
111        smallest_file_in_bytes: u128::MAX,
112        ..Default::default()
113    };
114    let num_roots = inputs.len();
115    let mut aggregates = Vec::with_capacity(num_roots);
116    let mut device_ids = vec![0; num_roots];
117    let mut completed = vec![false; num_roots];
118    let mut roots = Vec::with_capacity(num_roots);
119    let has_ignore_patterns = walk_options.ignore_patterns.is_some();
120    for (root_idx, (path, prepared_entry)) in inputs.enumerate() {
121        #[cfg(not(any(windows, target_os = "macos")))]
122        let _ = prepared_entry;
123
124        aggregates.push(Aggregate {
125            path: path.clone(),
126            bytes: 0,
127            errors: 0,
128            is_file: false,
129        });
130        let device_id = if walk_options.cross_filesystems {
131            0
132        } else {
133            #[cfg(target_os = "macos")]
134            let root_device_id = prepared_entry
135                .as_ref()
136                .and_then(|entry| entry.metadata.as_ref().ok())
137                .map_or_else(|| crossdev::init(&path), |metadata| Ok(metadata.dev()));
138            #[cfg(not(target_os = "macos"))]
139            let root_device_id = crossdev::init(&path);
140
141            let Ok(device_id) = root_device_id else {
142                aggregates[root_idx].errors += 1;
143                completed[root_idx] = true;
144                continue;
145            };
146            device_id
147        };
148        device_ids[root_idx] = device_id;
149        roots.push(WalkRoot {
150            index: root_idx,
151            pattern_root: has_ignore_patterns.then(|| path.clone()),
152            path,
153            #[cfg(any(windows, target_os = "macos"))]
154            entry: prepared_entry,
155            device_id,
156        });
157    }
158    let mut inodes = InodeFilter::default();
159    let progress = Throttle::new(Duration::from_millis(100), Duration::from_secs(1).into());
160    let mut progress_visible = false;
161    let mut next_output = 0;
162
163    // With multiple roots, a shared hard link is attributed to whichever root reaches it first.
164    for (root_idx, event) in
165        walk_options.iter_from_paths(roots, false, crate::walk::Order::Completion)
166    {
167        let entry = match event {
168            crate::walk::RootEvent::Entry(entry) => entry,
169            crate::walk::RootEvent::Finished => {
170                completed[root_idx] = true;
171                if !sort_by_size_in_bytes {
172                    output_completed(
173                        &mut out,
174                        &mut err,
175                        &aggregates,
176                        &completed,
177                        &mut next_output,
178                        &mut progress_visible,
179                        byte_format,
180                    )?;
181                }
182                continue;
183            }
184        };
185        let aggregate = &mut aggregates[root_idx];
186        stats.entries_traversed += 1;
187        progress.throttled(|| {
188            if let Some(err) = err.as_mut() {
189                write!(err, "Enumerating {} items\r", stats.entries_traversed).ok();
190                progress_visible = true;
191            }
192        });
193        match entry {
194            Ok(entry) => {
195                if entry.depth == 0 {
196                    aggregate.is_file = entry.file_type.is_file()
197                        || entry.file_type.is_symlink() && entry.path().is_file();
198                }
199                let file_size = u128::from(match &entry.metadata {
200                    Ok(m)
201                        if (walk_options.count_hard_links || inodes.add(&entry, m))
202                            && (walk_options.cross_filesystems
203                                || crossdev::is_same_device(device_ids[root_idx], m)) =>
204                    {
205                        if walk_options.apparent_size {
206                            m.len()
207                        } else {
208                            size_on_disk(&entry, m).unwrap_or_else(|_| {
209                                aggregate.errors += 1;
210                                0
211                            })
212                        }
213                    }
214                    Ok(_) => 0,
215                    Err(_) => {
216                        aggregate.errors += 1;
217                        0
218                    }
219                });
220                stats.largest_file_in_bytes = stats.largest_file_in_bytes.max(file_size);
221                stats.smallest_file_in_bytes = stats.smallest_file_in_bytes.min(file_size);
222                aggregate.bytes += file_size;
223            }
224            Err(_) => aggregate.errors += 1,
225        }
226    }
227
228    let total = aggregates.iter().map(|aggregate| aggregate.bytes).sum();
229    res.num_errors = aggregates.iter().map(|aggregate| aggregate.errors).sum();
230
231    if stats.entries_traversed == 0 {
232        stats.smallest_file_in_bytes = 0;
233    }
234
235    if progress_visible && let Some(err) = err.as_mut() {
236        write!(err, "{CLEAR_CURRENT_LINE}").ok();
237    }
238
239    if sort_by_size_in_bytes {
240        output_sorted(&mut out, aggregates, byte_format)?;
241    } else {
242        // Be sure failed roots are also printed, as they lack a `Finished` event,
243        // the traversal never starts on them.
244        output_completed(
245            &mut out,
246            &mut err,
247            &aggregates,
248            &completed,
249            &mut next_output,
250            &mut progress_visible,
251            byte_format,
252        )?;
253        debug_assert_eq!(next_output, num_roots);
254    }
255
256    if num_roots > 1 && compute_total {
257        output_colored_path(
258            &mut out,
259            Path::new("total"),
260            total,
261            res.num_errors,
262            None,
263            byte_format,
264        )?;
265    }
266    Ok((res, stats))
267}
268
269/// Write the contiguous run of completed roots starting at `next_output`, preserving input order.
270/// Clears a visible progress line before writing the first completed root.
271/// `progress_visible` tracks if progress information is currently shown, taking up the last line.
272fn output_completed<W: io::Write, E: io::Write>(
273    out: &mut W,
274    err: &mut Option<E>,
275    aggregates: &[Aggregate],
276    completed: &[bool],
277    next_output: &mut usize,
278    progress_visible: &mut bool,
279    byte_format: ByteFormat,
280) -> io::Result<()> {
281    let must_report_completed_path = completed.get(*next_output).copied() == Some(true);
282    // Remove the transient progress line before writing permanent results to the terminal.
283    if must_report_completed_path && *progress_visible {
284        if let Some(err) = err.as_mut() {
285            write!(err, "{CLEAR_CURRENT_LINE}").ok();
286        }
287        *progress_visible = false;
288    }
289    while completed.get(*next_output).copied() == Some(true) {
290        let aggregate = &aggregates[*next_output];
291        output_colored_path(
292            out,
293            &aggregate.path,
294            aggregate.bytes,
295            aggregate.errors,
296            aggregate.path_color(),
297            byte_format,
298        )?;
299        *next_output += 1;
300    }
301    Ok(())
302}
303
304fn output_sorted(
305    out: &mut impl io::Write,
306    mut aggregates: Vec<Aggregate>,
307    byte_format: ByteFormat,
308) -> std::result::Result<(), io::Error> {
309    aggregates.sort_by_key(|aggregate| aggregate.bytes);
310    for aggregate in aggregates {
311        output_colored_path(
312            out,
313            &aggregate.path,
314            aggregate.bytes,
315            aggregate.errors,
316            aggregate.path_color(),
317            byte_format,
318        )?;
319    }
320    Ok(())
321}
322
323fn output_colored_path(
324    out: &mut impl io::Write,
325    path: impl AsRef<Path>,
326    num_bytes: u128,
327    num_errors: u64,
328    path_color: Option<Color>,
329    byte_format: ByteFormat,
330) -> std::result::Result<(), io::Error> {
331    let size = byte_format.display(num_bytes).to_string();
332    let size = size.green();
333    let size_width = byte_format.width();
334    let path = path.as_ref().display();
335
336    let errors = if num_errors != 0 {
337        format!(
338            "  <{num_errors} IO Error{plural_s}>",
339            plural_s = if num_errors > 1 { "s" } else { "" }
340        )
341    } else {
342        String::new()
343    };
344
345    if let Some(color) = path_color {
346        writeln!(out, "{size:>size_width$} {}{errors}", path.color(color))
347    } else {
348        writeln!(out, "{size:>size_width$} {path}{errors}")
349    }
350}
351
352/// Statistics obtained during a filesystem walk
353#[derive(Default, Debug)]
354pub struct Statistics {
355    /// The amount of entries we have seen during filesystem traversal
356    pub entries_traversed: u64,
357    /// The size of the smallest file encountered in bytes
358    pub smallest_file_in_bytes: u128,
359    /// The size of the largest file encountered in bytes
360    pub largest_file_in_bytes: u128,
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn byte_counts(out: &[u8]) -> Vec<u128> {
368        let out = std::str::from_utf8(out).unwrap();
369        out.match_indices(" b")
370            .map(|(unit, _)| {
371                out[..unit]
372                    .chars()
373                    .rev()
374                    .take_while(char::is_ascii_digit)
375                    .collect::<String>()
376                    .chars()
377                    .rev()
378                    .collect::<String>()
379                    .parse()
380                    .unwrap()
381            })
382            .collect()
383    }
384
385    #[cfg(any(windows, target_os = "macos"))]
386    #[test]
387    fn file_as_root_keeps_cached_metadata_after_removal() {
388        let directory = tempfile::tempdir().unwrap();
389        let path = directory.path().join("prepared-file");
390
391        for sort_by_size_in_bytes in [false, true] {
392            std::fs::write(&path, b"cached metadata").unwrap();
393            let expected_size = std::fs::metadata(&path).unwrap().len();
394            let entry = dua_core::read_dir(directory.path())
395                .unwrap()
396                .next()
397                .unwrap()
398                .unwrap();
399            std::fs::remove_file(&path).unwrap();
400
401            let mut out = Vec::new();
402            let (result, statistics) = aggregate_entries(
403                &mut out,
404                None::<Vec<u8>>,
405                WalkOptions {
406                    threads: 1,
407                    count_hard_links: false,
408                    apparent_size: true,
409                    cross_filesystems: false,
410                    ignore_dirs: std::collections::BTreeSet::default(),
411                    ignore_patterns: None,
412                },
413                false,
414                sort_by_size_in_bytes,
415                ByteFormat::Bytes,
416                vec![entry],
417            )
418            .unwrap();
419
420            assert_eq!(result.num_errors, 0);
421            assert_eq!(statistics.entries_traversed, 1);
422            assert_eq!(byte_counts(&out), [u128::from(expected_size)]);
423            let out = String::from_utf8(out).unwrap();
424            assert!(
425                out.contains(&format!(" {}\n", path.display())),
426                "the bulk reader must preserve the cached file type after removal; querying the \
427                 path again would fail, leave `is_file` false, and incorrectly add cyan directory \
428                 coloring: {out:?}"
429            );
430        }
431    }
432
433    #[cfg(target_os = "macos")]
434    #[test]
435    fn overlapping_directory_roots_preserve_stat_link_count_cycles() {
436        use std::os::unix::fs::MetadataExt;
437
438        const OVERLAPPING_VISITS: u64 = 5;
439
440        let directory = tempfile::tempdir().unwrap();
441        let parent = directory.path().join("parent");
442        let child = parent.join("child");
443        let grandchild = child.join("grandchild");
444        std::fs::create_dir_all(&grandchild).unwrap();
445        let file = grandchild.join("file");
446        std::fs::write(&file, b"repeated directory contents").unwrap();
447
448        let parent_metadata = std::fs::symlink_metadata(&parent).unwrap();
449        let child_metadata = std::fs::symlink_metadata(&child).unwrap();
450        let grandchild_metadata = std::fs::symlink_metadata(&grandchild).unwrap();
451        let file_metadata = std::fs::symlink_metadata(&file).unwrap();
452        assert!(
453            child_metadata.nlink() > 1,
454            "expected multiple links for {child:?}, got {}",
455            child_metadata.nlink()
456        );
457        assert!(
458            grandchild_metadata.nlink() > 1,
459            "expected multiple links for {grandchild:?}, got {}",
460            grandchild_metadata.nlink()
461        );
462
463        let roots = vec![parent, child.clone(), child.clone(), child.clone(), child];
464
465        for count_hard_links in [false, true] {
466            let directory_visits = |metadata: &std::fs::Metadata| {
467                if count_hard_links || metadata.nlink() <= 1 {
468                    OVERLAPPING_VISITS
469                } else {
470                    OVERLAPPING_VISITS.div_ceil(metadata.nlink())
471                }
472            };
473            let expected = u128::from(parent_metadata.len())
474                + u128::from(directory_visits(&child_metadata) * child_metadata.len())
475                + u128::from(directory_visits(&grandchild_metadata) * grandchild_metadata.len())
476                + u128::from(OVERLAPPING_VISITS * file_metadata.len());
477
478            let mut out = Vec::new();
479            let result = aggregate(
480                &mut out,
481                None::<Vec<u8>>,
482                WalkOptions {
483                    threads: 1,
484                    count_hard_links,
485                    apparent_size: true,
486                    cross_filesystems: true,
487                    ignore_dirs: std::collections::BTreeSet::default(),
488                    ignore_patterns: None,
489                },
490                true,
491                false,
492                ByteFormat::Bytes,
493                roots.clone(),
494            )
495            .unwrap();
496
497            assert_eq!(result.0.num_errors, 0);
498            assert_eq!(
499                byte_counts(&out).last().copied(),
500                Some(expected),
501                "overlapping directory totals with count_hard_links={count_hard_links}"
502            );
503        }
504    }
505
506    #[test]
507    fn completed_roots_stream_in_input_order() {
508        let aggregates = [
509            Aggregate {
510                path: "first".into(),
511                bytes: 1,
512                errors: 0,
513                is_file: false,
514            },
515            Aggregate {
516                path: "second".into(),
517                bytes: 2,
518                errors: 0,
519                is_file: false,
520            },
521        ];
522        let mut completed = [false, true];
523        let mut next_output = 0;
524        let mut progress_visible = true;
525        let mut out = Vec::new();
526        let mut err = Some(Vec::new());
527
528        output_completed(
529            &mut out,
530            &mut err,
531            &aggregates,
532            &completed,
533            &mut next_output,
534            &mut progress_visible,
535            ByteFormat::Bytes,
536        )
537        .unwrap();
538        assert!(
539            out.is_empty(),
540            "later roots must not overtake earlier roots"
541        );
542
543        completed[0] = true;
544        output_completed(
545            &mut out,
546            &mut err,
547            &aggregates,
548            &completed,
549            &mut next_output,
550            &mut progress_visible,
551            ByteFormat::Bytes,
552        )
553        .unwrap();
554
555        assert_eq!(byte_counts(&out), [1, 2]);
556        let out = String::from_utf8(out).unwrap();
557        assert!(
558            out.find("first").unwrap() < out.find("second").unwrap(),
559            "the first root is also emitted first"
560        );
561        assert_eq!(next_output, 2, "output stopped at root {next_output}");
562        assert_eq!(
563            err.as_deref(),
564            Some(CLEAR_CURRENT_LINE.as_bytes()),
565            "unexpected progress cleanup: {err:?}"
566        );
567        assert!(!progress_visible, "progress remained visible after cleanup");
568    }
569
570    #[test]
571    fn fast_roots_do_not_emit_terminal_erases() {
572        let dir = tempfile::tempdir().unwrap();
573        let paths = [dir.path().join("a"), dir.path().join("b")];
574        for path in &paths {
575            std::fs::write(path, []).unwrap();
576        }
577        let mut out = Vec::new();
578        let mut err = Vec::new();
579
580        aggregate(
581            &mut out,
582            Some(&mut err),
583            WalkOptions {
584                threads: 2,
585                count_hard_links: true,
586                apparent_size: false,
587                cross_filesystems: true,
588                ignore_dirs: std::collections::BTreeSet::default(),
589                ignore_patterns: None,
590            },
591            true,
592            true,
593            ByteFormat::Metric,
594            paths.into(),
595        )
596        .unwrap();
597
598        assert!(
599            err.is_empty(),
600            "fast roots should not clear unseen progress"
601        );
602    }
603
604    #[cfg(unix)]
605    #[test]
606    fn root_device_error_is_reported() {
607        use std::os::unix::fs::symlink;
608
609        let dir = tempfile::tempdir().unwrap();
610        let root = dir.path().join("dangling");
611        symlink(dir.path().join("missing"), &root).unwrap();
612
613        let (result, _) = aggregate(
614            Vec::new(),
615            None::<Vec<u8>>,
616            WalkOptions {
617                threads: 1,
618                count_hard_links: true,
619                apparent_size: true,
620                cross_filesystems: false,
621                ignore_dirs: std::collections::BTreeSet::default(),
622                ignore_patterns: None,
623            },
624            false,
625            true,
626            ByteFormat::Bytes,
627            vec![root],
628        )
629        .unwrap();
630
631        assert_eq!(result.num_errors, 1);
632    }
633
634    #[test]
635    fn ignored_patterns_are_left_out_of_the_reported_size() {
636        let dir = tempfile::tempdir().unwrap();
637        std::fs::create_dir(dir.path().join("cache")).unwrap();
638        std::fs::write(dir.path().join("kept"), [0; 64]).unwrap();
639        std::fs::write(dir.path().join("cache/blob"), [0; 4096]).unwrap();
640
641        // Kept outside the traversed tree so they are not counted themselves.
642        let patterns_dir = tempfile::tempdir().unwrap();
643        let ignore_cache = patterns_dir.path().join("cache-only");
644        let ignore_both = patterns_dir.path().join("cache-and-kept");
645        std::fs::write(&ignore_cache, "cache/\n").unwrap();
646        std::fs::write(&ignore_both, "cache/\nkept\n").unwrap();
647
648        let aggregate_with = |ignore_from: &[PathBuf]| -> u128 {
649            let mut out = Vec::new();
650            aggregate(
651                &mut out,
652                None::<&mut Vec<u8>>,
653                WalkOptions {
654                    threads: 2,
655                    count_hard_links: true,
656                    apparent_size: true,
657                    cross_filesystems: true,
658                    ignore_dirs: std::collections::BTreeSet::default(),
659                    ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(),
660                },
661                false,
662                true,
663                ByteFormat::Bytes,
664                vec![dir.path().to_owned()],
665            )
666            .unwrap();
667            byte_counts(&out)
668                .into_iter()
669                .next()
670                .unwrap_or_else(|| panic!("expected a byte count in {out:?}"))
671        };
672
673        // Directory entries have a size of their own that differs per filesystem - 4096 bytes on
674        // ext4, next to nothing on APFS - so only differences between runs are compared here.
675        let full = aggregate_with(&[]);
676        let without_cache = aggregate_with(&[ignore_cache]);
677        let without_either = aggregate_with(&[ignore_both]);
678
679        assert!(
680            full >= 4096 + 64,
681            "without patterns both files are counted, got {full}"
682        );
683        assert!(
684            full - without_cache >= 4096,
685            "excluding `cache/` drops at least the 4096-byte file inside it, \
686             but only {} bytes disappeared",
687            full - without_cache
688        );
689        assert_eq!(
690            without_cache - without_either,
691            64,
692            "the 64-byte file is still counted until a pattern matches it too"
693        );
694    }
695    #[cfg(windows)]
696    #[test]
697    fn windows_disk_size_survives_removing_the_entry_path() {
698        let dir = tempfile::tempdir().unwrap();
699        let path = dir.path().join("file");
700        std::fs::write(&path, b"content").unwrap();
701        let entry = crate::walk::Entry::from_path(&path).unwrap();
702        let metadata = entry.metadata.as_ref().unwrap();
703        let expected = metadata.allocated_size();
704        std::fs::remove_file(path).unwrap();
705        assert_eq!(
706            size_on_disk(&entry, metadata).unwrap(),
707            expected,
708            "Windows aggregation should use the already-enumerated allocation size"
709        );
710    }
711
712    #[cfg(windows)]
713    #[test]
714    fn windows_disk_size_preserves_zero_sized_directories() {
715        let dir = tempfile::tempdir().unwrap();
716        std::fs::write(dir.path().join("file"), b"content").unwrap();
717        let entry = crate::walk::Entry::from_path(dir.path()).unwrap();
718        let metadata = entry.metadata.as_ref().unwrap();
719        assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0);
720    }
721}