Skip to main content

dua/
aggregate.rs

1use crate::{ByteFormat, InodeFilter, Throttle, WalkOptions, WalkResult, WalkRoot, crossdev};
2use anyhow::Result;
3#[cfg(not(windows))]
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(windows))]
11fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
12    entry.path().size_on_disk_fast(metadata)
13}
14
15#[cfg(windows)]
16#[allow(clippy::unnecessary_wraps)]
17fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
18    Ok(if entry.file_type.is_dir() {
19        0
20    } else {
21        metadata.allocated_size()
22    })
23}
24
25const CLEAR_CURRENT_LINE: &str = "\x1b[2K\r";
26
27/// Aggregate the given `paths` and write information about them to `out` in a human-readable format.
28/// If `compute_total` is set, it will write an additional line with the total size across all given `paths`.
29/// If `sort_by_size_in_bytes` is set, we will sort all sizes (ascending) before outputting them.
30pub fn aggregate(
31    mut out: impl io::Write,
32    mut err: Option<impl io::Write>,
33    walk_options: WalkOptions,
34    compute_total: bool,
35    sort_by_size_in_bytes: bool,
36    byte_format: ByteFormat,
37    paths: Vec<PathBuf>,
38) -> Result<(WalkResult, Statistics)> {
39    let mut res = WalkResult::default();
40    let mut stats = Statistics {
41        smallest_file_in_bytes: u128::MAX,
42        ..Default::default()
43    };
44    let num_roots = paths.len();
45    let mut aggregates = paths
46        .iter()
47        .cloned()
48        .map(|path| {
49            (
50                path,  // root path
51                0u128, // accumulated byte size
52                0u64,  // I/O error count
53            )
54        })
55        .collect::<Vec<_>>();
56    let mut device_ids = vec![0; num_roots];
57    let mut completed = vec![false; num_roots];
58    let mut roots = Vec::with_capacity(num_roots);
59    let has_ignore_patterns = walk_options.ignore_patterns.is_some();
60    for (root_idx, path) in paths.into_iter().enumerate() {
61        let device_id = if walk_options.cross_filesystems {
62            0
63        } else {
64            let Ok(device_id) = crossdev::init(&path) else {
65                aggregates[root_idx].2 += 1;
66                completed[root_idx] = true;
67                continue;
68            };
69            device_id
70        };
71        device_ids[root_idx] = device_id;
72        roots.push(WalkRoot {
73            index: root_idx,
74            pattern_root: has_ignore_patterns.then(|| path.clone()),
75            path,
76            device_id,
77        });
78    }
79    let mut inodes = InodeFilter::default();
80    let progress = Throttle::new(Duration::from_millis(100), Duration::from_secs(1).into());
81    let mut progress_visible = false;
82    let mut next_output = 0;
83
84    // With multiple roots, a shared hard link is attributed to whichever root reaches it first.
85    for (root_idx, event) in
86        walk_options.iter_from_paths(roots, false, crate::walk::Order::Completion)
87    {
88        let entry = match event {
89            crate::walk::RootEvent::Entry(entry) => entry,
90            crate::walk::RootEvent::Finished => {
91                completed[root_idx] = true;
92                if !sort_by_size_in_bytes {
93                    output_completed(
94                        &mut out,
95                        &mut err,
96                        &aggregates,
97                        &completed,
98                        &mut next_output,
99                        &mut progress_visible,
100                        byte_format,
101                    )?;
102                }
103                continue;
104            }
105        };
106        let (_, num_bytes, num_errors) = &mut aggregates[root_idx];
107        stats.entries_traversed += 1;
108        progress.throttled(|| {
109            if let Some(err) = err.as_mut() {
110                write!(err, "Enumerating {} items\r", stats.entries_traversed).ok();
111                progress_visible = true;
112            }
113        });
114        match entry {
115            Ok(entry) => {
116                let file_size = u128::from(match &entry.metadata {
117                    Ok(m)
118                        if (walk_options.count_hard_links || inodes.add(m))
119                            && (walk_options.cross_filesystems
120                                || crossdev::is_same_device(device_ids[root_idx], m)) =>
121                    {
122                        if walk_options.apparent_size {
123                            m.len()
124                        } else {
125                            size_on_disk(&entry, m).unwrap_or_else(|_| {
126                                *num_errors += 1;
127                                0
128                            })
129                        }
130                    }
131                    Ok(_) => 0,
132                    Err(_) => {
133                        *num_errors += 1;
134                        0
135                    }
136                });
137                stats.largest_file_in_bytes = stats.largest_file_in_bytes.max(file_size);
138                stats.smallest_file_in_bytes = stats.smallest_file_in_bytes.min(file_size);
139                *num_bytes += file_size;
140            }
141            Err(_) => *num_errors += 1,
142        }
143    }
144
145    let total = aggregates.iter().map(|(_, bytes, _)| bytes).sum();
146    res.num_errors = aggregates.iter().map(|(_, _, errors)| errors).sum();
147
148    if stats.entries_traversed == 0 {
149        stats.smallest_file_in_bytes = 0;
150    }
151
152    if progress_visible && let Some(err) = err.as_mut() {
153        write!(err, "{CLEAR_CURRENT_LINE}").ok();
154    }
155
156    if sort_by_size_in_bytes {
157        output_sorted(&mut out, aggregates, byte_format)?;
158    } else {
159        // Be sure failed roots are also printed, as they lack a `Finished` event,
160        // the traversal never starts on them.
161        output_completed(
162            &mut out,
163            &mut err,
164            &aggregates,
165            &completed,
166            &mut next_output,
167            &mut progress_visible,
168            byte_format,
169        )?;
170        debug_assert_eq!(next_output, num_roots);
171    }
172
173    if num_roots > 1 && compute_total {
174        output_colored_path(
175            &mut out,
176            Path::new("total"),
177            total,
178            res.num_errors,
179            None,
180            byte_format,
181        )?;
182    }
183    Ok((res, stats))
184}
185
186/// Write the contiguous run of completed roots starting at `next_output`, preserving input order.
187/// Clears a visible progress line before writing the first completed root.
188/// `progress_visible` tracks if progress information is currently shown, taking up the last line.
189fn output_completed<W: io::Write, E: io::Write>(
190    out: &mut W,
191    err: &mut Option<E>,
192    aggregates: &[(std::path::PathBuf, u128, u64)],
193    completed: &[bool],
194    next_output: &mut usize,
195    progress_visible: &mut bool,
196    byte_format: ByteFormat,
197) -> io::Result<()> {
198    let must_report_completed_path = completed.get(*next_output).copied() == Some(true);
199    // Remove the transient progress line before writing permanent results to the terminal.
200    if must_report_completed_path && *progress_visible {
201        if let Some(err) = err.as_mut() {
202            write!(err, "{CLEAR_CURRENT_LINE}").ok();
203        }
204        *progress_visible = false;
205    }
206    while completed.get(*next_output).copied() == Some(true) {
207        let (path, num_bytes, num_errors) = &aggregates[*next_output];
208        output_colored_path(
209            out,
210            path,
211            *num_bytes,
212            *num_errors,
213            path_color_of(path),
214            byte_format,
215        )?;
216        *next_output += 1;
217    }
218    Ok(())
219}
220
221fn output_sorted(
222    out: &mut impl io::Write,
223    mut aggregates: Vec<(std::path::PathBuf, u128, u64)>,
224    byte_format: ByteFormat,
225) -> std::result::Result<(), io::Error> {
226    aggregates.sort_by_key(|&(_, num_bytes, _)| num_bytes);
227    for (path, num_bytes, num_errors) in aggregates {
228        output_colored_path(
229            out,
230            &path,
231            num_bytes,
232            num_errors,
233            path_color_of(&path),
234            byte_format,
235        )?;
236    }
237    Ok(())
238}
239
240fn path_color_of(path: impl AsRef<Path>) -> Option<Color> {
241    (!path.as_ref().is_file()).then_some(Color::Cyan)
242}
243
244fn output_colored_path(
245    out: &mut impl io::Write,
246    path: impl AsRef<Path>,
247    num_bytes: u128,
248    num_errors: u64,
249    path_color: Option<Color>,
250    byte_format: ByteFormat,
251) -> std::result::Result<(), io::Error> {
252    let size = byte_format.display(num_bytes).to_string();
253    let size = size.green();
254    let size_width = byte_format.width();
255    let path = path.as_ref().display();
256
257    let errors = if num_errors != 0 {
258        format!(
259            "  <{num_errors} IO Error{plural_s}>",
260            plural_s = if num_errors > 1 { "s" } else { "" }
261        )
262    } else {
263        String::new()
264    };
265
266    if let Some(color) = path_color {
267        writeln!(out, "{size:>size_width$} {}{errors}", path.color(color))
268    } else {
269        writeln!(out, "{size:>size_width$} {path}{errors}")
270    }
271}
272
273/// Statistics obtained during a filesystem walk
274#[derive(Default, Debug)]
275pub struct Statistics {
276    /// The amount of entries we have seen during filesystem traversal
277    pub entries_traversed: u64,
278    /// The size of the smallest file encountered in bytes
279    pub smallest_file_in_bytes: u128,
280    /// The size of the largest file encountered in bytes
281    pub largest_file_in_bytes: u128,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn byte_counts(out: &[u8]) -> Vec<u128> {
289        let out = std::str::from_utf8(out).unwrap();
290        out.match_indices(" b")
291            .map(|(unit, _)| {
292                out[..unit]
293                    .chars()
294                    .rev()
295                    .take_while(char::is_ascii_digit)
296                    .collect::<String>()
297                    .chars()
298                    .rev()
299                    .collect::<String>()
300                    .parse()
301                    .unwrap()
302            })
303            .collect()
304    }
305
306    #[test]
307    fn completed_roots_stream_in_input_order() {
308        let aggregates = [("first".into(), 1, 0), ("second".into(), 2, 0)];
309        let mut completed = [false, true];
310        let mut next_output = 0;
311        let mut progress_visible = true;
312        let mut out = Vec::new();
313        let mut err = Some(Vec::new());
314
315        output_completed(
316            &mut out,
317            &mut err,
318            &aggregates,
319            &completed,
320            &mut next_output,
321            &mut progress_visible,
322            ByteFormat::Bytes,
323        )
324        .unwrap();
325        assert!(
326            out.is_empty(),
327            "later roots must not overtake earlier roots"
328        );
329
330        completed[0] = true;
331        output_completed(
332            &mut out,
333            &mut err,
334            &aggregates,
335            &completed,
336            &mut next_output,
337            &mut progress_visible,
338            ByteFormat::Bytes,
339        )
340        .unwrap();
341
342        assert_eq!(byte_counts(&out), [1, 2]);
343        let out = String::from_utf8(out).unwrap();
344        assert!(
345            out.find("first").unwrap() < out.find("second").unwrap(),
346            "the first root is also emitted first"
347        );
348        assert_eq!(next_output, 2, "output stopped at root {next_output}");
349        assert_eq!(
350            err.as_deref(),
351            Some(CLEAR_CURRENT_LINE.as_bytes()),
352            "unexpected progress cleanup: {err:?}"
353        );
354        assert!(!progress_visible, "progress remained visible after cleanup");
355    }
356
357    #[test]
358    fn fast_roots_do_not_emit_terminal_erases() {
359        let dir = tempfile::tempdir().unwrap();
360        let paths = [dir.path().join("a"), dir.path().join("b")];
361        for path in &paths {
362            std::fs::write(path, []).unwrap();
363        }
364        let mut out = Vec::new();
365        let mut err = Vec::new();
366
367        aggregate(
368            &mut out,
369            Some(&mut err),
370            WalkOptions {
371                threads: 2,
372                count_hard_links: true,
373                apparent_size: false,
374                cross_filesystems: true,
375                ignore_dirs: std::collections::BTreeSet::default(),
376                ignore_patterns: None,
377            },
378            true,
379            true,
380            ByteFormat::Metric,
381            paths.into(),
382        )
383        .unwrap();
384
385        assert!(
386            err.is_empty(),
387            "fast roots should not clear unseen progress"
388        );
389    }
390
391    #[cfg(unix)]
392    #[test]
393    fn root_device_error_is_reported() {
394        use std::os::unix::fs::symlink;
395
396        let dir = tempfile::tempdir().unwrap();
397        let root = dir.path().join("dangling");
398        symlink(dir.path().join("missing"), &root).unwrap();
399
400        let (result, _) = aggregate(
401            Vec::new(),
402            None::<Vec<u8>>,
403            WalkOptions {
404                threads: 1,
405                count_hard_links: true,
406                apparent_size: true,
407                cross_filesystems: false,
408                ignore_dirs: std::collections::BTreeSet::default(),
409                ignore_patterns: None,
410            },
411            false,
412            true,
413            ByteFormat::Bytes,
414            vec![root],
415        )
416        .unwrap();
417
418        assert_eq!(result.num_errors, 1);
419    }
420
421    #[test]
422    fn ignored_patterns_are_left_out_of_the_reported_size() {
423        let dir = tempfile::tempdir().unwrap();
424        std::fs::create_dir(dir.path().join("cache")).unwrap();
425        std::fs::write(dir.path().join("kept"), [0; 64]).unwrap();
426        std::fs::write(dir.path().join("cache/blob"), [0; 4096]).unwrap();
427
428        // Kept outside the traversed tree so they are not counted themselves.
429        let patterns_dir = tempfile::tempdir().unwrap();
430        let ignore_cache = patterns_dir.path().join("cache-only");
431        let ignore_both = patterns_dir.path().join("cache-and-kept");
432        std::fs::write(&ignore_cache, "cache/\n").unwrap();
433        std::fs::write(&ignore_both, "cache/\nkept\n").unwrap();
434
435        let aggregate_with = |ignore_from: &[PathBuf]| -> u128 {
436            let mut out = Vec::new();
437            aggregate(
438                &mut out,
439                None::<&mut Vec<u8>>,
440                WalkOptions {
441                    threads: 2,
442                    count_hard_links: true,
443                    apparent_size: true,
444                    cross_filesystems: true,
445                    ignore_dirs: std::collections::BTreeSet::default(),
446                    ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(),
447                },
448                false,
449                true,
450                ByteFormat::Bytes,
451                vec![dir.path().to_owned()],
452            )
453            .unwrap();
454            byte_counts(&out)
455                .into_iter()
456                .next()
457                .unwrap_or_else(|| panic!("expected a byte count in {out:?}"))
458        };
459
460        // Directory entries have a size of their own that differs per filesystem - 4096 bytes on
461        // ext4, next to nothing on APFS - so only differences between runs are compared here.
462        let full = aggregate_with(&[]);
463        let without_cache = aggregate_with(&[ignore_cache]);
464        let without_either = aggregate_with(&[ignore_both]);
465
466        assert!(
467            full >= 4096 + 64,
468            "without patterns both files are counted, got {full}"
469        );
470        assert!(
471            full - without_cache >= 4096,
472            "excluding `cache/` drops at least the 4096-byte file inside it, \
473             but only {} bytes disappeared",
474            full - without_cache
475        );
476        assert_eq!(
477            without_cache - without_either,
478            64,
479            "the 64-byte file is still counted until a pattern matches it too"
480        );
481    }
482    #[cfg(windows)]
483    #[test]
484    fn windows_disk_size_survives_removing_the_entry_path() {
485        let dir = tempfile::tempdir().unwrap();
486        let path = dir.path().join("file");
487        std::fs::write(&path, b"content").unwrap();
488        let entry = crate::walk::Entry::from_path(&path).unwrap();
489        let metadata = entry.metadata.as_ref().unwrap();
490        let expected = metadata.allocated_size();
491        std::fs::remove_file(path).unwrap();
492        assert_eq!(
493            size_on_disk(&entry, metadata).unwrap(),
494            expected,
495            "Windows aggregation should use the already-enumerated allocation size"
496        );
497    }
498
499    #[cfg(windows)]
500    #[test]
501    fn windows_disk_size_preserves_zero_sized_directories() {
502        let dir = tempfile::tempdir().unwrap();
503        std::fs::write(dir.path().join("file"), b"content").unwrap();
504        let entry = crate::walk::Entry::from_path(dir.path()).unwrap();
505        let metadata = entry.metadata.as_ref().unwrap();
506        assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0);
507    }
508}