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