Skip to main content

dua/
traverse.rs

1use crate::{Throttle, WalkOptions, 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
17/// Node index type used by the traversal tree graph.
18pub type TreeIndex = NodeIndex;
19/// Graph type used to represent traversed filesystem entries.
20pub type Tree = StableGraph<EntryData, (), Directed>;
21
22/// Data stored for each filesystem entry in the traversal tree.
23#[derive(Eq, PartialEq, Clone)]
24pub struct EntryData {
25    /// The entry name relative to its parent.
26    pub name: PathBuf,
27    /// The entry's size in bytes. If it's a directory, the size is the aggregated file size of all children
28    /// plus the  size of the directory entry itself
29    pub size: u128,
30    /// Last modification time if available.
31    pub mtime: SystemTime,
32    /// Recursive entry count for directories, or `None` for files.
33    pub entry_count: Option<u64>,
34    /// If set, the item meta-data could not be obtained
35    pub metadata_io_error: bool,
36    /// `true` if the entry is a directory.
37    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            // Skip mtime
60            .field("metadata_io_error", &self.metadata_io_error)
61            .finish()
62    }
63}
64
65/// The result of the previous filesystem traversal
66#[derive(Debug)]
67pub struct Traversal {
68    /// A tree representing the entire filestem traversal
69    pub tree: Tree,
70    /// The top-level node of the tree.
71    pub root_index: TreeIndex,
72    /// The time at which the instance was created, typically the start of the traversal.
73    pub start_time: Instant,
74    /// The time it cost to compute the traversal, when done.
75    pub cost: Option<Duration>,
76}
77
78impl Default for Traversal {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl Traversal {
85    /// Create a new empty traversal with a synthetic root node.
86    #[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    /// Return `true` if this traversal is considered expensive to recompute.
99    #[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/// Runtime statistics gathered while traversal is running.
106#[derive(Clone, Copy)]
107pub struct TraversalStats {
108    /// Amount of files or directories we have seen during the filesystem traversal
109    pub entries_traversed: u64,
110    /// The time at which the traversal started.
111    pub start: std::time::Instant,
112    /// The amount of time it took to finish the traversal. Set only once done.
113    pub elapsed: Option<std::time::Duration>,
114    /// Total amount of IO errors encountered when traversing the filesystem
115    pub io_errors: u64,
116    /// Total amount of bytes seen during the traversal
117    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
132/// A filesystem entry waiting to be integrated into a traversal.
133pub struct TraversalEntry(crate::walk::Entry);
134
135/// Events emitted by a background filesystem traversal.
136pub enum TraversalEvent {
137    /// A discovered entry and its traversal context.
138    Entry(io::Result<TraversalEntry>, Arc<PathBuf>, u64),
139    /// Traversal completed with additional I/O error count.
140    Finished(u64),
141}
142
143/// An in-progress traversal which exposes newly obtained entries
144pub struct BackgroundTraversal {
145    walk_options: WalkOptions,
146    /// Tree node index that acts as root for this traversal integration.
147    pub root_idx: TreeIndex,
148    /// Running traversal statistics.
149    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    /// 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        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                let mut io_errors: u64 = 0;
175                move || {
176                    for root_path in input {
177                        log::info!("Walking {}", root_path.display());
178                        let Ok(device_id) = crossdev::init(root_path.as_ref()) else {
179                            io_errors += 1;
180                            continue;
181                        };
182
183                        let root_path = Arc::new(root_path);
184                        for entry in walk_options.iter_from_path(
185                            root_path.as_ref(),
186                            device_id,
187                            skip_root,
188                            crate::walk::Order::ParentFirst,
189                        ) {
190                            if entry_tx
191                                .send(TraversalEvent::Entry(
192                                    entry.map(TraversalEntry),
193                                    Arc::clone(&root_path),
194                                    device_id,
195                                ))
196                                .is_err()
197                            {
198                                // The channel is closed, this means the user has
199                                // requested to quit the app. Abort the walking.
200                                return;
201                            }
202                        }
203                    }
204                    if entry_tx.send(TraversalEvent::Finished(io_errors)).is_err() {
205                        log::error!("Failed to send TraversalEvents::Finished event");
206                    }
207                }
208            })?;
209
210        Ok(Self {
211            walk_options: walk_options.clone(),
212            root_idx,
213            stats: TraversalStats::default(),
214            nodes_by_path: HashMap::new(),
215            inodes: InodeFilter::default(),
216            throttle: Some(Throttle::new(Duration::from_millis(250), None)),
217            skip_root,
218            use_root_path,
219            event_rx: entry_rx,
220        })
221    }
222
223    /// Integrate `event` into traversal `t` so its information is represented by it.
224    /// This builds the traversal tree from a directory-walk.
225    ///
226    /// Returns
227    /// * `Some(true)` if the traversal is finished
228    /// * `Some(false)` if the caller may update its state after throttling kicked in
229    /// * `None` - the event was written into the traversal, but there is nothing else to do
230    ///
231    /// # Panics
232    ///
233    /// Panics if a child entry arrives before its parent, violating the parent-first traversal
234    /// invariant.
235    #[expect(
236        clippy::too_many_lines,
237        reason = "event integration keeps tree updates atomic"
238    )]
239    pub fn integrate_traversal_event(
240        &mut self,
241        traversal: &mut Traversal,
242        event: TraversalEvent,
243    ) -> Option<bool> {
244        match event {
245            TraversalEvent::Entry(entry, root_path, device_id) => {
246                self.stats.entries_traversed += 1;
247                let mut data = EntryData::default();
248                match entry {
249                    Ok(TraversalEntry(entry)) => {
250                        let walk_depth = entry.depth;
251                        if self.skip_root {
252                            data.name = entry.file_name.clone().into();
253                        } else {
254                            data.name = if walk_depth < 1 && self.use_root_path {
255                                (*root_path).clone()
256                            } else {
257                                entry.file_name.clone().into()
258                            }
259                        }
260
261                        let mut file_size = 0u128;
262                        let mut mtime: SystemTime = UNIX_EPOCH;
263                        data.is_dir = entry.file_type.is_dir();
264                        if let Ok(m) = &entry.metadata {
265                            if self.walk_options.count_hard_links
266                                || self.inodes.add(m)
267                                    && (self.walk_options.cross_filesystems
268                                        || crossdev::is_same_device(device_id, m))
269                            {
270                                if self.walk_options.apparent_size {
271                                    file_size = u128::from(m.len());
272                                } else {
273                                    file_size = u128::from(
274                                        size_on_disk(&entry.parent_path, &data.name, m)
275                                            .unwrap_or_else(|_| {
276                                                self.stats.io_errors += 1;
277                                                data.metadata_io_error = true;
278                                                0
279                                            }),
280                                    );
281                                }
282                            } else {
283                                data.entry_count = Some(0);
284                            }
285
286                            if let Ok(modified) = m.modified() {
287                                mtime = modified;
288                            } else {
289                                self.stats.io_errors += 1;
290                                data.metadata_io_error = true;
291                            }
292                        } else {
293                            self.stats.io_errors += 1;
294                            data.metadata_io_error = true;
295                        }
296
297                        data.mtime = mtime;
298                        data.size = file_size;
299                        if data.is_dir {
300                            data.entry_count = Some(1);
301                        }
302                        let entry_count = u64::from(data.is_dir || data.entry_count != Some(0));
303
304                        let parent_index = if walk_depth == 0 {
305                            self.root_idx
306                        } else {
307                            if self.skip_root {
308                                self.nodes_by_path
309                                    .entry((*root_path).clone())
310                                    .or_insert(self.root_idx);
311                            }
312                            *self
313                                .nodes_by_path
314                                .get(entry.parent_path.as_ref())
315                                .expect("parent entries are emitted before their children")
316                        };
317                        let entry_index = traversal.tree.add_node(data);
318                        traversal.tree.add_edge(parent_index, entry_index, ());
319                        if traversal.tree[entry_index].is_dir {
320                            self.nodes_by_path.insert(entry.path(), entry_index);
321                        }
322
323                        let mut ancestor = Some(parent_index);
324                        while let Some(index) = ancestor {
325                            ancestor = traversal
326                                .tree
327                                .neighbors_directed(index, Direction::Incoming)
328                                .next();
329                            let entry = &mut traversal.tree[index];
330                            entry.size += file_size;
331                            *entry.entry_count.get_or_insert(0) += entry_count;
332                        }
333                    }
334                    Err(_) => self.stats.io_errors += 1,
335                }
336
337                if self.throttle.as_ref().is_some_and(|t| t.can_update()) {
338                    return Some(false);
339                }
340            }
341            TraversalEvent::Finished(io_errors) => {
342                self.stats.io_errors += io_errors;
343
344                self.throttle = None;
345                let root_size = traversal.tree[self.root_idx].size;
346                self.nodes_by_path = HashMap::new();
347                self.stats.total_bytes = Some(root_size);
348                self.stats.elapsed = Some(self.stats.start.elapsed());
349
350                return Some(true);
351            }
352        }
353        None
354    }
355}
356
357#[cfg(not(windows))]
358/// Return disk usage for `name` on Unix-like platforms.
359fn size_on_disk(_parent: &Path, name: &Path, meta: &Metadata) -> io::Result<u64> {
360    name.size_on_disk_fast(meta)
361}
362
363#[cfg(windows)]
364/// Return disk usage for `name` on Windows platforms.
365fn size_on_disk(parent: &Path, name: &Path, meta: &Metadata) -> io::Result<u64> {
366    parent.join(name).size_on_disk_fast(meta)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn ancestor_sizes_update_before_traversal_finishes() {
375        let dir = tempfile::tempdir().unwrap();
376        std::fs::create_dir(dir.path().join("nested")).unwrap();
377        std::fs::write(dir.path().join("nested/file"), b"content").unwrap();
378
379        let mut traversal = Traversal::new();
380        let mut background = BackgroundTraversal::start(
381            traversal.root_index,
382            &WalkOptions {
383                threads: 2,
384                count_hard_links: true,
385                apparent_size: true,
386                cross_filesystems: true,
387                ignore_dirs: std::collections::BTreeSet::default(),
388            },
389            vec![dir.path().to_owned()],
390            false,
391            false,
392        )
393        .unwrap();
394
395        loop {
396            let event = background.event_rx.recv().unwrap();
397            let is_file = matches!(
398                &event,
399                TraversalEvent::Entry(Ok(TraversalEntry(entry)), _, _)
400                    if entry.file_name == "file"
401            );
402            background.integrate_traversal_event(&mut traversal, event);
403            if is_file {
404                let root_size = traversal.tree[traversal.root_index].size;
405                assert!(
406                    root_size >= 7,
407                    "root size should include the 7-byte nested file, got {root_size}"
408                );
409                let nested_size = traversal
410                    .tree
411                    .node_weights()
412                    .find(|entry| entry.name == Path::new("nested"))
413                    .unwrap()
414                    .size;
415                assert!(
416                    nested_size >= 7,
417                    "nested directory size should include its 7-byte file, got {nested_size}"
418                );
419                break;
420            }
421        }
422    }
423
424    #[test]
425    fn size_of_entry_data() {
426        assert!(
427            std::mem::size_of::<EntryData>() <= 80,
428            "the size of this ({}) should not exceed 80 as it affects overall memory consumption",
429            std::mem::size_of::<EntryData>()
430        );
431    }
432}