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(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
27pub(crate) struct TraversalProgress<W: io::Write> {
29 writer: Option<W>,
30 throttle: Throttle,
31 visible: bool,
33}
34
35impl<W: io::Write> TraversalProgress<W> {
36 pub(crate) fn new(writer: Option<W>) -> Self {
37 Self {
38 writer,
39 throttle: Throttle::new(Duration::from_millis(100), Duration::from_secs(1).into()),
40 visible: false,
41 }
42 }
43
44 pub(crate) fn update(&mut self, entries: u64) {
45 if self.throttle.can_update() {
46 self.write(entries);
47 }
48 }
49
50 fn write(&mut self, entries: u64) {
51 if let Some(writer) = self.writer.as_mut() {
52 write!(writer, "Enumerating {entries} items\r").ok();
53 self.visible = true;
54 }
55 }
56
57 pub(crate) fn clear(&mut self) {
58 if self.visible {
59 if let Some(writer) = self.writer.as_mut() {
60 write!(writer, "{CLEAR_CURRENT_LINE}").ok();
61 }
62 self.visible = false;
63 }
64 }
65}
66
67struct Aggregate {
70 display_path: PathBuf,
72 bytes: u128,
74 errors: u64,
76 is_file: bool,
78}
79
80impl Aggregate {
81 fn path_color(&self) -> Option<Color> {
82 (!self.is_file).then_some(Color::Cyan)
83 }
84}
85
86pub fn aggregate(
90 out: (impl io::Write, bool),
91 err: Option<impl io::Write>,
92 walk_options: WalkOptions,
93 compute_total: bool,
94 sort_by_size_in_bytes: bool,
95 byte_format: ByteFormat,
96 paths: Vec<PathBuf>,
97) -> Result<(WalkResult, Statistics)> {
98 let cwd = std::env::current_dir()?;
99 aggregate_inner(
100 out,
101 err,
102 walk_options,
103 compute_total,
104 sort_by_size_in_bytes,
105 byte_format,
106 paths.into_iter().map(|display_path| {
107 let path = gix::path::normalize(display_path.as_path().into(), &cwd)
108 .map_or_else(|| display_path.clone(), |path| path.into_owned());
109 (path, display_path, None)
110 }),
111 )
112}
113
114#[cfg(any(windows, target_os = "macos"))]
119pub fn aggregate_entries(
120 out: (impl io::Write, bool),
121 err: Option<impl io::Write>,
122 walk_options: WalkOptions,
123 compute_total: bool,
124 sort_by_size_in_bytes: bool,
125 byte_format: ByteFormat,
126 entries: Vec<dua_core::Entry>,
127) -> Result<(WalkResult, Statistics)> {
128 aggregate_inner(
129 out,
130 err,
131 walk_options,
132 compute_total,
133 sort_by_size_in_bytes,
134 byte_format,
135 entries.into_iter().map(|entry| {
136 let path = entry.path();
137 (path.clone(), path, Some(entry))
138 }),
139 )
140}
141
142fn aggregate_inner(
143 out: (impl io::Write, bool),
144 err: Option<impl io::Write>,
145 walk_options: WalkOptions,
146 compute_total: bool,
147 sort_by_size_in_bytes: bool,
148 byte_format: ByteFormat,
149 inputs: impl ExactSizeIterator<Item = (PathBuf, PathBuf, Option<crate::walk::Entry>)>,
150) -> Result<(WalkResult, Statistics)> {
151 let (mut out, out_supports_colors) = out;
152 let output_options = (byte_format, out_supports_colors);
153 #[cfg(target_os = "macos")]
154 let apfs_clone_accounting = walk_options.metadata_options.apfs_clone_metadata;
155 let mut res = WalkResult::default();
156 let mut stats = Statistics::default();
157 let mut smallest_file_in_bytes = None;
158 let num_roots = inputs.len();
159 let mut aggregates = Vec::with_capacity(num_roots);
160 let mut device_ids = vec![0; num_roots];
161 let mut completed = vec![false; num_roots];
162 let mut roots = Vec::with_capacity(num_roots);
163 let has_ignore_patterns = walk_options.ignore_patterns.is_some();
164 for (root_idx, (path, display_path, prepared_entry)) in inputs.enumerate() {
165 #[cfg(not(any(windows, target_os = "macos")))]
166 let _ = prepared_entry;
167
168 aggregates.push(Aggregate {
169 display_path,
170 bytes: 0,
171 errors: 0,
172 is_file: false,
173 });
174 let device_id = if walk_options.cross_filesystems {
175 0
176 } else {
177 #[cfg(target_os = "macos")]
178 let root_device_id = prepared_entry
179 .as_ref()
180 .and_then(|entry| entry.metadata.as_ref().ok())
181 .map_or_else(|| crossdev::init(&path), |metadata| Ok(metadata.dev()));
182 #[cfg(not(target_os = "macos"))]
183 let root_device_id = crossdev::init(&path);
184
185 let Ok(device_id) = root_device_id else {
186 aggregates[root_idx].errors += 1;
187 completed[root_idx] = true;
188 continue;
189 };
190 device_id
191 };
192 device_ids[root_idx] = device_id;
193 roots.push(WalkRoot {
194 index: root_idx,
195 pattern_root: has_ignore_patterns.then(|| path.clone()),
196 path,
197 #[cfg(any(windows, target_os = "macos"))]
198 entry: prepared_entry,
199 device_id,
200 });
201 }
202 let mut inodes = InodeFilter::default();
203 let mut progress = TraversalProgress::new(err);
204 let mut next_output = 0;
205
206 for (root_idx, event) in
208 walk_options.iter_from_paths(roots, false, crate::walk::Order::Completion)
209 {
210 let entry = match event {
211 crate::walk::RootEvent::Entry(entry) => entry,
212 crate::walk::RootEvent::Finished => {
213 completed[root_idx] = true;
214 if !sort_by_size_in_bytes {
215 output_completed(
216 &mut out,
217 &aggregates,
218 &completed,
219 &mut next_output,
220 &mut progress,
221 output_options,
222 )?;
223 }
224 continue;
225 }
226 };
227 let aggregate = &mut aggregates[root_idx];
228 stats.entries_traversed += 1;
229 progress.update(stats.entries_traversed);
230 match entry {
231 Ok(entry) => {
232 if entry.depth == 0 {
233 aggregate.is_file = entry.file_type.is_file()
234 || entry.file_type.is_symlink() && entry.path().is_file();
235 }
236 let file_size = u128::from(match &entry.metadata {
237 Ok(m)
238 if (walk_options.count_hard_links || inodes.add(&entry, m))
239 && (walk_options.cross_filesystems
240 || crossdev::is_same_device(device_ids[root_idx], m)) =>
241 {
242 if walk_options.apparent_size {
243 m.len()
244 } else {
245 #[cfg(target_os = "macos")]
246 if apfs_clone_accounting {
247 inodes.allocated_size(m)
248 } else {
249 m.allocated_size()
250 }
251 #[cfg(not(target_os = "macos"))]
252 {
253 size_on_disk(&entry, m).unwrap_or_else(|_| {
254 aggregate.errors += 1;
255 0
256 })
257 }
258 }
259 }
260 Ok(_) => 0,
261 Err(_) => {
262 aggregate.errors += 1;
263 0
264 }
265 });
266 stats.largest_file_in_bytes = stats.largest_file_in_bytes.max(file_size);
267 smallest_file_in_bytes = smallest_file_in_bytes
268 .map_or(file_size, |size: u128| size.min(file_size))
269 .into();
270 aggregate.bytes += file_size;
271 }
272 Err(_) => aggregate.errors += 1,
273 }
274 }
275
276 let total = aggregates.iter().map(|aggregate| aggregate.bytes).sum();
277 res.num_errors = aggregates.iter().map(|aggregate| aggregate.errors).sum();
278
279 stats.smallest_file_in_bytes = smallest_file_in_bytes.unwrap_or_default();
280
281 progress.clear();
282
283 if sort_by_size_in_bytes {
284 output_sorted(&mut out, aggregates, output_options)?;
285 } else {
286 output_completed(
289 &mut out,
290 &aggregates,
291 &completed,
292 &mut next_output,
293 &mut progress,
294 output_options,
295 )?;
296 debug_assert_eq!(next_output, num_roots);
297 }
298
299 if num_roots > 1 && compute_total {
300 output_colored_path(
301 &mut out,
302 out_supports_colors,
303 Path::new("total"),
304 total,
305 res.num_errors,
306 None,
307 byte_format,
308 )?;
309 }
310 Ok((res, stats))
311}
312
313fn output_completed<W: io::Write, E: io::Write>(
316 out: &mut W,
317 aggregates: &[Aggregate],
318 completed: &[bool],
319 next_output: &mut usize,
320 progress: &mut TraversalProgress<E>,
321 (byte_format, out_supports_colors): (ByteFormat, bool),
322) -> io::Result<()> {
323 let must_report_completed_path = completed.get(*next_output).copied() == Some(true);
324 if must_report_completed_path {
326 progress.clear();
327 }
328 while completed.get(*next_output).copied() == Some(true) {
329 let aggregate = &aggregates[*next_output];
330 output_colored_path(
331 out,
332 out_supports_colors,
333 &aggregate.display_path,
334 aggregate.bytes,
335 aggregate.errors,
336 aggregate.path_color(),
337 byte_format,
338 )?;
339 *next_output += 1;
340 }
341 Ok(())
342}
343
344fn output_sorted(
345 out: &mut impl io::Write,
346 mut aggregates: Vec<Aggregate>,
347 (byte_format, out_supports_colors): (ByteFormat, bool),
348) -> std::result::Result<(), io::Error> {
349 aggregates.sort_by_key(|aggregate| aggregate.bytes);
350 for aggregate in aggregates {
351 output_colored_path(
352 out,
353 out_supports_colors,
354 &aggregate.display_path,
355 aggregate.bytes,
356 aggregate.errors,
357 aggregate.path_color(),
358 byte_format,
359 )?;
360 }
361 Ok(())
362}
363
364pub(crate) fn output_colored_path(
365 out: &mut impl io::Write,
366 out_supports_colors: bool,
367 path: impl AsRef<Path>,
368 num_bytes: u128,
369 num_errors: u64,
370 path_color: Option<Color>,
371 byte_format: ByteFormat,
372) -> std::result::Result<(), io::Error> {
373 let size = byte_format.display(num_bytes).to_string();
374 let size_width = byte_format.width();
375 let path = path.as_ref().display();
376
377 let errors = if num_errors != 0 {
378 format!(
379 " <{num_errors} IO Error{plural_s}>",
380 plural_s = if num_errors > 1 { "s" } else { "" }
381 )
382 } else {
383 String::new()
384 };
385
386 if !out_supports_colors {
387 return writeln!(out, "{size:>size_width$} {path}{errors}");
388 }
389
390 let size = size.green();
391 if let Some(color) = path_color {
392 writeln!(out, "{size:>size_width$} {}{errors}", path.color(color))
393 } else {
394 writeln!(out, "{size:>size_width$} {path}{errors}")
395 }
396}
397
398#[derive(Default, Debug)]
400pub struct Statistics {
401 pub entries_traversed: u64,
403 pub smallest_file_in_bytes: u128,
405 pub largest_file_in_bytes: u128,
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn byte_counts(out: &[u8]) -> Vec<u128> {
414 let out = std::str::from_utf8(out).unwrap();
415 out.match_indices(" b")
416 .map(|(unit, _)| {
417 out[..unit]
418 .chars()
419 .rev()
420 .take_while(char::is_ascii_digit)
421 .collect::<String>()
422 .chars()
423 .rev()
424 .collect::<String>()
425 .parse()
426 .unwrap()
427 })
428 .collect()
429 }
430
431 #[test]
432 fn traversal_progress_writes_and_clears_one_line() {
433 let mut progress = TraversalProgress::new(Some(Vec::new()));
434 progress.write(42);
435 progress.clear();
436
437 assert_eq!(
438 progress.writer.as_deref(),
439 Some(b"Enumerating 42 items\r\x1b[2K\r".as_slice())
440 );
441 assert!(!progress.visible);
442 }
443
444 #[cfg(any(windows, target_os = "macos"))]
445 #[test]
446 fn file_as_root_keeps_cached_metadata_after_removal() {
447 let directory = tempfile::tempdir().unwrap();
448 let path = directory.path().join("prepared-file");
449
450 for sort_by_size_in_bytes in [false, true] {
451 std::fs::write(&path, b"cached metadata").unwrap();
452 let expected_size = std::fs::metadata(&path).unwrap().len();
453 let entry = dua_core::read_dir(directory.path(), dua_core::Options::default())
454 .unwrap()
455 .next()
456 .unwrap()
457 .unwrap();
458 std::fs::remove_file(&path).unwrap();
459
460 let mut out = Vec::new();
461 let (result, statistics) = aggregate_entries(
462 (&mut out, false),
463 None::<Vec<u8>>,
464 WalkOptions {
465 threads: 1,
466 count_hard_links: false,
467 apparent_size: true,
468 cross_filesystems: false,
469 ignore_dirs: std::collections::BTreeSet::default(),
470 ignore_patterns: None,
471 metadata_options: crate::TraversalOptions::default(),
472 },
473 false,
474 sort_by_size_in_bytes,
475 ByteFormat::Bytes,
476 vec![entry],
477 )
478 .unwrap();
479
480 assert_eq!(result.num_errors, 0);
481 assert_eq!(statistics.entries_traversed, 1);
482 assert_eq!(byte_counts(&out), [u128::from(expected_size)]);
483 let out = String::from_utf8(out).unwrap();
484 assert!(
485 out.contains(&format!(" {}\n", path.display())),
486 "the bulk reader must preserve the cached file type after removal; querying the \
487 path again would fail, leave `is_file` false, and incorrectly add cyan directory \
488 coloring: {out:?}"
489 );
490 }
491 }
492
493 #[cfg(target_os = "macos")]
494 #[test]
495 fn overlapping_directory_roots_preserve_stat_link_count_cycles() {
496 use std::os::unix::fs::MetadataExt;
497
498 const OVERLAPPING_VISITS: u64 = 5;
499
500 let directory = tempfile::tempdir().unwrap();
501 let parent = directory.path().join("parent");
502 let child = parent.join("child");
503 let grandchild = child.join("grandchild");
504 std::fs::create_dir_all(&grandchild).unwrap();
505 let file = grandchild.join("file");
506 std::fs::write(&file, b"repeated directory contents").unwrap();
507
508 let parent_metadata = std::fs::symlink_metadata(&parent).unwrap();
509 let child_metadata = std::fs::symlink_metadata(&child).unwrap();
510 let grandchild_metadata = std::fs::symlink_metadata(&grandchild).unwrap();
511 let file_metadata = std::fs::symlink_metadata(&file).unwrap();
512 assert!(
513 child_metadata.nlink() > 1,
514 "expected multiple links for {child:?}, got {}",
515 child_metadata.nlink()
516 );
517 assert!(
518 grandchild_metadata.nlink() > 1,
519 "expected multiple links for {grandchild:?}, got {}",
520 grandchild_metadata.nlink()
521 );
522
523 let roots = vec![parent, child.clone(), child.clone(), child.clone(), child];
524
525 for count_hard_links in [false, true] {
526 let directory_visits = |metadata: &std::fs::Metadata| {
527 if count_hard_links || metadata.nlink() <= 1 {
528 OVERLAPPING_VISITS
529 } else {
530 OVERLAPPING_VISITS.div_ceil(metadata.nlink())
531 }
532 };
533 let expected = u128::from(parent_metadata.len())
534 + u128::from(directory_visits(&child_metadata) * child_metadata.len())
535 + u128::from(directory_visits(&grandchild_metadata) * grandchild_metadata.len())
536 + u128::from(OVERLAPPING_VISITS * file_metadata.len());
537
538 let mut out = Vec::new();
539 let result = aggregate(
540 (&mut out, false),
541 None::<Vec<u8>>,
542 WalkOptions {
543 threads: 1,
544 count_hard_links,
545 apparent_size: true,
546 cross_filesystems: true,
547 ignore_dirs: std::collections::BTreeSet::default(),
548 ignore_patterns: None,
549 metadata_options: crate::TraversalOptions::default(),
550 },
551 true,
552 false,
553 ByteFormat::Bytes,
554 roots.clone(),
555 )
556 .unwrap();
557
558 assert_eq!(result.0.num_errors, 0);
559 assert_eq!(
560 byte_counts(&out).last().copied(),
561 Some(expected),
562 "overlapping directory totals with count_hard_links={count_hard_links}"
563 );
564 }
565 }
566
567 #[cfg(target_os = "macos")]
568 #[test]
569 fn full_apfs_clones_preserve_private_forks_hard_links_and_logical_sizes() {
570 use std::io::Write as _;
571 use std::os::unix::fs::MetadataExt;
572
573 const STAT_BLOCK_BYTES: u128 = 512;
574 const RESOURCE_FORK_BYTES: usize = 4096;
575 const DATA_FORK_BYTES: usize = RESOURCE_FORK_BYTES * 2;
576
577 let directory = tempfile::tempdir().unwrap();
578 let original = directory.path().join("original");
579 let clone = directory.path().join("clone");
580 let partial_clone = directory.path().join("partial-clone");
581 let hard_link = directory.path().join("hard-link");
582 std::fs::write(&original, vec![7; DATA_FORK_BYTES]).unwrap();
583 std::fs::copy(&original, &clone).unwrap();
587 std::fs::copy(&original, &partial_clone).unwrap();
588 std::fs::write(clone.join("..namedfork/rsrc"), vec![5; RESOURCE_FORK_BYTES]).unwrap();
589 std::fs::OpenOptions::new()
590 .write(true)
591 .open(&partial_clone)
592 .unwrap()
593 .write_all(&[9])
594 .unwrap();
595 std::fs::hard_link(&original, &hard_link).unwrap();
596
597 let original_metadata = std::fs::metadata(&original).unwrap();
598 let clone_metadata = std::fs::metadata(&clone).unwrap();
599 let partial_metadata = std::fs::metadata(&partial_clone).unwrap();
600 let directory_metadata = std::fs::metadata(directory.path()).unwrap();
601 let allocated_size = u128::from(original_metadata.blocks()) * STAT_BLOCK_BYTES;
602 let clone_allocated_size = u128::from(clone_metadata.blocks()) * STAT_BLOCK_BYTES;
603 let partial_allocated_size = u128::from(partial_metadata.blocks()) * STAT_BLOCK_BYTES;
604 let directory_allocated_size = u128::from(directory_metadata.blocks()) * STAT_BLOCK_BYTES;
605 let clone_private_size = clone_allocated_size - allocated_size;
606 let apparent_size = u128::from(original_metadata.len());
607 let directory_apparent_size = u128::from(directory_metadata.len());
608 assert_ne!(
609 clone_private_size, 0,
610 "the cloned fixture must own separately allocated resource-fork blocks"
611 );
612
613 let directory_root = || vec![directory.path().to_owned()];
614 for (case, roots, apparent_size_requested, count_hard_links, expected_total) in [
615 (
616 "full and partial clones retain private forks",
617 directory_root(),
618 false,
619 false,
620 directory_allocated_size
621 + allocated_size
622 + partial_allocated_size
623 + clone_private_size,
624 ),
625 (
626 "explicit hard links remain counted",
627 directory_root(),
628 false,
629 true,
630 directory_allocated_size
631 + allocated_size * 2
632 + partial_allocated_size
633 + clone_private_size,
634 ),
635 (
636 "logical sizes remain independent",
637 directory_root(),
638 true,
639 false,
640 directory_apparent_size + apparent_size * 3,
641 ),
642 (
643 "logical sizes count requested hard links",
644 directory_root(),
645 true,
646 true,
647 directory_apparent_size + apparent_size * 4,
648 ),
649 (
650 "clone-first roots preserve explicit hard links",
651 vec![clone.clone(), original.clone(), hard_link],
652 false,
653 true,
654 allocated_size * 2 + clone_private_size,
655 ),
656 (
657 "repeated cloned roots are not distinct clone inodes",
658 vec![clone.clone(), clone, original],
659 false,
660 false,
661 clone_allocated_size * 2,
662 ),
663 ] {
664 let mut output = Vec::new();
665 let (result, _) = aggregate(
666 (&mut output, false),
667 None::<Vec<u8>>,
668 WalkOptions {
669 threads: 2,
670 count_hard_links,
671 apparent_size: apparent_size_requested,
672 cross_filesystems: true,
673 ignore_dirs: std::collections::BTreeSet::default(),
674 ignore_patterns: None,
675 metadata_options: crate::TraversalOptions {
676 apfs_clone_metadata: true,
677 },
678 },
679 true,
680 true,
681 ByteFormat::Bytes,
682 roots,
683 )
684 .unwrap();
685
686 assert_eq!(result.num_errors, 0, "unexpected traversal errors: {case}");
687 assert_eq!(
688 byte_counts(&output).last().copied(),
689 Some(expected_total),
690 "incorrect aggregate for {case}"
691 );
692 }
693
694 let entries = dua_core::read_dir(
695 directory.path(),
696 dua_core::Options {
697 apfs_clone_metadata: true,
698 },
699 )
700 .unwrap()
701 .collect::<std::io::Result<Vec<_>>>()
702 .unwrap();
703 let mut output = Vec::new();
704 let (result, _) = aggregate_entries(
705 (&mut output, false),
706 None::<Vec<u8>>,
707 WalkOptions {
708 threads: 2,
709 count_hard_links: false,
710 apparent_size: false,
711 cross_filesystems: false,
712 ignore_dirs: std::collections::BTreeSet::default(),
713 ignore_patterns: None,
714 metadata_options: crate::TraversalOptions {
715 apfs_clone_metadata: true,
716 },
717 },
718 true,
719 true,
720 ByteFormat::Bytes,
721 entries,
722 )
723 .unwrap();
724
725 assert_eq!(
726 result.num_errors, 0,
727 "prepared sibling roots should retain their cached filesystem identities"
728 );
729 assert_eq!(
730 byte_counts(&output).last().copied(),
731 Some(allocated_size + partial_allocated_size + clone_private_size),
732 "prepared sibling roots should deduplicate cloned data, retain private forks, \
733 and omit their parent directory"
734 );
735 }
736
737 #[test]
738 fn completed_roots_stream_in_input_order() {
739 let aggregates = [
740 Aggregate {
741 display_path: "first".into(),
742 bytes: 1,
743 errors: 0,
744 is_file: false,
745 },
746 Aggregate {
747 display_path: "second".into(),
748 bytes: 2,
749 errors: 0,
750 is_file: false,
751 },
752 ];
753 let mut completed = [false, true];
754 let mut next_output = 0;
755 let mut progress = TraversalProgress::new(Some(Vec::new()));
756 progress.visible = true;
757 let mut out = Vec::new();
758
759 output_completed(
760 &mut out,
761 &aggregates,
762 &completed,
763 &mut next_output,
764 &mut progress,
765 (ByteFormat::Bytes, false),
766 )
767 .unwrap();
768 assert!(
769 out.is_empty(),
770 "later roots must not overtake earlier roots"
771 );
772
773 completed[0] = true;
774 output_completed(
775 &mut out,
776 &aggregates,
777 &completed,
778 &mut next_output,
779 &mut progress,
780 (ByteFormat::Bytes, false),
781 )
782 .unwrap();
783
784 assert_eq!(byte_counts(&out), [1, 2]);
785 let out = String::from_utf8(out).unwrap();
786 assert!(
787 out.find("first").unwrap() < out.find("second").unwrap(),
788 "the first root is also emitted first"
789 );
790 assert_eq!(next_output, 2, "output stopped at root {next_output}");
791 assert_eq!(
792 progress.writer.as_deref(),
793 Some(CLEAR_CURRENT_LINE.as_bytes()),
794 "unexpected progress cleanup"
795 );
796 assert!(!progress.visible, "progress remained visible after cleanup");
797 }
798
799 #[test]
800 fn fast_roots_do_not_emit_terminal_erases() {
801 let dir = tempfile::tempdir().unwrap();
802 let paths = [dir.path().join("a"), dir.path().join("b")];
803 for path in &paths {
804 std::fs::write(path, []).unwrap();
805 }
806 let mut out = Vec::new();
807 let mut err = Vec::new();
808
809 aggregate(
810 (&mut out, false),
811 Some(&mut err),
812 WalkOptions {
813 threads: 2,
814 count_hard_links: true,
815 apparent_size: false,
816 cross_filesystems: true,
817 ignore_dirs: std::collections::BTreeSet::default(),
818 ignore_patterns: None,
819 metadata_options: crate::TraversalOptions::default(),
820 },
821 true,
822 true,
823 ByteFormat::Metric,
824 paths.into(),
825 )
826 .unwrap();
827
828 assert!(
829 err.is_empty(),
830 "fast roots should not clear unseen progress"
831 );
832 }
833
834 #[cfg(unix)]
835 #[test]
836 fn root_device_error_is_reported() {
837 use std::os::unix::fs::symlink;
838
839 let dir = tempfile::tempdir().unwrap();
840 let root = dir.path().join("dangling");
841 symlink(dir.path().join("missing"), &root).unwrap();
842
843 let (result, _) = aggregate(
844 (Vec::new(), false),
845 None::<Vec<u8>>,
846 WalkOptions {
847 threads: 1,
848 count_hard_links: true,
849 apparent_size: true,
850 cross_filesystems: false,
851 ignore_dirs: std::collections::BTreeSet::default(),
852 ignore_patterns: None,
853 metadata_options: crate::TraversalOptions::default(),
854 },
855 false,
856 true,
857 ByteFormat::Bytes,
858 vec![root],
859 )
860 .unwrap();
861
862 assert_eq!(result.num_errors, 1);
863 }
864
865 #[test]
866 fn ignored_patterns_are_left_out_of_the_reported_size() {
867 let dir = tempfile::tempdir().unwrap();
868 std::fs::create_dir(dir.path().join("cache")).unwrap();
869 std::fs::write(dir.path().join("kept"), [0; 64]).unwrap();
870 std::fs::write(dir.path().join("cache/blob"), [0; 4096]).unwrap();
871
872 let patterns_dir = tempfile::tempdir().unwrap();
874 let ignore_cache = patterns_dir.path().join("cache-only");
875 let ignore_both = patterns_dir.path().join("cache-and-kept");
876 std::fs::write(&ignore_cache, "cache/\n").unwrap();
877 std::fs::write(&ignore_both, "cache/\nkept\n").unwrap();
878
879 let aggregate_with = |ignore_from: &[PathBuf]| -> u128 {
880 let mut out = Vec::new();
881 aggregate(
882 (&mut out, false),
883 None::<&mut Vec<u8>>,
884 WalkOptions {
885 threads: 2,
886 count_hard_links: true,
887 apparent_size: true,
888 cross_filesystems: true,
889 ignore_dirs: std::collections::BTreeSet::default(),
890 ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(),
891 metadata_options: crate::TraversalOptions::default(),
892 },
893 false,
894 true,
895 ByteFormat::Bytes,
896 vec![dir.path().to_owned()],
897 )
898 .unwrap();
899 byte_counts(&out)
900 .into_iter()
901 .next()
902 .unwrap_or_else(|| panic!("expected a byte count in {out:?}"))
903 };
904
905 let full = aggregate_with(&[]);
908 let without_cache = aggregate_with(&[ignore_cache]);
909 let without_either = aggregate_with(&[ignore_both]);
910
911 assert!(
912 full >= 4096 + 64,
913 "without patterns both files are counted, got {full}"
914 );
915 assert!(
916 full - without_cache >= 4096,
917 "excluding `cache/` drops at least the 4096-byte file inside it, \
918 but only {} bytes disappeared",
919 full - without_cache
920 );
921 assert_eq!(
922 without_cache - without_either,
923 64,
924 "the 64-byte file is still counted until a pattern matches it too"
925 );
926 }
927 #[cfg(windows)]
928 #[test]
929 fn windows_disk_size_survives_removing_the_entry_path() {
930 let dir = tempfile::tempdir().unwrap();
931 let path = dir.path().join("file");
932 std::fs::write(&path, b"content").unwrap();
933 let entry =
934 crate::walk::Entry::from_path(&path, crate::TraversalOptions::default()).unwrap();
935 let metadata = entry.metadata.as_ref().unwrap();
936 let expected = metadata.allocated_size();
937 std::fs::remove_file(path).unwrap();
938 assert_eq!(
939 size_on_disk(&entry, metadata).unwrap(),
940 expected,
941 "Windows aggregation should use the already-enumerated allocation size"
942 );
943 }
944
945 #[cfg(windows)]
946 #[test]
947 fn windows_disk_size_preserves_zero_sized_directories() {
948 let dir = tempfile::tempdir().unwrap();
949 std::fs::write(dir.path().join("file"), b"content").unwrap();
950 let entry =
951 crate::walk::Entry::from_path(dir.path(), crate::TraversalOptions::default()).unwrap();
952 let metadata = entry.metadata.as_ref().unwrap();
953 assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0);
954 }
955}