1use crate::{
2 ByteFormat, InodeFilter, Throttle, WalkOptions, WalkResult, WalkRoot, crossdev,
3 snapshot::{Replay, Snapshot},
4 tree::metadata_io_error_count,
5};
6use anyhow::{Context, Result};
7#[cfg(not(any(windows, target_os = "macos")))]
8use filesize::PathExt;
9use owo_colors::{AnsiColors as Color, OwoColorize};
10use std::path::PathBuf;
11use std::time::Duration;
12use std::{io, path::Path};
13
14#[cfg(not(any(windows, target_os = "macos")))]
15fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
16 entry.path().size_on_disk_fast(metadata)
17}
18
19#[cfg(windows)]
20#[allow(clippy::unnecessary_wraps)]
21fn size_on_disk(entry: &crate::walk::Entry, metadata: &crate::walk::Metadata) -> io::Result<u64> {
22 Ok(if entry.file_type.is_dir() {
23 0
24 } else {
25 metadata.allocated_size()
26 })
27}
28
29const CLEAR_CURRENT_LINE: &str = "\x1b[2K\r";
30
31pub(crate) struct TraversalProgress<W: io::Write> {
33 writer: Option<W>,
34 throttle: Throttle,
35 visible: bool,
37}
38
39impl<W: io::Write> TraversalProgress<W> {
40 pub(crate) fn new(writer: Option<W>) -> Self {
41 Self {
42 writer,
43 throttle: Throttle::new(Duration::from_millis(100), Duration::from_secs(1).into()),
44 visible: false,
45 }
46 }
47
48 pub(crate) fn update(&mut self, entries: u64) {
49 if self.throttle.can_update() {
50 self.write(entries);
51 }
52 }
53
54 fn write(&mut self, entries: u64) {
55 if let Some(writer) = self.writer.as_mut() {
56 write!(writer, "Enumerating {entries} items\r").ok();
57 self.visible = true;
58 }
59 }
60
61 pub(crate) fn clear(&mut self) {
62 if self.visible {
63 if let Some(writer) = self.writer.as_mut() {
64 write!(writer, "{CLEAR_CURRENT_LINE}").ok();
65 }
66 self.visible = false;
67 }
68 }
69}
70
71struct Aggregate {
74 display_path: PathBuf,
76 bytes: u128,
78 errors: u64,
80 is_file: bool,
82}
83
84impl Aggregate {
85 fn path_color(&self) -> Option<Color> {
86 (!self.is_file).then_some(Color::Cyan)
87 }
88}
89
90pub fn aggregate(
94 out: (impl io::Write, bool),
95 err: Option<impl io::Write>,
96 walk_options: WalkOptions,
97 compute_total: bool,
98 sort_by_size_in_bytes: bool,
99 byte_format: ByteFormat,
100 paths: Vec<PathBuf>,
101) -> Result<(WalkResult, Statistics)> {
102 let cwd = std::env::current_dir()?;
103 aggregate_inner(
104 out,
105 err,
106 walk_options,
107 compute_total,
108 sort_by_size_in_bytes,
109 byte_format,
110 paths.into_iter().map(|display_path| {
111 let path = gix::path::normalize(display_path.as_path().into(), &cwd)
112 .map_or_else(|| display_path.clone(), |path| path.into_owned());
113 (path, display_path, None)
114 }),
115 )
116}
117
118#[cfg(any(windows, target_os = "macos"))]
123pub fn aggregate_entries(
124 out: (impl io::Write, bool),
125 err: Option<impl io::Write>,
126 walk_options: WalkOptions,
127 compute_total: bool,
128 sort_by_size_in_bytes: bool,
129 byte_format: ByteFormat,
130 entries: Vec<dua_core::Entry>,
131) -> Result<(WalkResult, Statistics)> {
132 aggregate_inner(
133 out,
134 err,
135 walk_options,
136 compute_total,
137 sort_by_size_in_bytes,
138 byte_format,
139 entries.into_iter().map(|entry| {
140 let path = entry.path();
141 (path.clone(), path, Some(entry))
142 }),
143 )
144}
145
146pub fn aggregate_snapshot(
148 out: (impl io::Write, bool),
149 snapshot: &Snapshot,
150 compute_total: bool,
151 sort_by_size_in_bytes: bool,
152 byte_format: ByteFormat,
153) -> Result<WalkResult> {
154 let aggregates = snapshot
155 .roots
156 .iter()
157 .map(|&root| {
158 let entry = snapshot
159 .traversal
160 .tree
161 .entry(root)
162 .context("snapshot root does not exist")?;
163 let data = entry.data;
164 Ok(Aggregate {
165 display_path: entry.name.into_owned(),
166 bytes: data.size,
167 errors: metadata_io_error_count(&snapshot.traversal.tree, &[root]),
168 is_file: !data.is_dir,
169 })
170 })
171 .collect::<Result<Vec<_>>>()?;
172 write_snapshot_aggregates(
173 out,
174 aggregates,
175 compute_total,
176 sort_by_size_in_bytes,
177 byte_format,
178 )
179}
180
181pub fn aggregate_replay<R: io::Read + io::Seek>(
183 out: (impl io::Write, bool),
184 replay: &mut Replay<R>,
185 compute_total: bool,
186 sort_by_size_in_bytes: bool,
187 byte_format: ByteFormat,
188) -> Result<WalkResult> {
189 let mut aggregates: Vec<Aggregate> = Vec::new();
190 replay.for_each_entry(|entry| {
191 let error = u64::from(entry.data.metadata_io_error);
192 if entry.depth == 0 {
193 aggregates
194 .try_reserve(1)
195 .context("could not grow snapshot root table")?;
196 aggregates.push(Aggregate {
197 bytes: entry.data.size,
198 is_file: !entry.data.is_dir,
199 display_path: entry.name().into_owned(),
200 errors: error,
201 });
202 } else if error != 0 {
203 let root = aggregates
204 .last_mut()
205 .context("snapshot entry precedes its root")?;
206 root.errors = root.errors.saturating_add(error);
207 }
208 Ok(())
209 })?;
210 write_snapshot_aggregates(
211 out,
212 aggregates,
213 compute_total,
214 sort_by_size_in_bytes,
215 byte_format,
216 )
217}
218
219fn write_snapshot_aggregates(
220 out: (impl io::Write, bool),
221 aggregates: Vec<Aggregate>,
222 compute_total: bool,
223 sort_by_size_in_bytes: bool,
224 byte_format: ByteFormat,
225) -> Result<WalkResult> {
226 let (mut out, out_supports_colors) = out;
227 let output_options = (byte_format, out_supports_colors);
228 let total = aggregates.iter().try_fold(0u128, |total, aggregate| {
229 total
230 .checked_add(aggregate.bytes)
231 .context("snapshot total size overflow")
232 })?;
233 let num_errors = aggregates.iter().fold(0u64, |total, aggregate| {
234 total.saturating_add(aggregate.errors)
235 });
236 let num_roots = aggregates.len();
237
238 if sort_by_size_in_bytes {
239 output_sorted(&mut out, aggregates, output_options)?;
240 } else {
241 for aggregate in &aggregates {
242 output_colored_path(
243 &mut out,
244 out_supports_colors,
245 &aggregate.display_path,
246 aggregate.bytes,
247 aggregate.errors,
248 aggregate.path_color(),
249 byte_format,
250 )?;
251 }
252 }
253
254 if num_roots > 1 && compute_total {
255 output_colored_path(
256 &mut out,
257 out_supports_colors,
258 Path::new("total"),
259 total,
260 num_errors,
261 None,
262 byte_format,
263 )?;
264 }
265 Ok(WalkResult { num_errors })
266}
267
268fn aggregate_inner(
269 out: (impl io::Write, bool),
270 err: Option<impl io::Write>,
271 walk_options: WalkOptions,
272 compute_total: bool,
273 sort_by_size_in_bytes: bool,
274 byte_format: ByteFormat,
275 inputs: impl ExactSizeIterator<Item = (PathBuf, PathBuf, Option<crate::walk::Entry>)>,
276) -> Result<(WalkResult, Statistics)> {
277 let (mut out, out_supports_colors) = out;
278 let output_options = (byte_format, out_supports_colors);
279 #[cfg(target_os = "macos")]
280 let apfs_clone_accounting = walk_options.metadata_options.apfs_clone_metadata;
281 let mut res = WalkResult::default();
282 let mut stats = Statistics::default();
283 let mut smallest_file_in_bytes = None;
284 let num_roots = inputs.len();
285 let mut aggregates = Vec::with_capacity(num_roots);
286 let mut device_ids = vec![0; num_roots];
287 let mut completed = vec![false; num_roots];
288 let mut roots = Vec::with_capacity(num_roots);
289 let has_ignore_patterns = walk_options.ignore_patterns.is_some();
290 for (root_idx, (path, display_path, prepared_entry)) in inputs.enumerate() {
291 #[cfg(not(any(windows, target_os = "macos")))]
292 let _ = prepared_entry;
293
294 aggregates.push(Aggregate {
295 display_path,
296 bytes: 0,
297 errors: 0,
298 is_file: false,
299 });
300 let device_id = if walk_options.cross_filesystems {
301 0
302 } else {
303 #[cfg(target_os = "macos")]
304 let root_device_id = prepared_entry
305 .as_ref()
306 .and_then(|entry| entry.metadata.as_ref().ok())
307 .map_or_else(|| crossdev::init(&path), |metadata| Ok(metadata.dev()));
308 #[cfg(not(target_os = "macos"))]
309 let root_device_id = crossdev::init(&path);
310
311 let Ok(device_id) = root_device_id else {
312 aggregates[root_idx].errors += 1;
313 completed[root_idx] = true;
314 continue;
315 };
316 device_id
317 };
318 device_ids[root_idx] = device_id;
319 roots.push(WalkRoot {
320 index: root_idx,
321 pattern_root: has_ignore_patterns.then(|| path.clone()),
322 path,
323 #[cfg(any(windows, target_os = "macos"))]
324 entry: prepared_entry,
325 device_id,
326 });
327 }
328 let mut inodes = InodeFilter::default();
329 let mut progress = TraversalProgress::new(err);
330 let mut next_output = 0;
331
332 for (root_idx, event) in
334 walk_options.iter_from_paths(roots, false, crate::walk::Order::Completion)
335 {
336 let entry = match event {
337 crate::walk::RootEvent::Entry(entry) => entry,
338 crate::walk::RootEvent::Finished => {
339 completed[root_idx] = true;
340 if !sort_by_size_in_bytes {
341 output_completed(
342 &mut out,
343 &aggregates,
344 &completed,
345 &mut next_output,
346 &mut progress,
347 output_options,
348 )?;
349 }
350 continue;
351 }
352 };
353 let aggregate = &mut aggregates[root_idx];
354 stats.entries_traversed += 1;
355 progress.update(stats.entries_traversed);
356 match entry {
357 Ok(entry) => {
358 if entry.depth == 0 {
359 aggregate.is_file = entry.file_type.is_file()
360 || entry.file_type.is_symlink() && entry.path().is_file();
361 }
362 let file_size = u128::from(match &entry.metadata {
363 Ok(m)
364 if (walk_options.count_hard_links || inodes.add(&entry, m))
365 && (walk_options.cross_filesystems
366 || crossdev::is_same_device(device_ids[root_idx], m)) =>
367 {
368 if walk_options.apparent_size {
369 m.len()
370 } else {
371 #[cfg(target_os = "macos")]
372 if apfs_clone_accounting {
373 inodes.allocated_size(m)
374 } else {
375 m.allocated_size()
376 }
377 #[cfg(not(target_os = "macos"))]
378 {
379 size_on_disk(&entry, m).unwrap_or_else(|_| {
380 aggregate.errors += 1;
381 0
382 })
383 }
384 }
385 }
386 Ok(_) => 0,
387 Err(_) => {
388 aggregate.errors += 1;
389 0
390 }
391 });
392 stats.largest_file_in_bytes = stats.largest_file_in_bytes.max(file_size);
393 smallest_file_in_bytes = smallest_file_in_bytes
394 .map_or(file_size, |size: u128| size.min(file_size))
395 .into();
396 aggregate.bytes += file_size;
397 }
398 Err(_) => aggregate.errors += 1,
399 }
400 }
401
402 let total = aggregates.iter().map(|aggregate| aggregate.bytes).sum();
403 res.num_errors = aggregates.iter().map(|aggregate| aggregate.errors).sum();
404
405 stats.smallest_file_in_bytes = smallest_file_in_bytes.unwrap_or_default();
406
407 progress.clear();
408
409 if sort_by_size_in_bytes {
410 output_sorted(&mut out, aggregates, output_options)?;
411 } else {
412 output_completed(
415 &mut out,
416 &aggregates,
417 &completed,
418 &mut next_output,
419 &mut progress,
420 output_options,
421 )?;
422 debug_assert_eq!(next_output, num_roots);
423 }
424
425 if num_roots > 1 && compute_total {
426 output_colored_path(
427 &mut out,
428 out_supports_colors,
429 Path::new("total"),
430 total,
431 res.num_errors,
432 None,
433 byte_format,
434 )?;
435 }
436 Ok((res, stats))
437}
438
439fn output_completed<W: io::Write, E: io::Write>(
442 out: &mut W,
443 aggregates: &[Aggregate],
444 completed: &[bool],
445 next_output: &mut usize,
446 progress: &mut TraversalProgress<E>,
447 (byte_format, out_supports_colors): (ByteFormat, bool),
448) -> io::Result<()> {
449 let must_report_completed_path = completed.get(*next_output).copied() == Some(true);
450 if must_report_completed_path {
452 progress.clear();
453 }
454 while completed.get(*next_output).copied() == Some(true) {
455 let aggregate = &aggregates[*next_output];
456 output_colored_path(
457 out,
458 out_supports_colors,
459 &aggregate.display_path,
460 aggregate.bytes,
461 aggregate.errors,
462 aggregate.path_color(),
463 byte_format,
464 )?;
465 *next_output += 1;
466 }
467 Ok(())
468}
469
470fn output_sorted(
471 out: &mut impl io::Write,
472 mut aggregates: Vec<Aggregate>,
473 (byte_format, out_supports_colors): (ByteFormat, bool),
474) -> std::result::Result<(), io::Error> {
475 aggregates.sort_by_key(|aggregate| aggregate.bytes);
476 for aggregate in aggregates {
477 output_colored_path(
478 out,
479 out_supports_colors,
480 &aggregate.display_path,
481 aggregate.bytes,
482 aggregate.errors,
483 aggregate.path_color(),
484 byte_format,
485 )?;
486 }
487 Ok(())
488}
489
490pub(crate) fn output_colored_path(
491 out: &mut impl io::Write,
492 out_supports_colors: bool,
493 path: impl AsRef<Path>,
494 num_bytes: u128,
495 num_errors: u64,
496 path_color: Option<Color>,
497 byte_format: ByteFormat,
498) -> std::result::Result<(), io::Error> {
499 let size = byte_format.display(num_bytes).to_string();
500 let size_width = byte_format.width();
501 let path = path.as_ref();
502
503 let errors = if num_errors != 0 {
504 format!(
505 " <{num_errors} IO Error{plural_s}>",
506 plural_s = if num_errors > 1 { "s" } else { "" }
507 )
508 } else {
509 String::new()
510 };
511
512 if !out_supports_colors {
513 return writeln!(out, "{size:>size_width$} {}{errors}", path.display());
514 }
515
516 let path = path
517 .to_string_lossy()
518 .chars()
519 .map(|character| {
520 if character.is_control() {
521 '\u{FFFD}'
522 } else {
523 character
524 }
525 })
526 .collect::<String>();
527 let size = size.green();
528 if let Some(color) = path_color {
529 writeln!(out, "{size:>size_width$} {}{errors}", path.color(color))
530 } else {
531 writeln!(out, "{size:>size_width$} {path}{errors}")
532 }
533}
534
535#[derive(Default, Debug)]
537pub struct Statistics {
538 pub entries_traversed: u64,
540 pub smallest_file_in_bytes: u128,
542 pub largest_file_in_bytes: u128,
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::traverse::{EntryData, Traversal};
550 use bstr::ByteSlice;
551
552 fn byte_counts(out: &[u8]) -> Vec<u128> {
553 let out = std::str::from_utf8(out).unwrap();
554 out.match_indices(" b")
555 .map(|(unit, _)| {
556 out[..unit]
557 .chars()
558 .rev()
559 .take_while(char::is_ascii_digit)
560 .collect::<String>()
561 .chars()
562 .rev()
563 .collect::<String>()
564 .parse()
565 .unwrap()
566 })
567 .collect()
568 }
569
570 #[test]
571 fn traversal_progress_writes_and_clears_one_line() {
572 let mut progress = TraversalProgress::new(Some(Vec::new()));
573 progress.write(42);
574 progress.clear();
575
576 assert_eq!(
577 progress.writer.as_deref(),
578 Some(b"Enumerating 42 items\r\x1b[2K\r".as_slice())
579 );
580 assert!(!progress.visible);
581 }
582
583 #[test]
584 fn snapshot_aggregate_uses_stored_roots_and_errors() {
585 let mut traversal = Traversal::new();
586 let large = traversal.tree.add_child(
587 traversal.root_index,
588 "not-on-disk-large",
589 EntryData {
590 size: 9,
591 is_dir: true,
592 ..EntryData::default()
593 },
594 );
595 traversal.tree.add_child(
596 large,
597 "missing",
598 EntryData {
599 metadata_io_error: true,
600 ..EntryData::default()
601 },
602 );
603 let small = traversal.tree.add_child(
604 traversal.root_index,
605 "not-on-disk-small",
606 EntryData {
607 size: 2,
608 ..EntryData::default()
609 },
610 );
611 let snapshot = Snapshot {
612 traversal,
613 roots: vec![large, small],
614 };
615
616 let mut out = Vec::new();
617 let result =
618 aggregate_snapshot((&mut out, false), &snapshot, true, false, ByteFormat::Bytes)
619 .unwrap();
620 insta::assert_snapshot!(out.as_bstr(), "stored root order, total and IO errors", @r"
621 9 b not-on-disk-large <1 IO Error>
622 2 b not-on-disk-small
623 11 b total <1 IO Error>
624 ");
625 assert_eq!(result.num_errors, 1);
626
627 let mut bytes = Vec::new();
628 crate::snapshot::write(&mut bytes, &snapshot.traversal, &snapshot.roots, None).unwrap();
629 let mut replay = Replay::new(std::io::Cursor::new(bytes)).unwrap();
630 let mut replayed = Vec::new();
631 let replayed_result = aggregate_replay(
632 (&mut replayed, false),
633 &mut replay,
634 true,
635 false,
636 ByteFormat::Bytes,
637 )
638 .unwrap();
639 assert_eq!(replayed, out);
640 assert_eq!(replayed_result.num_errors, result.num_errors);
641
642 let mut sorted = Vec::new();
643 aggregate_snapshot(
644 (&mut sorted, false),
645 &snapshot,
646 false,
647 true,
648 ByteFormat::Bytes,
649 )
650 .unwrap();
651 insta::assert_snapshot!(sorted.as_bstr(), "stored roots sorted by size without total", @r"
652 2 b not-on-disk-small
653 9 b not-on-disk-large <1 IO Error>
654 ");
655 }
656
657 #[test]
658 fn terminal_output_sanitizes_control_characters() {
659 let mut redirected = Vec::new();
660 output_colored_path(
661 &mut redirected,
662 false,
663 "name\t\x1b[31m",
664 1,
665 0,
666 None,
667 ByteFormat::Bytes,
668 )
669 .unwrap();
670 assert!(redirected.ends_with(b"name\t\x1b[31m\n"));
671
672 let mut terminal = Vec::new();
673 output_colored_path(
674 &mut terminal,
675 true,
676 "name\t\x1b[31m",
677 1,
678 0,
679 None,
680 ByteFormat::Bytes,
681 )
682 .unwrap();
683 let terminal = String::from_utf8(terminal).unwrap();
684 assert!(terminal.contains("name\u{FFFD}\u{FFFD}[31m"));
685 }
686
687 #[cfg(any(windows, target_os = "macos"))]
688 #[test]
689 fn file_as_root_keeps_cached_metadata_after_removal() {
690 let directory = tempfile::tempdir().unwrap();
691 let path = directory.path().join("prepared-file");
692
693 for sort_by_size_in_bytes in [false, true] {
694 std::fs::write(&path, b"cached metadata").unwrap();
695 let expected_size = std::fs::metadata(&path).unwrap().len();
696 let entry = dua_core::read_dir(directory.path(), dua_core::Options::default())
697 .unwrap()
698 .next()
699 .unwrap()
700 .unwrap();
701 std::fs::remove_file(&path).unwrap();
702
703 let mut out = Vec::new();
704 let (result, statistics) = aggregate_entries(
705 (&mut out, false),
706 None::<Vec<u8>>,
707 WalkOptions {
708 threads: 1,
709 count_hard_links: false,
710 apparent_size: true,
711 cross_filesystems: false,
712 ignore_dirs: std::collections::BTreeSet::default(),
713 ignore_patterns: None,
714 metadata_options: crate::TraversalOptions::default(),
715 },
716 false,
717 sort_by_size_in_bytes,
718 ByteFormat::Bytes,
719 vec![entry],
720 )
721 .unwrap();
722
723 assert_eq!(result.num_errors, 0);
724 assert_eq!(statistics.entries_traversed, 1);
725 assert_eq!(byte_counts(&out), [u128::from(expected_size)]);
726 let out = String::from_utf8(out).unwrap();
727 assert!(
728 out.contains(&format!(" {}\n", path.display())),
729 "the bulk reader must preserve the cached file type after removal; querying the \
730 path again would fail, leave `is_file` false, and incorrectly add cyan directory \
731 coloring: {out:?}"
732 );
733 }
734 }
735
736 #[cfg(target_os = "macos")]
737 #[test]
738 fn overlapping_directory_roots_preserve_stat_link_count_cycles() {
739 use std::os::unix::fs::MetadataExt;
740
741 const OVERLAPPING_VISITS: u64 = 5;
742
743 let directory = tempfile::tempdir().unwrap();
744 let parent = directory.path().join("parent");
745 let child = parent.join("child");
746 let grandchild = child.join("grandchild");
747 std::fs::create_dir_all(&grandchild).unwrap();
748 let file = grandchild.join("file");
749 std::fs::write(&file, b"repeated directory contents").unwrap();
750
751 let parent_metadata = std::fs::symlink_metadata(&parent).unwrap();
752 let child_metadata = std::fs::symlink_metadata(&child).unwrap();
753 let grandchild_metadata = std::fs::symlink_metadata(&grandchild).unwrap();
754 let file_metadata = std::fs::symlink_metadata(&file).unwrap();
755 assert!(
756 child_metadata.nlink() > 1,
757 "expected multiple links for {child:?}, got {}",
758 child_metadata.nlink()
759 );
760 assert!(
761 grandchild_metadata.nlink() > 1,
762 "expected multiple links for {grandchild:?}, got {}",
763 grandchild_metadata.nlink()
764 );
765
766 let roots = vec![parent, child.clone(), child.clone(), child.clone(), child];
767
768 for count_hard_links in [false, true] {
769 let directory_visits = |metadata: &std::fs::Metadata| {
770 if count_hard_links || metadata.nlink() <= 1 {
771 OVERLAPPING_VISITS
772 } else {
773 OVERLAPPING_VISITS.div_ceil(metadata.nlink())
774 }
775 };
776 let expected = u128::from(parent_metadata.len())
777 + u128::from(directory_visits(&child_metadata) * child_metadata.len())
778 + u128::from(directory_visits(&grandchild_metadata) * grandchild_metadata.len())
779 + u128::from(OVERLAPPING_VISITS * file_metadata.len());
780
781 let mut out = Vec::new();
782 let result = aggregate(
783 (&mut out, false),
784 None::<Vec<u8>>,
785 WalkOptions {
786 threads: 1,
787 count_hard_links,
788 apparent_size: true,
789 cross_filesystems: true,
790 ignore_dirs: std::collections::BTreeSet::default(),
791 ignore_patterns: None,
792 metadata_options: crate::TraversalOptions::default(),
793 },
794 true,
795 false,
796 ByteFormat::Bytes,
797 roots.clone(),
798 )
799 .unwrap();
800
801 assert_eq!(result.0.num_errors, 0);
802 assert_eq!(
803 byte_counts(&out).last().copied(),
804 Some(expected),
805 "overlapping directory totals with count_hard_links={count_hard_links}"
806 );
807 }
808 }
809
810 #[cfg(target_os = "macos")]
811 #[test]
812 fn full_apfs_clones_preserve_private_forks_hard_links_and_logical_sizes() {
813 use std::io::Write as _;
814 use std::os::unix::fs::MetadataExt;
815
816 const STAT_BLOCK_BYTES: u128 = 512;
817 const RESOURCE_FORK_BYTES: usize = 4096;
818 const DATA_FORK_BYTES: usize = RESOURCE_FORK_BYTES * 2;
819
820 let directory = tempfile::tempdir().unwrap();
821 let original = directory.path().join("original");
822 let clone = directory.path().join("clone");
823 let partial_clone = directory.path().join("partial-clone");
824 let hard_link = directory.path().join("hard-link");
825 std::fs::write(&original, vec![7; DATA_FORK_BYTES]).unwrap();
826 std::fs::copy(&original, &clone).unwrap();
830 std::fs::copy(&original, &partial_clone).unwrap();
831 std::fs::write(clone.join("..namedfork/rsrc"), vec![5; RESOURCE_FORK_BYTES]).unwrap();
832 std::fs::OpenOptions::new()
833 .write(true)
834 .open(&partial_clone)
835 .unwrap()
836 .write_all(&[9])
837 .unwrap();
838 std::fs::hard_link(&original, &hard_link).unwrap();
839
840 let original_metadata = std::fs::metadata(&original).unwrap();
841 let clone_metadata = std::fs::metadata(&clone).unwrap();
842 let partial_metadata = std::fs::metadata(&partial_clone).unwrap();
843 let directory_metadata = std::fs::metadata(directory.path()).unwrap();
844 let allocated_size = u128::from(original_metadata.blocks()) * STAT_BLOCK_BYTES;
845 let clone_allocated_size = u128::from(clone_metadata.blocks()) * STAT_BLOCK_BYTES;
846 let partial_allocated_size = u128::from(partial_metadata.blocks()) * STAT_BLOCK_BYTES;
847 let directory_allocated_size = u128::from(directory_metadata.blocks()) * STAT_BLOCK_BYTES;
848 let clone_private_size = clone_allocated_size - allocated_size;
849 let apparent_size = u128::from(original_metadata.len());
850 let directory_apparent_size = u128::from(directory_metadata.len());
851 assert_ne!(
852 clone_private_size, 0,
853 "the cloned fixture must own separately allocated resource-fork blocks"
854 );
855
856 let directory_root = || vec![directory.path().to_owned()];
857 for (case, roots, apparent_size_requested, count_hard_links, expected_total) in [
858 (
859 "full and partial clones retain private forks",
860 directory_root(),
861 false,
862 false,
863 directory_allocated_size
864 + allocated_size
865 + partial_allocated_size
866 + clone_private_size,
867 ),
868 (
869 "explicit hard links remain counted",
870 directory_root(),
871 false,
872 true,
873 directory_allocated_size
874 + allocated_size * 2
875 + partial_allocated_size
876 + clone_private_size,
877 ),
878 (
879 "logical sizes remain independent",
880 directory_root(),
881 true,
882 false,
883 directory_apparent_size + apparent_size * 3,
884 ),
885 (
886 "logical sizes count requested hard links",
887 directory_root(),
888 true,
889 true,
890 directory_apparent_size + apparent_size * 4,
891 ),
892 (
893 "clone-first roots preserve explicit hard links",
894 vec![clone.clone(), original.clone(), hard_link],
895 false,
896 true,
897 allocated_size * 2 + clone_private_size,
898 ),
899 (
900 "repeated cloned roots are not distinct clone inodes",
901 vec![clone.clone(), clone, original],
902 false,
903 false,
904 clone_allocated_size * 2,
905 ),
906 ] {
907 let mut output = Vec::new();
908 let (result, _) = aggregate(
909 (&mut output, false),
910 None::<Vec<u8>>,
911 WalkOptions {
912 threads: 2,
913 count_hard_links,
914 apparent_size: apparent_size_requested,
915 cross_filesystems: true,
916 ignore_dirs: std::collections::BTreeSet::default(),
917 ignore_patterns: None,
918 metadata_options: crate::TraversalOptions {
919 apfs_clone_metadata: true,
920 },
921 },
922 true,
923 true,
924 ByteFormat::Bytes,
925 roots,
926 )
927 .unwrap();
928
929 assert_eq!(result.num_errors, 0, "unexpected traversal errors: {case}");
930 assert_eq!(
931 byte_counts(&output).last().copied(),
932 Some(expected_total),
933 "incorrect aggregate for {case}"
934 );
935 }
936
937 let entries = dua_core::read_dir(
938 directory.path(),
939 dua_core::Options {
940 apfs_clone_metadata: true,
941 },
942 )
943 .unwrap()
944 .collect::<std::io::Result<Vec<_>>>()
945 .unwrap();
946 let mut output = Vec::new();
947 let (result, _) = aggregate_entries(
948 (&mut output, false),
949 None::<Vec<u8>>,
950 WalkOptions {
951 threads: 2,
952 count_hard_links: false,
953 apparent_size: false,
954 cross_filesystems: false,
955 ignore_dirs: std::collections::BTreeSet::default(),
956 ignore_patterns: None,
957 metadata_options: crate::TraversalOptions {
958 apfs_clone_metadata: true,
959 },
960 },
961 true,
962 true,
963 ByteFormat::Bytes,
964 entries,
965 )
966 .unwrap();
967
968 assert_eq!(
969 result.num_errors, 0,
970 "prepared sibling roots should retain their cached filesystem identities"
971 );
972 assert_eq!(
973 byte_counts(&output).last().copied(),
974 Some(allocated_size + partial_allocated_size + clone_private_size),
975 "prepared sibling roots should deduplicate cloned data, retain private forks, \
976 and omit their parent directory"
977 );
978 }
979
980 #[test]
981 fn completed_roots_stream_in_input_order() {
982 let aggregates = [
983 Aggregate {
984 display_path: "first".into(),
985 bytes: 1,
986 errors: 0,
987 is_file: false,
988 },
989 Aggregate {
990 display_path: "second".into(),
991 bytes: 2,
992 errors: 0,
993 is_file: false,
994 },
995 ];
996 let mut completed = [false, true];
997 let mut next_output = 0;
998 let mut progress = TraversalProgress::new(Some(Vec::new()));
999 progress.visible = true;
1000 let mut out = Vec::new();
1001
1002 output_completed(
1003 &mut out,
1004 &aggregates,
1005 &completed,
1006 &mut next_output,
1007 &mut progress,
1008 (ByteFormat::Bytes, false),
1009 )
1010 .unwrap();
1011 assert!(
1012 out.is_empty(),
1013 "later roots must not overtake earlier roots"
1014 );
1015
1016 completed[0] = true;
1017 output_completed(
1018 &mut out,
1019 &aggregates,
1020 &completed,
1021 &mut next_output,
1022 &mut progress,
1023 (ByteFormat::Bytes, false),
1024 )
1025 .unwrap();
1026
1027 insta::assert_snapshot!(out.as_bstr(), "completed roots released in input order", @r"
1028 1 b first
1029 2 b second
1030 ");
1031 assert_eq!(next_output, 2, "output stopped at root {next_output}");
1032 assert_eq!(
1033 progress.writer.as_deref(),
1034 Some(CLEAR_CURRENT_LINE.as_bytes()),
1035 "unexpected progress cleanup"
1036 );
1037 assert!(!progress.visible, "progress remained visible after cleanup");
1038 }
1039
1040 #[test]
1041 fn fast_roots_do_not_emit_terminal_erases() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let paths = [dir.path().join("a"), dir.path().join("b")];
1044 for path in &paths {
1045 std::fs::write(path, []).unwrap();
1046 }
1047 let mut out = Vec::new();
1048 let mut err = Vec::new();
1049
1050 aggregate(
1051 (&mut out, false),
1052 Some(&mut err),
1053 WalkOptions {
1054 threads: 2,
1055 count_hard_links: true,
1056 apparent_size: false,
1057 cross_filesystems: true,
1058 ignore_dirs: std::collections::BTreeSet::default(),
1059 ignore_patterns: None,
1060 metadata_options: crate::TraversalOptions::default(),
1061 },
1062 true,
1063 true,
1064 ByteFormat::Metric,
1065 paths.into(),
1066 )
1067 .unwrap();
1068
1069 assert!(
1070 err.is_empty(),
1071 "fast roots should not clear unseen progress"
1072 );
1073 }
1074
1075 #[cfg(unix)]
1076 #[test]
1077 fn root_device_error_is_reported() {
1078 use std::os::unix::fs::symlink;
1079
1080 let dir = tempfile::tempdir().unwrap();
1081 let root = dir.path().join("dangling");
1082 symlink(dir.path().join("missing"), &root).unwrap();
1083
1084 let (result, _) = aggregate(
1085 (Vec::new(), false),
1086 None::<Vec<u8>>,
1087 WalkOptions {
1088 threads: 1,
1089 count_hard_links: true,
1090 apparent_size: true,
1091 cross_filesystems: false,
1092 ignore_dirs: std::collections::BTreeSet::default(),
1093 ignore_patterns: None,
1094 metadata_options: crate::TraversalOptions::default(),
1095 },
1096 false,
1097 true,
1098 ByteFormat::Bytes,
1099 vec![root],
1100 )
1101 .unwrap();
1102
1103 assert_eq!(result.num_errors, 1);
1104 }
1105
1106 #[test]
1107 fn ignored_patterns_are_left_out_of_the_reported_size() {
1108 let dir = tempfile::tempdir().unwrap();
1109 std::fs::create_dir(dir.path().join("cache")).unwrap();
1110 std::fs::write(dir.path().join("kept"), [0; 64]).unwrap();
1111 std::fs::write(dir.path().join("cache/blob"), [0; 4096]).unwrap();
1112
1113 let patterns_dir = tempfile::tempdir().unwrap();
1115 let ignore_cache = patterns_dir.path().join("cache-only");
1116 let ignore_both = patterns_dir.path().join("cache-and-kept");
1117 std::fs::write(&ignore_cache, "cache/\n").unwrap();
1118 std::fs::write(&ignore_both, "cache/\nkept\n").unwrap();
1119
1120 let aggregate_with = |ignore_from: &[PathBuf]| -> u128 {
1121 let mut out = Vec::new();
1122 aggregate(
1123 (&mut out, false),
1124 None::<&mut Vec<u8>>,
1125 WalkOptions {
1126 threads: 2,
1127 count_hard_links: true,
1128 apparent_size: true,
1129 cross_filesystems: true,
1130 ignore_dirs: std::collections::BTreeSet::default(),
1131 ignore_patterns: crate::IgnorePatterns::from_files(ignore_from).unwrap(),
1132 metadata_options: crate::TraversalOptions::default(),
1133 },
1134 false,
1135 true,
1136 ByteFormat::Bytes,
1137 vec![dir.path().to_owned()],
1138 )
1139 .unwrap();
1140 byte_counts(&out)
1141 .into_iter()
1142 .next()
1143 .unwrap_or_else(|| panic!("expected a byte count in {out:?}"))
1144 };
1145
1146 let full = aggregate_with(&[]);
1149 let without_cache = aggregate_with(&[ignore_cache]);
1150 let without_either = aggregate_with(&[ignore_both]);
1151
1152 assert!(
1153 full >= 4096 + 64,
1154 "without patterns both files are counted, got {full}"
1155 );
1156 assert!(
1157 full - without_cache >= 4096,
1158 "excluding `cache/` drops at least the 4096-byte file inside it, \
1159 but only {} bytes disappeared",
1160 full - without_cache
1161 );
1162 assert_eq!(
1163 without_cache - without_either,
1164 64,
1165 "the 64-byte file is still counted until a pattern matches it too"
1166 );
1167 }
1168 #[cfg(windows)]
1169 #[test]
1170 fn windows_disk_size_survives_removing_the_entry_path() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let path = dir.path().join("file");
1173 std::fs::write(&path, b"content").unwrap();
1174 let entry =
1175 crate::walk::Entry::from_path(&path, crate::TraversalOptions::default()).unwrap();
1176 let metadata = entry.metadata.as_ref().unwrap();
1177 let expected = metadata.allocated_size();
1178 std::fs::remove_file(path).unwrap();
1179 assert_eq!(
1180 size_on_disk(&entry, metadata).unwrap(),
1181 expected,
1182 "Windows aggregation should use the already-enumerated allocation size"
1183 );
1184 }
1185
1186 #[cfg(windows)]
1187 #[test]
1188 fn windows_disk_size_preserves_zero_sized_directories() {
1189 let dir = tempfile::tempdir().unwrap();
1190 std::fs::write(dir.path().join("file"), b"content").unwrap();
1191 let entry =
1192 crate::walk::Entry::from_path(dir.path(), crate::TraversalOptions::default()).unwrap();
1193 let metadata = entry.metadata.as_ref().unwrap();
1194 assert_eq!(size_on_disk(&entry, metadata).unwrap(), 0);
1195 }
1196}