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
33struct Aggregate {
36 path: PathBuf,
38 bytes: u128,
40 errors: u64,
42 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
52pub 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#[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 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 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
269fn 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 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#[derive(Default, Debug)]
354pub struct Statistics {
355 pub entries_traversed: u64,
357 pub smallest_file_in_bytes: u128,
359 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 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 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}