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                            #[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                            // The channel is closed, this means the user has
232                            // requested to quit the app. Abort the walking.
233                            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    /// Integrate `event` into traversal `t` so its information is represented by it.
256    /// This builds the traversal tree from a directory-walk.
257    ///
258    /// Returns
259    /// * `Some(true)` if the traversal is finished
260    /// * `Some(false)` if the caller may update its state after throttling kicked in
261    /// * `None` - the event was written into the traversal, but there is nothing else to do
262    ///
263    /// # Panics
264    ///
265    /// Panics if a child entry arrives before its parent, violating the parent-first traversal
266    /// invariant.
267    #[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")))]
396/// Return disk usage for `name` on Unix-like platforms.
397fn 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/// Return disk usage from metadata already collected by the macOS filesystem walker.
408#[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/// Return disk usage for `name` on Windows platforms.
420#[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}