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