1use crate::{Throttle, WalkOptions, WalkRoot, crossdev, inodefilter::InodeFilter};
2
3use crossbeam::channel::Receiver;
4#[cfg(not(any(windows, target_os = "macos")))]
5use filesize::PathExt;
6use petgraph::{Directed, Direction, graph::NodeIndex, stable_graph::StableGraph};
7use std::time::Instant;
8use std::{
9 collections::HashMap,
10 fmt, io,
11 path::{Path, PathBuf},
12 sync::Arc,
13 time::{Duration, SystemTime, UNIX_EPOCH},
14};
15
16pub type TreeIndex = NodeIndex;
18pub type Tree = StableGraph<EntryData, (), Directed>;
20
21#[derive(Eq, PartialEq, Clone)]
23pub struct EntryData {
24 pub name: PathBuf,
26 pub size: u128,
29 pub mtime: SystemTime,
31 pub entry_count: Option<u64>,
33 pub metadata_io_error: bool,
35 pub is_dir: bool,
37}
38
39impl Default for EntryData {
40 fn default() -> EntryData {
41 EntryData {
42 name: PathBuf::default(),
43 size: u128::default(),
44 mtime: UNIX_EPOCH,
45 entry_count: None,
46 metadata_io_error: bool::default(),
47 is_dir: false,
48 }
49 }
50}
51
52impl fmt::Debug for EntryData {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.debug_struct("EntryData")
55 .field("name", &self.name)
56 .field("size", &self.size)
57 .field("entry_count", &self.entry_count)
58 .field("metadata_io_error", &self.metadata_io_error)
60 .finish()
61 }
62}
63
64#[derive(Debug)]
66pub struct Traversal {
67 pub tree: Tree,
69 pub root_index: TreeIndex,
71 pub start_time: Instant,
73 pub cost: Option<Duration>,
75}
76
77impl Default for Traversal {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl Traversal {
84 #[must_use]
86 pub fn new() -> Self {
87 let mut tree = Tree::new();
88 let root_index = tree.add_node(EntryData::default());
89 Self {
90 tree,
91 root_index,
92 start_time: Instant::now(),
93 cost: None,
94 }
95 }
96
97 #[must_use]
99 pub fn is_costly(&self) -> bool {
100 self.cost.is_none_or(|d| d.as_secs_f32() > 10.0)
101 }
102}
103
104#[derive(Clone, Copy)]
106pub struct TraversalStats {
107 pub entries_traversed: u64,
109 pub start: std::time::Instant,
111 pub elapsed: Option<std::time::Duration>,
113 pub io_errors: u64,
115 pub total_bytes: Option<u128>,
117}
118
119impl Default for TraversalStats {
120 fn default() -> Self {
121 Self {
122 entries_traversed: 0,
123 start: std::time::Instant::now(),
124 elapsed: None,
125 io_errors: 0,
126 total_bytes: None,
127 }
128 }
129}
130
131pub struct TraversalEntry(crate::walk::Entry);
133
134pub enum TraversalEvent {
136 Entry(io::Result<TraversalEntry>, Arc<PathBuf>, u64),
138 Finished(u64),
140}
141
142pub struct BackgroundTraversal {
144 walk_options: WalkOptions,
145 pub root_idx: TreeIndex,
147 pub stats: TraversalStats,
149 nodes_by_path: HashMap<(usize, PathBuf), TreeIndex>,
151 inodes: InodeFilter,
152 throttle: Option<Throttle>,
153 skip_root: bool,
154 use_root_path: bool,
155 pub event_rx: Receiver<TraversalEvent>,
157}
158
159impl BackgroundTraversal {
160 pub fn start(
163 root_idx: TreeIndex,
164 walk_options: &WalkOptions,
165 input: Vec<PathBuf>,
166 pattern_roots: Option<&[PathBuf]>,
167 skip_root: bool,
168 use_root_path: bool,
169 ) -> anyhow::Result<BackgroundTraversal> {
170 let (entry_tx, entry_rx) = crossbeam::channel::bounded(100);
171 let pattern_roots = pattern_roots.map(<[PathBuf]>::to_owned);
172 std::thread::Builder::new()
173 .name("dua-fs-walk-dispatcher".to_string())
174 .spawn({
175 let walk_options = walk_options.clone();
176 move || {
177 let mut io_errors = 0;
178 let (mut root_paths, mut device_ids, mut walk_roots) = (
179 Vec::with_capacity(input.len()),
180 Vec::with_capacity(input.len()),
181 Vec::with_capacity(input.len()),
182 );
183 for root_path in input {
184 log::info!("Walking {}", root_path.display());
185 let device_id = if walk_options.cross_filesystems {
186 0
187 } else {
188 let Ok(device_id) = crossdev::init(&root_path) else {
189 io_errors += 1;
191 continue;
192 };
193 device_id
194 };
195 let pattern_root = pattern_roots.as_deref().map(|pattern_roots| {
196 pattern_roots
197 .iter()
198 .filter(|candidate| root_path.starts_with(candidate))
199 .max_by_key(|candidate| candidate.components().count())
200 .cloned()
201 .unwrap_or_else(|| root_path.clone())
202 });
203 walk_roots.push(WalkRoot {
204 index: walk_roots.len(),
205 pattern_root,
206 path: root_path.clone(),
207 #[cfg(any(windows, target_os = "macos"))]
208 entry: None,
209 device_id,
210 });
211 device_ids.push(device_id);
212 root_paths.push(Arc::new(root_path));
213 }
214
215 for (root, event) in walk_options.iter_from_paths(
216 walk_roots,
217 skip_root,
218 crate::walk::Order::ParentFirst,
219 ) {
220 let crate::walk::RootEvent::Entry(entry) = event else {
221 continue;
222 };
223 if entry_tx
224 .send(TraversalEvent::Entry(
225 entry.map(TraversalEntry),
226 Arc::clone(&root_paths[root]),
227 device_ids[root],
228 ))
229 .is_err()
230 {
231 return;
234 }
235 }
236 if entry_tx.send(TraversalEvent::Finished(io_errors)).is_err() {
237 log::error!("Failed to send TraversalEvents::Finished event");
238 }
239 }
240 })?;
241
242 Ok(Self {
243 walk_options: walk_options.clone(),
244 root_idx,
245 stats: TraversalStats::default(),
246 nodes_by_path: HashMap::new(),
247 inodes: InodeFilter::default(),
248 throttle: Some(Throttle::new(Duration::from_millis(250), None)),
249 skip_root,
250 use_root_path,
251 event_rx: entry_rx,
252 })
253 }
254
255 #[expect(
268 clippy::too_many_lines,
269 reason = "event integration keeps tree updates atomic"
270 )]
271 pub fn integrate_traversal_event(
272 &mut self,
273 traversal: &mut Traversal,
274 event: TraversalEvent,
275 ) -> Option<bool> {
276 match event {
277 TraversalEvent::Entry(entry, root_path, device_id) => {
278 let root = Arc::as_ptr(&root_path) as usize;
279 self.stats.entries_traversed += 1;
280 let mut data = EntryData::default();
281 match entry {
282 Ok(TraversalEntry(entry)) => {
283 let walk_depth = entry.depth;
284 if self.skip_root {
285 data.name = entry.file_name.clone().into();
286 } else {
287 data.name = if walk_depth < 1 && self.use_root_path {
288 (*root_path).clone()
289 } else {
290 entry.file_name.clone().into()
291 }
292 }
293
294 let mut file_size = 0u128;
295 let mut mtime: SystemTime = UNIX_EPOCH;
296 data.is_dir = entry.file_type.is_dir();
297 if let Ok(m) = &entry.metadata {
298 if self.walk_options.count_hard_links
299 || self.inodes.add(&entry, m)
300 && (self.walk_options.cross_filesystems
301 || crossdev::is_same_device(device_id, m))
302 {
303 if self.walk_options.apparent_size {
304 file_size = u128::from(m.len());
305 } else {
306 file_size =
307 u128::from(
308 size_on_disk(
309 &entry.parent_path,
310 &data.name,
311 m,
312 data.is_dir,
313 )
314 .unwrap_or_else(|_| {
315 self.stats.io_errors += 1;
316 data.metadata_io_error = true;
317 0
318 }),
319 );
320 }
321 } else {
322 data.entry_count = Some(0);
323 }
324
325 if let Ok(modified) = m.modified() {
326 mtime = modified;
327 } else {
328 self.stats.io_errors += 1;
329 data.metadata_io_error = true;
330 }
331 } else {
332 self.stats.io_errors += 1;
333 data.metadata_io_error = true;
334 }
335
336 data.mtime = mtime;
337 data.size = file_size;
338 if data.is_dir {
339 data.entry_count = Some(1);
340 }
341 let entry_count = u64::from(data.is_dir || data.entry_count != Some(0));
342
343 let parent_index = if walk_depth == 0 {
344 self.root_idx
345 } else {
346 if self.skip_root {
347 self.nodes_by_path
348 .entry((root, (*root_path).clone()))
349 .or_insert(self.root_idx);
350 }
351 *self
352 .nodes_by_path
353 .get(&(root, entry.parent_path.to_path_buf()))
354 .expect("parent entries are emitted before their children")
355 };
356 let entry_index = traversal.tree.add_node(data);
357 traversal.tree.add_edge(parent_index, entry_index, ());
358 if traversal.tree[entry_index].is_dir {
359 self.nodes_by_path.insert((root, entry.path()), entry_index);
360 }
361
362 let mut ancestor = Some(parent_index);
363 while let Some(index) = ancestor {
364 ancestor = traversal
365 .tree
366 .neighbors_directed(index, Direction::Incoming)
367 .next();
368 let entry = &mut traversal.tree[index];
369 entry.size += file_size;
370 *entry.entry_count.get_or_insert(0) += entry_count;
371 }
372 }
373 Err(_) => self.stats.io_errors += 1,
374 }
375
376 if self.throttle.as_ref().is_some_and(|t| t.can_update()) {
377 return Some(false);
378 }
379 }
380 TraversalEvent::Finished(io_errors) => {
381 self.stats.io_errors += io_errors;
382 self.throttle = None;
383 let root_size = traversal.tree[self.root_idx].size;
384 self.nodes_by_path = HashMap::new();
385 self.stats.total_bytes = Some(root_size);
386 self.stats.elapsed = Some(self.stats.start.elapsed());
387
388 return Some(true);
389 }
390 }
391 None
392 }
393}
394
395#[cfg(not(any(windows, target_os = "macos")))]
396fn size_on_disk(
398 _parent: &Path,
399 name: &Path,
400 meta: &crate::walk::Metadata,
401 _is_dir: bool,
402) -> io::Result<u64> {
403 name.size_on_disk_fast(meta)
404}
405
406#[cfg(target_os = "macos")]
407#[allow(clippy::unnecessary_wraps)]
409fn size_on_disk(
410 _parent: &Path,
411 _name: &Path,
412 meta: &crate::walk::Metadata,
413 _is_dir: bool,
414) -> io::Result<u64> {
415 Ok(meta.allocated_size())
416}
417
418#[cfg(windows)]
419#[allow(clippy::unnecessary_wraps)]
421fn size_on_disk(
422 _parent: &Path,
423 _name: &Path,
424 meta: &crate::walk::Metadata,
425 is_dir: bool,
426) -> io::Result<u64> {
427 Ok(if is_dir { 0 } else { meta.allocated_size() })
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn ancestor_sizes_update_before_traversal_finishes() {
436 let dir = tempfile::tempdir().unwrap();
437 std::fs::create_dir(dir.path().join("nested")).unwrap();
438 std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
439
440 let mut traversal = Traversal::new();
441 let mut background = BackgroundTraversal::start(
442 traversal.root_index,
443 &WalkOptions {
444 threads: 2,
445 count_hard_links: true,
446 apparent_size: true,
447 cross_filesystems: true,
448 ignore_dirs: std::collections::BTreeSet::default(),
449 ignore_patterns: None,
450 },
451 vec![dir.path().to_owned()],
452 None,
453 false,
454 false,
455 )
456 .unwrap();
457
458 loop {
459 let event = background.event_rx.recv().unwrap();
460 let is_file = matches!(
461 &event,
462 TraversalEvent::Entry(Ok(TraversalEntry(entry)), _, _)
463 if entry.file_name == "file"
464 );
465 background.integrate_traversal_event(&mut traversal, event);
466 if is_file {
467 let root_size = traversal.tree[traversal.root_index].size;
468 assert!(
469 root_size >= 7,
470 "root size should include the 7-byte nested file, got {root_size}"
471 );
472 let nested_size = traversal
473 .tree
474 .node_weights()
475 .find(|entry| entry.name == Path::new("nested"))
476 .unwrap()
477 .size;
478 assert!(
479 nested_size >= 7,
480 "nested directory size should include its 7-byte file, got {nested_size}"
481 );
482 break;
483 }
484 }
485 }
486
487 #[test]
488 fn duplicate_roots_keep_their_own_children() {
489 let dir = tempfile::tempdir().unwrap();
490 std::fs::write(dir.path().join("file"), b"content").unwrap();
491 let mut traversal = Traversal::new();
492 let mut background = BackgroundTraversal::start(
493 traversal.root_index,
494 &WalkOptions {
495 threads: 1,
496 count_hard_links: true,
497 apparent_size: true,
498 cross_filesystems: true,
499 ignore_dirs: std::collections::BTreeSet::default(),
500 ignore_patterns: None,
501 },
502 vec![dir.path().to_owned(), dir.path().to_owned()],
503 None,
504 false,
505 false,
506 )
507 .unwrap();
508
509 while !background
510 .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
511 .unwrap_or(false)
512 {}
513
514 let roots = traversal
515 .tree
516 .neighbors_directed(traversal.root_index, Direction::Outgoing)
517 .collect::<Vec<_>>();
518 assert_eq!(roots.len(), 2);
519 for root in roots {
520 assert_eq!(
521 traversal
522 .tree
523 .neighbors_directed(root, Direction::Outgoing)
524 .count(),
525 1
526 );
527 }
528 }
529
530 #[cfg(unix)]
531 #[test]
532 fn root_device_error_is_reported() {
533 use std::os::unix::fs::symlink;
534
535 let dir = tempfile::tempdir().unwrap();
536 let root = dir.path().join("dangling");
537 let valid = dir.path().join("valid");
538 symlink(dir.path().join("missing"), &root).unwrap();
539 std::fs::write(&valid, b"content").unwrap();
540 let mut traversal = Traversal::new();
541 let mut background = BackgroundTraversal::start(
542 traversal.root_index,
543 &WalkOptions {
544 threads: 1,
545 count_hard_links: true,
546 apparent_size: true,
547 cross_filesystems: false,
548 ignore_dirs: std::collections::BTreeSet::default(),
549 ignore_patterns: None,
550 },
551 vec![root.clone(), valid.clone()],
552 None,
553 false,
554 false,
555 )
556 .unwrap();
557
558 while !background
559 .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
560 .unwrap_or(false)
561 {}
562
563 assert_eq!(background.stats.io_errors, 1);
564 }
565
566 #[test]
567 fn size_of_entry_data() {
568 assert!(
569 std::mem::size_of::<EntryData>() <= 80,
570 "the size of this ({}) should not exceed 80 as it affects overall memory consumption",
571 std::mem::size_of::<EntryData>()
572 );
573 }
574}