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