Skip to main content

dua/
traverse.rs

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
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.
140    Finished,
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                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                            // The channel is closed, this means the user has
213                            // requested to quit the app. Abort the walking.
214                            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    /// Integrate `event` into traversal `t` so its information is represented by it.
237    /// This builds the traversal tree from a directory-walk.
238    ///
239    /// Returns
240    /// * `Some(true)` if the traversal is finished
241    /// * `Some(false)` if the caller may update its state after throttling kicked in
242    /// * `None` - the event was written into the traversal, but there is nothing else to do
243    ///
244    /// # Panics
245    ///
246    /// Panics if a child entry arrives before its parent, violating the parent-first traversal
247    /// invariant.
248    #[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))]
369/// Return disk usage for `name` on Unix-like platforms.
370fn size_on_disk(_parent: &Path, name: &Path, meta: &Metadata) -> io::Result<u64> {
371    name.size_on_disk_fast(meta)
372}
373
374#[cfg(windows)]
375/// Return disk usage for `name` on Windows platforms.
376fn 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}