Skip to main content

dua/
traverse.rs

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
16/// Node index type used by the traversal tree graph.
17pub type TreeIndex = NodeIndex;
18/// Graph type used to represent traversed filesystem entries.
19pub type Tree = StableGraph<EntryData, (), Directed>;
20
21/// Data stored for each filesystem entry in the traversal tree.
22#[derive(Eq, PartialEq, Clone)]
23pub struct EntryData {
24    /// The entry name relative to its parent.
25    pub name: PathBuf,
26    /// The entry's size in bytes. If it's a directory, the size is the aggregated file size of all children
27    /// plus the  size of the directory entry itself
28    pub size: u128,
29    /// Last modification time if available.
30    pub mtime: SystemTime,
31    /// Recursive entry count for directories, or `None` for files.
32    pub entry_count: Option<u64>,
33    /// If set, the item meta-data could not be obtained
34    pub metadata_io_error: bool,
35    /// `true` if the entry is a directory.
36    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            // Skip mtime
59            .field("metadata_io_error", &self.metadata_io_error)
60            .finish()
61    }
62}
63
64/// The result of the previous filesystem traversal
65#[derive(Debug)]
66pub struct Traversal {
67    /// A tree representing the entire filestem traversal
68    pub tree: Tree,
69    /// The top-level node of the tree.
70    pub root_index: TreeIndex,
71    /// The time at which the instance was created, typically the start of the traversal.
72    pub start_time: Instant,
73    /// The time it cost to compute the traversal, when done.
74    pub cost: Option<Duration>,
75}
76
77impl Default for Traversal {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl Traversal {
84    /// Create a new empty traversal with a synthetic root node.
85    #[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    /// Return `true` if this traversal is considered expensive to recompute.
98    #[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/// Runtime statistics gathered while traversal is running.
105#[derive(Clone, Copy)]
106pub struct TraversalStats {
107    /// Amount of files or directories we have seen during the filesystem traversal
108    pub entries_traversed: u64,
109    /// The time at which the traversal started.
110    pub start: std::time::Instant,
111    /// The amount of time it took to finish the traversal. Set only once done.
112    pub elapsed: Option<std::time::Duration>,
113    /// Total amount of IO errors encountered when traversing the filesystem
114    pub io_errors: u64,
115    /// Total amount of bytes seen during the traversal
116    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
131/// A filesystem entry waiting to be integrated into a traversal.
132pub struct TraversalEntry(crate::walk::Entry);
133
134/// Events emitted by a background filesystem traversal.
135pub enum TraversalEvent {
136    /// A discovered entry and its traversal context.
137    Entry(io::Result<TraversalEntry>, Arc<PathBuf>, u64),
138    /// Traversal completed with the number of root-initialization I/O errors.
139    Finished(u64),
140}
141
142/// An in-progress traversal which exposes newly obtained entries
143pub struct BackgroundTraversal {
144    walk_options: WalkOptions,
145    /// Tree node index that acts as root for this traversal integration.
146    pub root_idx: TreeIndex,
147    /// Running traversal statistics.
148    pub stats: TraversalStats,
149    /// Nodes keyed by root allocation identity and path so overlapping roots build separate trees.
150    nodes_by_path: HashMap<(usize, PathBuf), TreeIndex>,
151    inodes: InodeFilter,
152    throttle: Option<Throttle>,
153    skip_root: bool,
154    use_root_path: bool,
155    /// Receiver used to obtain traversal events from the worker thread.
156    pub event_rx: Receiver<TraversalEvent>,
157}
158
159impl BackgroundTraversal {
160    /// Start a background thread to perform the actual tree walk, and dispatch the results
161    /// as events to be received on [`BackgroundTraversal::event_rx`].
162    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                                // Skip roots that can't be accessed entirely.
190                                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                            device_id,
208                        });
209                        device_ids.push(device_id);
210                        root_paths.push(Arc::new(root_path));
211                    }
212
213                    for (root, event) in walk_options.iter_from_paths(
214                        walk_roots,
215                        skip_root,
216                        crate::walk::Order::ParentFirst,
217                    ) {
218                        let crate::walk::RootEvent::Entry(entry) = event else {
219                            continue;
220                        };
221                        if entry_tx
222                            .send(TraversalEvent::Entry(
223                                entry.map(TraversalEntry),
224                                Arc::clone(&root_paths[root]),
225                                device_ids[root],
226                            ))
227                            .is_err()
228                        {
229                            // The channel is closed, this means the user has
230                            // requested to quit the app. Abort the walking.
231                            return;
232                        }
233                    }
234                    if entry_tx.send(TraversalEvent::Finished(io_errors)).is_err() {
235                        log::error!("Failed to send TraversalEvents::Finished event");
236                    }
237                }
238            })?;
239
240        Ok(Self {
241            walk_options: walk_options.clone(),
242            root_idx,
243            stats: TraversalStats::default(),
244            nodes_by_path: HashMap::new(),
245            inodes: InodeFilter::default(),
246            throttle: Some(Throttle::new(Duration::from_millis(250), None)),
247            skip_root,
248            use_root_path,
249            event_rx: entry_rx,
250        })
251    }
252
253    /// Integrate `event` into traversal `t` so its information is represented by it.
254    /// This builds the traversal tree from a directory-walk.
255    ///
256    /// Returns
257    /// * `Some(true)` if the traversal is finished
258    /// * `Some(false)` if the caller may update its state after throttling kicked in
259    /// * `None` - the event was written into the traversal, but there is nothing else to do
260    ///
261    /// # Panics
262    ///
263    /// Panics if a child entry arrives before its parent, violating the parent-first traversal
264    /// invariant.
265    #[expect(
266        clippy::too_many_lines,
267        reason = "event integration keeps tree updates atomic"
268    )]
269    pub fn integrate_traversal_event(
270        &mut self,
271        traversal: &mut Traversal,
272        event: TraversalEvent,
273    ) -> Option<bool> {
274        match event {
275            TraversalEvent::Entry(entry, root_path, device_id) => {
276                let root = Arc::as_ptr(&root_path) as usize;
277                self.stats.entries_traversed += 1;
278                let mut data = EntryData::default();
279                match entry {
280                    Ok(TraversalEntry(entry)) => {
281                        let walk_depth = entry.depth;
282                        if self.skip_root {
283                            data.name = entry.file_name.clone().into();
284                        } else {
285                            data.name = if walk_depth < 1 && self.use_root_path {
286                                (*root_path).clone()
287                            } else {
288                                entry.file_name.clone().into()
289                            }
290                        }
291
292                        let mut file_size = 0u128;
293                        let mut mtime: SystemTime = UNIX_EPOCH;
294                        data.is_dir = entry.file_type.is_dir();
295                        if let Ok(m) = &entry.metadata {
296                            if self.walk_options.count_hard_links
297                                || self.inodes.add(&entry, m)
298                                    && (self.walk_options.cross_filesystems
299                                        || crossdev::is_same_device(device_id, m))
300                            {
301                                if self.walk_options.apparent_size {
302                                    file_size = u128::from(m.len());
303                                } else {
304                                    file_size =
305                                        u128::from(
306                                            size_on_disk(
307                                                &entry.parent_path,
308                                                &data.name,
309                                                m,
310                                                data.is_dir,
311                                            )
312                                            .unwrap_or_else(|_| {
313                                                self.stats.io_errors += 1;
314                                                data.metadata_io_error = true;
315                                                0
316                                            }),
317                                        );
318                                }
319                            } else {
320                                data.entry_count = Some(0);
321                            }
322
323                            if let Ok(modified) = m.modified() {
324                                mtime = modified;
325                            } else {
326                                self.stats.io_errors += 1;
327                                data.metadata_io_error = true;
328                            }
329                        } else {
330                            self.stats.io_errors += 1;
331                            data.metadata_io_error = true;
332                        }
333
334                        data.mtime = mtime;
335                        data.size = file_size;
336                        if data.is_dir {
337                            data.entry_count = Some(1);
338                        }
339                        let entry_count = u64::from(data.is_dir || data.entry_count != Some(0));
340
341                        let parent_index = if walk_depth == 0 {
342                            self.root_idx
343                        } else {
344                            if self.skip_root {
345                                self.nodes_by_path
346                                    .entry((root, (*root_path).clone()))
347                                    .or_insert(self.root_idx);
348                            }
349                            *self
350                                .nodes_by_path
351                                .get(&(root, entry.parent_path.to_path_buf()))
352                                .expect("parent entries are emitted before their children")
353                        };
354                        let entry_index = traversal.tree.add_node(data);
355                        traversal.tree.add_edge(parent_index, entry_index, ());
356                        if traversal.tree[entry_index].is_dir {
357                            self.nodes_by_path.insert((root, entry.path()), entry_index);
358                        }
359
360                        let mut ancestor = Some(parent_index);
361                        while let Some(index) = ancestor {
362                            ancestor = traversal
363                                .tree
364                                .neighbors_directed(index, Direction::Incoming)
365                                .next();
366                            let entry = &mut traversal.tree[index];
367                            entry.size += file_size;
368                            *entry.entry_count.get_or_insert(0) += entry_count;
369                        }
370                    }
371                    Err(_) => self.stats.io_errors += 1,
372                }
373
374                if self.throttle.as_ref().is_some_and(|t| t.can_update()) {
375                    return Some(false);
376                }
377            }
378            TraversalEvent::Finished(io_errors) => {
379                self.stats.io_errors += io_errors;
380                self.throttle = None;
381                let root_size = traversal.tree[self.root_idx].size;
382                self.nodes_by_path = HashMap::new();
383                self.stats.total_bytes = Some(root_size);
384                self.stats.elapsed = Some(self.stats.start.elapsed());
385
386                return Some(true);
387            }
388        }
389        None
390    }
391}
392
393#[cfg(not(any(windows, target_os = "macos")))]
394/// Return disk usage for `name` on Unix-like platforms.
395fn size_on_disk(
396    _parent: &Path,
397    name: &Path,
398    meta: &crate::walk::Metadata,
399    _is_dir: bool,
400) -> io::Result<u64> {
401    name.size_on_disk_fast(meta)
402}
403
404#[cfg(target_os = "macos")]
405/// Return disk usage from metadata already collected by the macOS filesystem walker.
406#[allow(clippy::unnecessary_wraps)]
407fn size_on_disk(
408    _parent: &Path,
409    _name: &Path,
410    meta: &crate::walk::Metadata,
411    _is_dir: bool,
412) -> io::Result<u64> {
413    Ok(meta.allocated_size())
414}
415
416#[cfg(windows)]
417/// Return disk usage for `name` on Windows platforms.
418#[allow(clippy::unnecessary_wraps)]
419fn size_on_disk(
420    _parent: &Path,
421    _name: &Path,
422    meta: &crate::walk::Metadata,
423    is_dir: bool,
424) -> io::Result<u64> {
425    Ok(if is_dir { 0 } else { meta.allocated_size() })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn ancestor_sizes_update_before_traversal_finishes() {
434        let dir = tempfile::tempdir().unwrap();
435        std::fs::create_dir(dir.path().join("nested")).unwrap();
436        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
437
438        let mut traversal = Traversal::new();
439        let mut background = BackgroundTraversal::start(
440            traversal.root_index,
441            &WalkOptions {
442                threads: 2,
443                count_hard_links: true,
444                apparent_size: true,
445                cross_filesystems: true,
446                ignore_dirs: std::collections::BTreeSet::default(),
447                ignore_patterns: None,
448            },
449            vec![dir.path().to_owned()],
450            None,
451            false,
452            false,
453        )
454        .unwrap();
455
456        loop {
457            let event = background.event_rx.recv().unwrap();
458            let is_file = matches!(
459                &event,
460                TraversalEvent::Entry(Ok(TraversalEntry(entry)), _, _)
461                    if entry.file_name == "file"
462            );
463            background.integrate_traversal_event(&mut traversal, event);
464            if is_file {
465                let root_size = traversal.tree[traversal.root_index].size;
466                assert!(
467                    root_size >= 7,
468                    "root size should include the 7-byte nested file, got {root_size}"
469                );
470                let nested_size = traversal
471                    .tree
472                    .node_weights()
473                    .find(|entry| entry.name == Path::new("nested"))
474                    .unwrap()
475                    .size;
476                assert!(
477                    nested_size >= 7,
478                    "nested directory size should include its 7-byte file, got {nested_size}"
479                );
480                break;
481            }
482        }
483    }
484
485    #[test]
486    fn duplicate_roots_keep_their_own_children() {
487        let dir = tempfile::tempdir().unwrap();
488        std::fs::write(dir.path().join("file"), b"content").unwrap();
489        let mut traversal = Traversal::new();
490        let mut background = BackgroundTraversal::start(
491            traversal.root_index,
492            &WalkOptions {
493                threads: 1,
494                count_hard_links: true,
495                apparent_size: true,
496                cross_filesystems: true,
497                ignore_dirs: std::collections::BTreeSet::default(),
498                ignore_patterns: None,
499            },
500            vec![dir.path().to_owned(), dir.path().to_owned()],
501            None,
502            false,
503            false,
504        )
505        .unwrap();
506
507        while !background
508            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
509            .unwrap_or(false)
510        {}
511
512        let roots = traversal
513            .tree
514            .neighbors_directed(traversal.root_index, Direction::Outgoing)
515            .collect::<Vec<_>>();
516        assert_eq!(roots.len(), 2);
517        for root in roots {
518            assert_eq!(
519                traversal
520                    .tree
521                    .neighbors_directed(root, Direction::Outgoing)
522                    .count(),
523                1
524            );
525        }
526    }
527
528    #[cfg(unix)]
529    #[test]
530    fn root_device_error_is_reported() {
531        use std::os::unix::fs::symlink;
532
533        let dir = tempfile::tempdir().unwrap();
534        let root = dir.path().join("dangling");
535        let valid = dir.path().join("valid");
536        symlink(dir.path().join("missing"), &root).unwrap();
537        std::fs::write(&valid, b"content").unwrap();
538        let mut traversal = Traversal::new();
539        let mut background = BackgroundTraversal::start(
540            traversal.root_index,
541            &WalkOptions {
542                threads: 1,
543                count_hard_links: true,
544                apparent_size: true,
545                cross_filesystems: false,
546                ignore_dirs: std::collections::BTreeSet::default(),
547                ignore_patterns: None,
548            },
549            vec![root.clone(), valid.clone()],
550            None,
551            false,
552            false,
553        )
554        .unwrap();
555
556        while !background
557            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
558            .unwrap_or(false)
559        {}
560
561        assert_eq!(background.stats.io_errors, 1);
562    }
563
564    #[test]
565    fn size_of_entry_data() {
566        assert!(
567            std::mem::size_of::<EntryData>() <= 80,
568            "the size of this ({}) should not exceed 80 as it affects overall memory consumption",
569            std::mem::size_of::<EntryData>()
570        );
571    }
572}