Skip to main content

dua/
traverse.rs

1use crate::{Throttle, WalkOptions, WalkRoot, crossdev, inodefilter::InodeFilter};
2
3use crossbeam::channel::Receiver;
4#[cfg(not(windows))]
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 pattern_root = pattern_roots.as_deref().map(|pattern_roots| {
186                            pattern_roots
187                                .iter()
188                                .filter(|candidate| root_path.starts_with(candidate))
189                                .max_by_key(|candidate| candidate.components().count())
190                                .cloned()
191                                .unwrap_or_else(|| root_path.clone())
192                        });
193                        let device_id = if walk_options.cross_filesystems {
194                            0
195                        } else {
196                            let Ok(device_id) = crossdev::init(&root_path) else {
197                                // Skip roots that can't be accessed entirely.
198                                io_errors += 1;
199                                continue;
200                            };
201                            device_id
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(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(windows))]
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(windows)]
405/// Return disk usage for `name` on Windows platforms.
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(if is_dir { 0 } else { meta.allocated_size() })
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn ancestor_sizes_update_before_traversal_finishes() {
422        let dir = tempfile::tempdir().unwrap();
423        std::fs::create_dir(dir.path().join("nested")).unwrap();
424        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
425
426        let mut traversal = Traversal::new();
427        let mut background = BackgroundTraversal::start(
428            traversal.root_index,
429            &WalkOptions {
430                threads: 2,
431                count_hard_links: true,
432                apparent_size: true,
433                cross_filesystems: true,
434                ignore_dirs: std::collections::BTreeSet::default(),
435                ignore_patterns: None,
436            },
437            vec![dir.path().to_owned()],
438            None,
439            false,
440            false,
441        )
442        .unwrap();
443
444        loop {
445            let event = background.event_rx.recv().unwrap();
446            let is_file = matches!(
447                &event,
448                TraversalEvent::Entry(Ok(TraversalEntry(entry)), _, _)
449                    if entry.file_name == "file"
450            );
451            background.integrate_traversal_event(&mut traversal, event);
452            if is_file {
453                let root_size = traversal.tree[traversal.root_index].size;
454                assert!(
455                    root_size >= 7,
456                    "root size should include the 7-byte nested file, got {root_size}"
457                );
458                let nested_size = traversal
459                    .tree
460                    .node_weights()
461                    .find(|entry| entry.name == Path::new("nested"))
462                    .unwrap()
463                    .size;
464                assert!(
465                    nested_size >= 7,
466                    "nested directory size should include its 7-byte file, got {nested_size}"
467                );
468                break;
469            }
470        }
471    }
472
473    #[test]
474    fn duplicate_roots_keep_their_own_children() {
475        let dir = tempfile::tempdir().unwrap();
476        std::fs::write(dir.path().join("file"), b"content").unwrap();
477        let mut traversal = Traversal::new();
478        let mut background = BackgroundTraversal::start(
479            traversal.root_index,
480            &WalkOptions {
481                threads: 1,
482                count_hard_links: true,
483                apparent_size: true,
484                cross_filesystems: true,
485                ignore_dirs: std::collections::BTreeSet::default(),
486                ignore_patterns: None,
487            },
488            vec![dir.path().to_owned(), dir.path().to_owned()],
489            None,
490            false,
491            false,
492        )
493        .unwrap();
494
495        while !background
496            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
497            .unwrap_or(false)
498        {}
499
500        let roots = traversal
501            .tree
502            .neighbors_directed(traversal.root_index, Direction::Outgoing)
503            .collect::<Vec<_>>();
504        assert_eq!(roots.len(), 2);
505        for root in roots {
506            assert_eq!(
507                traversal
508                    .tree
509                    .neighbors_directed(root, Direction::Outgoing)
510                    .count(),
511                1
512            );
513        }
514    }
515
516    #[cfg(unix)]
517    #[test]
518    fn root_device_error_is_reported() {
519        use std::os::unix::fs::symlink;
520
521        let dir = tempfile::tempdir().unwrap();
522        let root = dir.path().join("dangling");
523        let valid = dir.path().join("valid");
524        symlink(dir.path().join("missing"), &root).unwrap();
525        std::fs::write(&valid, b"content").unwrap();
526        let mut traversal = Traversal::new();
527        let mut background = BackgroundTraversal::start(
528            traversal.root_index,
529            &WalkOptions {
530                threads: 1,
531                count_hard_links: true,
532                apparent_size: true,
533                cross_filesystems: false,
534                ignore_dirs: std::collections::BTreeSet::default(),
535                ignore_patterns: None,
536            },
537            vec![root.clone(), valid.clone()],
538            None,
539            false,
540            false,
541        )
542        .unwrap();
543
544        while !background
545            .integrate_traversal_event(&mut traversal, background.event_rx.recv().unwrap())
546            .unwrap_or(false)
547        {}
548
549        assert_eq!(background.stats.io_errors, 1);
550    }
551
552    #[test]
553    fn size_of_entry_data() {
554        assert!(
555            std::mem::size_of::<EntryData>() <= 80,
556            "the size of this ({}) should not exceed 80 as it affects overall memory consumption",
557            std::mem::size_of::<EntryData>()
558        );
559    }
560}