1use crate::{Throttle, WalkOptions, WalkRoot, crossdev, inodefilter::InodeFilter};
2
3use crossbeam::channel::Receiver;
4use filesize::PathExt;
5use petgraph::{Directed, Direction, graph::NodeIndex, stable_graph::StableGraph};
6use std::time::Instant;
7use std::{
8 collections::HashMap,
9 fmt,
10 fs::Metadata,
11 io,
12 path::{Path, PathBuf},
13 sync::Arc,
14 time::{Duration, SystemTime, UNIX_EPOCH},
15};
16
17pub type TreeIndex = NodeIndex;
19pub type Tree = StableGraph<EntryData, (), Directed>;
21
22#[derive(Eq, PartialEq, Clone)]
24pub struct EntryData {
25 pub name: PathBuf,
27 pub size: u128,
30 pub mtime: SystemTime,
32 pub entry_count: Option<u64>,
34 pub metadata_io_error: bool,
36 pub is_dir: bool,
38}
39
40impl Default for EntryData {
41 fn default() -> EntryData {
42 EntryData {
43 name: PathBuf::default(),
44 size: u128::default(),
45 mtime: UNIX_EPOCH,
46 entry_count: None,
47 metadata_io_error: bool::default(),
48 is_dir: false,
49 }
50 }
51}
52
53impl fmt::Debug for EntryData {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("EntryData")
56 .field("name", &self.name)
57 .field("size", &self.size)
58 .field("entry_count", &self.entry_count)
59 .field("metadata_io_error", &self.metadata_io_error)
61 .finish()
62 }
63}
64
65#[derive(Debug)]
67pub struct Traversal {
68 pub tree: Tree,
70 pub root_index: TreeIndex,
72 pub start_time: Instant,
74 pub cost: Option<Duration>,
76}
77
78impl Default for Traversal {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl Traversal {
85 #[must_use]
87 pub fn new() -> Self {
88 let mut tree = Tree::new();
89 let root_index = tree.add_node(EntryData::default());
90 Self {
91 tree,
92 root_index,
93 start_time: Instant::now(),
94 cost: None,
95 }
96 }
97
98 #[must_use]
100 pub fn is_costly(&self) -> bool {
101 self.cost.is_none_or(|d| d.as_secs_f32() > 10.0)
102 }
103}
104
105#[derive(Clone, Copy)]
107pub struct TraversalStats {
108 pub entries_traversed: u64,
110 pub start: std::time::Instant,
112 pub elapsed: Option<std::time::Duration>,
114 pub io_errors: u64,
116 pub total_bytes: Option<u128>,
118}
119
120impl Default for TraversalStats {
121 fn default() -> Self {
122 Self {
123 entries_traversed: 0,
124 start: std::time::Instant::now(),
125 elapsed: None,
126 io_errors: 0,
127 total_bytes: None,
128 }
129 }
130}
131
132pub struct TraversalEntry(crate::walk::Entry);
134
135pub enum TraversalEvent {
137 Entry(io::Result<TraversalEntry>, Arc<PathBuf>, u64),
139 Finished,
141}
142
143pub struct BackgroundTraversal {
145 walk_options: WalkOptions,
146 pub root_idx: TreeIndex,
148 pub stats: TraversalStats,
150 nodes_by_path: HashMap<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 skip_root: bool,
167 use_root_path: bool,
168 ) -> anyhow::Result<BackgroundTraversal> {
169 let (entry_tx, entry_rx) = crossbeam::channel::bounded(100);
170 std::thread::Builder::new()
171 .name("dua-fs-walk-dispatcher".to_string())
172 .spawn({
173 let walk_options = walk_options.clone();
174 move || {
175 let (mut root_paths, mut device_ids, mut walk_roots) = (
176 Vec::with_capacity(input.len()),
177 Vec::with_capacity(input.len()),
178 Vec::with_capacity(input.len()),
179 );
180 for (root_idx, root_path) in input.into_iter().enumerate() {
181 log::info!("Walking {}", root_path.display());
182 let device_id = if walk_options.cross_filesystems {
183 0
184 } else {
185 crossdev::init(&root_path).unwrap_or(0)
186 };
187 walk_roots.push(WalkRoot {
188 index: root_idx,
189 path: root_path.clone(),
190 device_id,
191 });
192 device_ids.push(device_id);
193 root_paths.push(Arc::new(root_path));
194 }
195
196 for (root, event) in walk_options.iter_from_paths(
197 walk_roots,
198 skip_root,
199 crate::walk::Order::ParentFirst,
200 ) {
201 let crate::walk::RootEvent::Entry(entry) = event else {
202 continue;
203 };
204 if entry_tx
205 .send(TraversalEvent::Entry(
206 entry.map(TraversalEntry),
207 Arc::clone(&root_paths[root]),
208 device_ids[root],
209 ))
210 .is_err()
211 {
212 return;
215 }
216 }
217 if entry_tx.send(TraversalEvent::Finished).is_err() {
218 log::error!("Failed to send TraversalEvents::Finished event");
219 }
220 }
221 })?;
222
223 Ok(Self {
224 walk_options: walk_options.clone(),
225 root_idx,
226 stats: TraversalStats::default(),
227 nodes_by_path: HashMap::new(),
228 inodes: InodeFilter::default(),
229 throttle: Some(Throttle::new(Duration::from_millis(250), None)),
230 skip_root,
231 use_root_path,
232 event_rx: entry_rx,
233 })
234 }
235
236 #[expect(
249 clippy::too_many_lines,
250 reason = "event integration keeps tree updates atomic"
251 )]
252 pub fn integrate_traversal_event(
253 &mut self,
254 traversal: &mut Traversal,
255 event: TraversalEvent,
256 ) -> Option<bool> {
257 match event {
258 TraversalEvent::Entry(entry, root_path, device_id) => {
259 self.stats.entries_traversed += 1;
260 let mut data = EntryData::default();
261 match entry {
262 Ok(TraversalEntry(entry)) => {
263 let walk_depth = entry.depth;
264 if self.skip_root {
265 data.name = entry.file_name.clone().into();
266 } else {
267 data.name = if walk_depth < 1 && self.use_root_path {
268 (*root_path).clone()
269 } else {
270 entry.file_name.clone().into()
271 }
272 }
273
274 let mut file_size = 0u128;
275 let mut mtime: SystemTime = UNIX_EPOCH;
276 data.is_dir = entry.file_type.is_dir();
277 if let Ok(m) = &entry.metadata {
278 if self.walk_options.count_hard_links
279 || self.inodes.add(m)
280 && (self.walk_options.cross_filesystems
281 || crossdev::is_same_device(device_id, m))
282 {
283 if self.walk_options.apparent_size {
284 file_size = u128::from(m.len());
285 } else {
286 file_size = u128::from(
287 size_on_disk(&entry.parent_path, &data.name, m)
288 .unwrap_or_else(|_| {
289 self.stats.io_errors += 1;
290 data.metadata_io_error = true;
291 0
292 }),
293 );
294 }
295 } else {
296 data.entry_count = Some(0);
297 }
298
299 if let Ok(modified) = m.modified() {
300 mtime = modified;
301 } else {
302 self.stats.io_errors += 1;
303 data.metadata_io_error = true;
304 }
305 } else {
306 self.stats.io_errors += 1;
307 data.metadata_io_error = true;
308 }
309
310 data.mtime = mtime;
311 data.size = file_size;
312 if data.is_dir {
313 data.entry_count = Some(1);
314 }
315 let entry_count = u64::from(data.is_dir || data.entry_count != Some(0));
316
317 let parent_index = if walk_depth == 0 {
318 self.root_idx
319 } else {
320 if self.skip_root {
321 self.nodes_by_path
322 .entry((*root_path).clone())
323 .or_insert(self.root_idx);
324 }
325 *self
326 .nodes_by_path
327 .get(entry.parent_path.as_ref())
328 .expect("parent entries are emitted before their children")
329 };
330 let entry_index = traversal.tree.add_node(data);
331 traversal.tree.add_edge(parent_index, entry_index, ());
332 if traversal.tree[entry_index].is_dir {
333 self.nodes_by_path.insert(entry.path(), entry_index);
334 }
335
336 let mut ancestor = Some(parent_index);
337 while let Some(index) = ancestor {
338 ancestor = traversal
339 .tree
340 .neighbors_directed(index, Direction::Incoming)
341 .next();
342 let entry = &mut traversal.tree[index];
343 entry.size += file_size;
344 *entry.entry_count.get_or_insert(0) += entry_count;
345 }
346 }
347 Err(_) => self.stats.io_errors += 1,
348 }
349
350 if self.throttle.as_ref().is_some_and(|t| t.can_update()) {
351 return Some(false);
352 }
353 }
354 TraversalEvent::Finished => {
355 self.throttle = None;
356 let root_size = traversal.tree[self.root_idx].size;
357 self.nodes_by_path = HashMap::new();
358 self.stats.total_bytes = Some(root_size);
359 self.stats.elapsed = Some(self.stats.start.elapsed());
360
361 return Some(true);
362 }
363 }
364 None
365 }
366}
367
368#[cfg(not(windows))]
369fn size_on_disk(_parent: &Path, name: &Path, meta: &Metadata) -> io::Result<u64> {
371 name.size_on_disk_fast(meta)
372}
373
374#[cfg(windows)]
375fn size_on_disk(parent: &Path, name: &Path, meta: &Metadata) -> io::Result<u64> {
377 parent.join(name).size_on_disk_fast(meta)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn ancestor_sizes_update_before_traversal_finishes() {
386 let dir = tempfile::tempdir().unwrap();
387 std::fs::create_dir(dir.path().join("nested")).unwrap();
388 std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
389
390 let mut traversal = Traversal::new();
391 let mut background = BackgroundTraversal::start(
392 traversal.root_index,
393 &WalkOptions {
394 threads: 2,
395 count_hard_links: true,
396 apparent_size: true,
397 cross_filesystems: true,
398 ignore_dirs: std::collections::BTreeSet::default(),
399 },
400 vec![dir.path().to_owned()],
401 false,
402 false,
403 )
404 .unwrap();
405
406 loop {
407 let event = background.event_rx.recv().unwrap();
408 let is_file = matches!(
409 &event,
410 TraversalEvent::Entry(Ok(TraversalEntry(entry)), _, _)
411 if entry.file_name == "file"
412 );
413 background.integrate_traversal_event(&mut traversal, event);
414 if is_file {
415 let root_size = traversal.tree[traversal.root_index].size;
416 assert!(
417 root_size >= 7,
418 "root size should include the 7-byte nested file, got {root_size}"
419 );
420 let nested_size = traversal
421 .tree
422 .node_weights()
423 .find(|entry| entry.name == Path::new("nested"))
424 .unwrap()
425 .size;
426 assert!(
427 nested_size >= 7,
428 "nested directory size should include its 7-byte file, got {nested_size}"
429 );
430 break;
431 }
432 }
433 }
434
435 #[test]
436 fn size_of_entry_data() {
437 assert!(
438 std::mem::size_of::<EntryData>() <= 80,
439 "the size of this ({}) should not exceed 80 as it affects overall memory consumption",
440 std::mem::size_of::<EntryData>()
441 );
442 }
443}