Skip to main content

dua/
common.rs

1use crate::crossdev;
2use crate::traverse::{EntryData, Tree, TreeIndex};
3use byte_unit::{ByteUnit, n_gb_bytes, n_gib_bytes, n_mb_bytes, n_mib_bytes};
4use serde::Deserialize;
5use std::collections::BTreeSet;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::time::Duration;
10use std::{fmt, path::Path};
11
12/// Return the entry at `node_idx` or panic if the index is invalid for `tree`.
13pub(crate) fn get_entry_or_panic(tree: &Tree, node_idx: TreeIndex) -> &EntryData {
14    tree.node_weight(node_idx)
15        .expect("node should always be retrievable with valid index")
16}
17
18pub(crate) fn get_size_or_panic(tree: &Tree, node_idx: TreeIndex) -> u128 {
19    get_entry_or_panic(tree, node_idx).size
20}
21
22/// Specifies a way to format bytes
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
24pub enum ByteFormat {
25    /// metric format, based on 1000.
26    #[serde(rename = "metric")]
27    Metric,
28    /// binary format, based on 1024
29    #[serde(rename = "binary")]
30    Binary,
31    /// raw bytes, without additional formatting
32    #[serde(rename = "bytes")]
33    Bytes,
34    /// only gigabytes without smart-unit
35    #[serde(rename = "gb")]
36    GB,
37    /// only gibibytes without smart-unit
38    #[serde(rename = "gib")]
39    GiB,
40    /// only megabytes without smart-unit
41    #[serde(rename = "mb")]
42    MB,
43    /// only mebibytes without smart-unit
44    #[serde(rename = "mib")]
45    MiB,
46}
47
48impl ByteFormat {
49    /// Return the content width (without unit suffix) needed to display values in this format.
50    pub fn width(self) -> usize {
51        use ByteFormat::*;
52        match self {
53            Metric => 10,
54            Binary => 11,
55            Bytes => 12,
56            MiB | MB => 12,
57            _ => 10,
58        }
59    }
60    /// Return the full width (value plus unit and separator) used by this format.
61    pub fn total_width(self) -> usize {
62        use ByteFormat::*;
63        const THE_SPACE_BETWEEN_UNIT_AND_NUMBER: usize = 1;
64
65        self.width()
66            + match self {
67                Binary | MiB | GiB => 3,
68                Metric | MB | GB => 2,
69                Bytes => 1,
70            }
71            + THE_SPACE_BETWEEN_UNIT_AND_NUMBER
72    }
73    /// Create a display adapter for `bytes` using this format.
74    pub fn display(self, bytes: u128) -> impl fmt::Display {
75        ByteFormatDisplay {
76            format: self,
77            bytes,
78        }
79    }
80}
81
82/// A lightweight display adapter created by [`ByteFormat::display`].
83struct ByteFormatDisplay {
84    format: ByteFormat,
85    bytes: u128,
86}
87
88impl fmt::Display for ByteFormatDisplay {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
90        use ByteFormat::*;
91        use byte_unit::Byte;
92
93        let format = match self.format {
94            Bytes => return write!(f, "{} b", self.bytes),
95            Binary => (true, None),
96            Metric => (false, None),
97            GB => (false, Some((n_gb_bytes!(1), ByteUnit::GB))),
98            GiB => (false, Some((n_gib_bytes!(1), ByteUnit::GiB))),
99            MB => (false, Some((n_mb_bytes!(1), ByteUnit::MB))),
100            MiB => (false, Some((n_mib_bytes!(1), ByteUnit::MiB))),
101        };
102
103        let b = match format {
104            (_, Some((divisor, unit))) => Byte::from_unit(self.bytes as f64 / divisor as f64, unit)
105                .expect("byte count > 0")
106                .get_adjusted_unit(unit),
107            (binary, None) => Byte::from_bytes(self.bytes).get_appropriate_unit(binary),
108        }
109        .format(2);
110        let mut splits = b.split(' ');
111        match (splits.next(), splits.next()) {
112            (Some(bytes), Some(unit)) => write!(
113                f,
114                "{} {:>unit_width$}",
115                bytes,
116                unit,
117                unit_width = match self.format {
118                    Binary => 3,
119                    Metric => 2,
120                    _ => 2,
121                }
122            ),
123            _ => f.write_str(&b),
124        }
125    }
126}
127
128/// Identify the kind of sorting to apply during filesystem iteration
129#[derive(Clone)]
130pub enum TraversalSorting {
131    /// Keep filesystem iteration order as provided by the walker.
132    None,
133    /// Sort entries alphabetically by file name during iteration.
134    AlphabeticalByFileName,
135}
136
137/// Throttle access to an optional `io::Write` to the specified `Duration`
138#[derive(Debug)]
139pub(crate) struct Throttle {
140    trigger: Arc<AtomicBool>,
141}
142
143impl Throttle {
144    /// Create a new throttle that allows updates at most once per `duration`.
145    ///
146    /// If `initial_sleep` is set, the first update is delayed by that amount.
147    pub(crate) fn new(duration: Duration, initial_sleep: Option<Duration>) -> Self {
148        let instance = Self {
149            trigger: Default::default(),
150        };
151
152        let trigger = Arc::downgrade(&instance.trigger);
153        std::thread::spawn(move || {
154            if let Some(duration) = initial_sleep {
155                std::thread::sleep(duration)
156            }
157            while let Some(t) = trigger.upgrade() {
158                t.store(true, Ordering::Relaxed);
159                std::thread::sleep(duration);
160            }
161        });
162
163        instance
164    }
165
166    /// Execute `f` only if the throttle currently allows an update.
167    pub(crate) fn throttled<F>(&self, f: F)
168    where
169        F: FnOnce(),
170    {
171        if self.can_update() {
172            f()
173        }
174    }
175
176    /// Return `true` if we are not currently throttled.
177    pub(crate) fn can_update(&self) -> bool {
178        self.trigger.swap(false, Ordering::Relaxed)
179    }
180}
181
182/// Configures a filesystem walk, including output and formatting options.
183#[derive(Clone)]
184pub struct WalkOptions {
185    /// The amount of threads to use. Refer to [`WalkDir::num_threads()`](https://docs.rs/jwalk/0.4.0/jwalk/struct.WalkDir.html#method.num_threads)
186    /// for more information.
187    pub threads: usize,
188    /// If `true`, count every hard-link occurrence independently.
189    pub count_hard_links: bool,
190    /// If `true`, use apparent size (`metadata.len()`), not allocated blocks on disk.
191    pub apparent_size: bool,
192    /// Sorting mode applied by the filesystem walker.
193    pub sorting: TraversalSorting,
194    /// If `false`, traversal is constrained to the root filesystem/device.
195    pub cross_filesystems: bool,
196    /// Canonicalized directories to skip from traversal.
197    pub ignore_dirs: BTreeSet<PathBuf>,
198}
199
200type WalkDir = jwalk::WalkDirGeneric<((), Option<Result<std::fs::Metadata, jwalk::Error>>)>;
201
202impl WalkOptions {
203    /// Create an iterator over `root` honoring this walk configuration.
204    ///
205    /// `root_device_id` is used to filter entries when `cross_filesystems == false`.
206    /// If `skip_root` is `true`, the root directory itself is omitted from yielded entries.
207    pub(crate) fn iter_from_path(
208        &self,
209        root: &Path,
210        root_device_id: u64,
211        skip_root: bool,
212    ) -> WalkDir {
213        let ignore_dirs = self.ignore_dirs.clone();
214        let cwd = std::env::current_dir().unwrap_or_else(|_| root.to_owned());
215        WalkDir::new(root)
216            .follow_links(false)
217            .min_depth(if skip_root { 1 } else { 0 })
218            .sort(match self.sorting {
219                TraversalSorting::None => false,
220                TraversalSorting::AlphabeticalByFileName => true,
221            })
222            .skip_hidden(false)
223            .process_read_dir({
224                let cross_filesystems = self.cross_filesystems;
225                move |_, _, _, dir_entry_results| {
226                    dir_entry_results.iter_mut().for_each(|dir_entry_result| {
227                        if let Ok(dir_entry) = dir_entry_result {
228                            let metadata = dir_entry.metadata();
229
230                            if dir_entry.file_type.is_dir() {
231                                let ok_for_fs = cross_filesystems
232                                    || metadata
233                                        .as_ref()
234                                        .map(|m| crossdev::is_same_device(root_device_id, m))
235                                        .unwrap_or(true);
236                                if !ok_for_fs
237                                    || ignore_directory(&dir_entry.path(), &ignore_dirs, &cwd)
238                                {
239                                    dir_entry.read_children_path = None;
240                                }
241                            }
242
243                            dir_entry.client_state = Some(metadata);
244                        }
245                    })
246                }
247            })
248            .parallelism(match self.threads {
249                0 => jwalk::Parallelism::RayonDefaultPool {
250                    busy_timeout: std::time::Duration::from_secs(1),
251                },
252                1 => jwalk::Parallelism::Serial,
253                _ => jwalk::Parallelism::RayonExistingPool {
254                    pool: jwalk::rayon::ThreadPoolBuilder::new()
255                        .stack_size(128 * 1024)
256                        .num_threads(self.threads)
257                        .thread_name(|idx| format!("dua-fs-walk-{idx}"))
258                        .build()
259                        .expect("fields we set cannot fail")
260                        .into(),
261                    busy_timeout: None,
262                },
263            })
264    }
265}
266
267/// Information we gather during a filesystem walk
268#[derive(Default)]
269pub struct WalkResult {
270    /// The amount of io::errors we encountered. Can happen when fetching meta-data, or when reading the directory contents.
271    pub num_errors: u64,
272}
273
274impl WalkResult {
275    /// Convert traversal result into a process exit code.
276    ///
277    /// Returns `0` if no I/O errors occurred, otherwise `1`.
278    pub fn to_exit_code(&self) -> i32 {
279        i32::from(self.num_errors > 0)
280    }
281}
282
283/// Canonicalize user-provided ignore directory paths.
284///
285/// Non-canonicalizable paths are ignored.
286pub fn canonicalize_ignore_dirs(ignore_dirs: &[PathBuf]) -> BTreeSet<PathBuf> {
287    let dirs = ignore_dirs
288        .iter()
289        .map(gix::path::realpath)
290        .filter_map(Result::ok)
291        .collect();
292    log::info!("Ignoring canonicalized {dirs:?}");
293    dirs
294}
295
296fn ignore_directory(path: &Path, ignore_dirs: &BTreeSet<PathBuf>, cwd: &Path) -> bool {
297    if ignore_dirs.is_empty() {
298        return false;
299    }
300    let path = gix::path::realpath_opts(path, cwd, 32);
301    path.map(|path| {
302        let ignored = ignore_dirs.contains(&path);
303        if ignored {
304            log::debug!("Ignored {path:?}");
305        }
306        ignored
307    })
308    .unwrap_or(false)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_ignore_directories() {
317        let cwd = std::env::current_dir().unwrap();
318        #[cfg(unix)]
319        let mut parameters = vec![
320            ("/usr", vec!["/usr"], true),
321            ("/usr/local", vec!["/usr"], false),
322            ("/smth", vec!["/usr"], false),
323            ("/usr/local/..", vec!["/usr/local/.."], true),
324            ("/usr", vec!["/usr/local/.."], true),
325            ("/usr/local/share/../..", vec!["/usr"], true),
326        ];
327
328        #[cfg(windows)]
329        let mut parameters = vec![
330            ("C:\\Windows", vec!["C:\\Windows"], true),
331            ("C:\\Windows\\System", vec!["C:\\Windows"], false),
332            ("C:\\Smth", vec!["C:\\Windows"], false),
333            (
334                "C:\\Windows\\System\\..",
335                vec!["C:\\Windows\\System\\.."],
336                true,
337            ),
338            ("C:\\Windows", vec!["C:\\Windows\\System\\.."], true),
339            (
340                "C:\\Windows\\System\\Speech\\..\\..",
341                vec!["C:\\Windows"],
342                true,
343            ),
344        ];
345
346        parameters.extend([
347            ("src", vec!["src"], true),
348            ("src/interactive", vec!["src"], false),
349            ("src/interactive/..", vec!["src"], true),
350        ]);
351
352        for (path, ignore_dirs, expected_result) in parameters {
353            let ignore_dirs = canonicalize_ignore_dirs(
354                &ignore_dirs.into_iter().map(Into::into).collect::<Vec<_>>(),
355            );
356            assert_eq!(
357                ignore_directory(path.as_ref(), &ignore_dirs, &cwd),
358                expected_result,
359                "result='{expected_result}' for path='{path}' and ignore_dir='{ignore_dirs:?}' "
360            );
361        }
362    }
363}