Skip to main content

dua/
common.rs

1use crate::{crossdev, walk};
2use anyhow::Context;
3use byte_unit::{Byte, Unit, UnitType};
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/// Specifies a way to format bytes
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
14pub enum ByteFormat {
15    /// metric format, based on 1000.
16    #[serde(rename = "metric")]
17    Metric,
18    /// binary format, based on 1024
19    #[serde(rename = "binary")]
20    Binary,
21    /// raw bytes, without additional formatting
22    #[serde(rename = "bytes")]
23    Bytes,
24    /// only gigabytes without smart-unit
25    #[serde(rename = "gb")]
26    GB,
27    /// only gibibytes without smart-unit
28    #[serde(rename = "gib")]
29    GiB,
30    /// only megabytes without smart-unit
31    #[serde(rename = "mb")]
32    MB,
33    /// only mebibytes without smart-unit
34    #[serde(rename = "mib")]
35    MiB,
36}
37
38impl ByteFormat {
39    /// Return the content width (without unit suffix) needed to display values in this format.
40    #[must_use]
41    pub fn width(self) -> usize {
42        use ByteFormat::{Binary, Bytes, MB, MiB};
43        match self {
44            Binary => 11,
45            Bytes | MB | MiB => 12,
46            _ => 10,
47        }
48    }
49    /// Return the full width (value plus unit and separator) used by this format.
50    #[must_use]
51    pub fn total_width(self) -> usize {
52        use ByteFormat::{Binary, Bytes, GB, GiB, MB, Metric, MiB};
53        const THE_SPACE_BETWEEN_UNIT_AND_NUMBER: usize = 1;
54
55        self.width()
56            + match self {
57                Binary | MiB | GiB => 3,
58                Metric | MB | GB => 2,
59                Bytes => 1,
60            }
61            + THE_SPACE_BETWEEN_UNIT_AND_NUMBER
62    }
63    /// Create a display adapter for `bytes` using this format.
64    #[must_use]
65    pub fn display(self, bytes: u128) -> impl fmt::Display {
66        ByteFormatDisplay {
67            format: self,
68            bytes,
69        }
70    }
71}
72
73/// A lightweight display adapter created by [`ByteFormat::display`].
74struct ByteFormatDisplay {
75    format: ByteFormat,
76    bytes: u128,
77}
78
79impl fmt::Display for ByteFormatDisplay {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
81        use ByteFormat::{Binary, Bytes, GB, GiB, MB, Metric, MiB};
82
83        let bytes = Byte::from_u128(self.bytes).expect("supported byte count");
84        let adjusted = match self.format {
85            Bytes => return write!(f, "{} b", self.bytes),
86            Binary => bytes.get_appropriate_unit(UnitType::Binary),
87            Metric => bytes.get_appropriate_unit(UnitType::Decimal),
88            GB => bytes.get_adjusted_unit(Unit::GB),
89            GiB => bytes.get_adjusted_unit(Unit::GiB),
90            MB => bytes.get_adjusted_unit(Unit::MB),
91            MiB => bytes.get_adjusted_unit(Unit::MiB),
92        };
93        let b = format!("{adjusted:.2}");
94        let mut splits = b.split(' ');
95        match (splits.next(), splits.next()) {
96            (Some(bytes), Some(unit)) => write!(
97                f,
98                "{} {:>unit_width$}",
99                bytes,
100                unit,
101                unit_width = match self.format {
102                    Binary => 3,
103                    _ => 2,
104                }
105            ),
106            _ => f.write_str(&b),
107        }
108    }
109}
110
111/// Throttle access to an optional `io::Write` to the specified `Duration`
112#[derive(Debug)]
113pub(crate) struct Throttle {
114    trigger: Arc<AtomicBool>,
115}
116
117impl Throttle {
118    /// Create a new throttle that allows updates at most once per `duration`.
119    ///
120    /// If `initial_sleep` is set, the first update is delayed by that amount.
121    pub(crate) fn new(duration: Duration, initial_sleep: Option<Duration>) -> Self {
122        let instance = Self {
123            trigger: Arc::default(),
124        };
125
126        let trigger = Arc::downgrade(&instance.trigger);
127        std::thread::spawn(move || {
128            if let Some(duration) = initial_sleep {
129                std::thread::sleep(duration);
130            }
131            while let Some(t) = trigger.upgrade() {
132                t.store(true, Ordering::Relaxed);
133                std::thread::sleep(duration);
134            }
135        });
136
137        instance
138    }
139
140    /// Execute `f` only if the throttle currently allows an update.
141    pub(crate) fn throttled<F>(&self, f: F)
142    where
143        F: FnOnce(),
144    {
145        if self.can_update() {
146            f();
147        }
148    }
149
150    /// Return `true` if we are not currently throttled.
151    pub(crate) fn can_update(&self) -> bool {
152        self.trigger.swap(false, Ordering::Relaxed)
153    }
154}
155
156/// Gitignore-style patterns, read from files, which exclude entries from a traversal.
157///
158/// Patterns are matched against the path `dua` reports for an entry: normally relative to its
159/// effective working directory, or relative to an input root outside that directory. So `target/`
160/// excludes directories named `target` at any depth, while `/target/` excludes only `target` at
161/// the traversal root of that relative path, exactly like a `.gitignore` at the top of a repository.
162///
163/// Matching is case-sensitive on every platform so that the same pattern file produces the same
164/// report everywhere.
165#[derive(Clone, Debug)]
166pub struct IgnorePatterns {
167    search: gix::ignore::Search,
168}
169
170impl IgnorePatterns {
171    /// Read gitignore-style patterns from each file in `files`, in the given order.
172    ///
173    /// The usual `.gitignore` precedence applies: within a file the last matching line wins, and
174    /// files listed later win over files listed earlier. Reading a file that does not exist, or
175    /// that cannot be read, is an error - a silently empty pattern set would quietly report sizes
176    /// the caller did not ask for.
177    pub fn from_files(files: &[PathBuf]) -> anyhow::Result<Option<Self>> {
178        let mut search = gix::ignore::Search::default();
179        for file in files {
180            let buf = std::fs::read(file).with_context(|| {
181                format!("Failed to read ignore patterns from {}", file.display())
182            })?;
183            search.add_patterns_buffer(
184                &buf,
185                file.clone(),
186                None,
187                gix::ignore::search::Ignore::default(),
188            );
189        }
190        let pattern_count = search
191            .patterns
192            .iter()
193            .map(|list| list.patterns.len())
194            .sum::<usize>();
195        Ok(if pattern_count != 0 {
196            log::info!(
197                "Loaded {pattern_count} ignore pattern(s) from {file_count} file(s)",
198                file_count = files.len()
199            );
200            Some(Self { search })
201        } else {
202            None
203        })
204    }
205
206    /// Return `true` if `relative_path` is excluded, with `is_dir` telling directories from files
207    /// so that patterns ending in `/` only match directories.
208    ///
209    /// Note that callers are expected to stop descending into excluded directories, which also
210    /// means a negated pattern cannot bring back an entry below an excluded directory - the same
211    /// restriction Git has.
212    #[must_use]
213    pub fn is_excluded(&self, relative_path: &Path, is_dir: bool) -> bool {
214        if relative_path.as_os_str().is_empty() {
215            return false;
216        }
217        let relative_path =
218            gix::path::to_unix_separators_on_windows(gix::path::into_bstr(relative_path));
219        self.search
220            .pattern_matching_relative_path(
221                relative_path.as_ref(),
222                Some(is_dir),
223                gix::ignore::glob::pattern::Case::Sensitive,
224            )
225            .is_some_and(|match_| !match_.pattern.is_negative())
226    }
227
228    /// Return `true` if the input `path` is excluded, resolving it the way the traversal does.
229    /// These are made relative to `cwd`.
230    ///
231    /// Input paths that are excluded are best dropped before the walk starts, or they would be
232    /// reported as being empty rather than not reported at all.
233    #[must_use]
234    pub fn excludes_input_path(&self, path: &Path, cwd: &Path) -> bool {
235        pattern_relative_path(path, cwd, path)
236            .is_some_and(|relative_path| self.is_excluded(relative_path, path.is_dir()))
237    }
238}
239
240/// Configures a filesystem walk, including output and formatting options.
241#[derive(Clone)]
242pub struct WalkOptions {
243    /// The amount of filesystem worker threads to use.
244    pub threads: usize,
245    /// If `true`, count every hard-link occurrence independently.
246    pub count_hard_links: bool,
247    /// If `true`, use apparent size (`metadata.len()`), not allocated blocks on disk.
248    pub apparent_size: bool,
249    /// If `false`, traversal is constrained to the root filesystem/device.
250    pub cross_filesystems: bool,
251    /// Canonicalized directories to skip from traversal.
252    pub ignore_dirs: BTreeSet<PathBuf>,
253    /// Gitignore-style patterns whose matches are left out of the traversal entirely.
254    /// `None` if no pattern was configured.
255    pub ignore_patterns: Option<IgnorePatterns>,
256}
257
258/// Tells whether an entry found under the root with the given index is left out of the traversal.
259type ExcludeEntry = Arc<dyn Fn(usize, &walk::Entry) -> bool + Send + Sync>;
260
261/// A root prepared for a filesystem walk.
262pub(crate) struct WalkRoot {
263    /// Index used to associate emitted events with the original input path.
264    pub index: usize,
265    /// Path at which to start walking.
266    pub path: PathBuf,
267    /// Most specific original input root containing `path`, used as the ignore-pattern base.
268    /// Choosing the longest containing root preserves the right base when input roots overlap and
269    /// `path` is a subtree being refreshed.
270    ///
271    /// This is usually `path`, but can be a parent directory if this is the root for an
272    /// interactive refresh.
273    pub pattern_root: Option<PathBuf>,
274    /// Device containing the root, used to constrain cross-filesystem walks.
275    /// It's `0` if it couldn't be obtained or if `cross_filesystem` is `true`.
276    pub device_id: u64,
277}
278
279impl WalkOptions {
280    pub(crate) fn iter_from_paths(
281        &self,
282        roots: Vec<WalkRoot>,
283        skip_root: bool,
284        order: walk::Order,
285    ) -> impl Iterator<Item = (usize, walk::RootEvent)> + use<> {
286        let num_roots = roots
287            .iter()
288            .map(|root| root.index)
289            .max()
290            .map_or(0, |idx| idx + 1);
291        let path_count = roots.len();
292        let (device_ids, root_paths, paths_with_idx) = roots.into_iter().fold(
293            (
294                vec![0; num_roots],
295                vec![None; num_roots],
296                Vec::with_capacity(path_count),
297            ),
298            |(mut device_ids, mut root_paths, mut paths), root| {
299                device_ids[root.index] = root.device_id;
300                root_paths[root.index] = root.pattern_root;
301                paths.push((root.index, root.path));
302                (device_ids, root_paths, paths)
303            },
304        );
305        let ignore_dirs = self.ignore_dirs.clone();
306        let cwd = std::env::current_dir().unwrap_or_default();
307        let cross_filesystems = self.cross_filesystems;
308
309        // Excluding an entry means pruning it from the walk *and* from the emitted events, so the
310        // predicate is shared between the two. It short-circuits on the pattern set being empty to
311        // keep the common case free of the path building it would otherwise do per entry.
312        let is_excluded: ExcludeEntry = {
313            let patterns = self.ignore_patterns.clone();
314            let cwd = cwd.clone();
315            Arc::new(move |root_idx: usize, entry: &walk::Entry| {
316                let Some((patterns, pattern_root)) =
317                    patterns.as_ref().zip(root_paths[root_idx].as_deref())
318                else {
319                    return false;
320                };
321                let path = entry.path();
322                pattern_relative_path(&path, &cwd, pattern_root).is_some_and(|relative_path| {
323                    patterns.is_excluded(relative_path, entry.file_type.is_dir())
324                })
325            })
326        };
327        let is_excluded_while_walking = Arc::clone(&is_excluded);
328
329        walk::walk_roots(
330            paths_with_idx,
331            self.threads,
332            order,
333            move |root_idx, entry| {
334                (cross_filesystems
335                    || entry.metadata.as_ref().map_or(true, |metadata| {
336                        crossdev::is_same_device(device_ids[root_idx], metadata)
337                    }))
338                    && (entry.depth == 0 || !ignore_directory(&entry.path(), &ignore_dirs, &cwd))
339                    && !is_excluded_while_walking(root_idx, entry)
340            },
341        )
342        .filter(move |(root_idx, event)| match event {
343            walk::RootEvent::Entry(Ok(entry)) => {
344                (!skip_root || entry.depth > 0) && !is_excluded(*root_idx, entry)
345            }
346            walk::RootEvent::Entry(Err(_)) | walk::RootEvent::Finished => true,
347        })
348    }
349}
350
351/// Information we gather during a filesystem walk
352#[derive(Default)]
353pub struct WalkResult {
354    /// The amount of `io::errors` we encountered. Can happen when fetching meta-data, or when reading the directory contents.
355    pub num_errors: u64,
356}
357
358impl WalkResult {
359    /// Convert traversal result into a process exit code.
360    ///
361    /// Returns `0` if no I/O errors occurred, otherwise `1`.
362    #[must_use]
363    pub fn to_exit_code(&self) -> i32 {
364        i32::from(self.num_errors > 0)
365    }
366}
367
368/// Canonicalize user-provided ignore directory paths.
369///
370/// Non-canonicalizable paths are ignored.
371pub fn canonicalize_ignore_dirs(ignore_dirs: &[PathBuf]) -> BTreeSet<PathBuf> {
372    let dirs = ignore_dirs
373        .iter()
374        .map(gix::path::realpath)
375        .filter_map(Result::ok)
376        .collect();
377    log::info!("Ignoring canonicalized {dirs:?}");
378    dirs
379}
380
381/// Return the path that ignore patterns are matched against, or `None` if there is none.
382///
383/// `dua` reports entries by their path relative to the current directory `cwd` - it even changes into
384/// the directory it was given, if it was given exactly one - so that is what patterns should see.
385/// Roots outside via `traversal_root` of the current directory have no such path, and fall back to
386/// being relative to the root they were found under.
387fn pattern_relative_path<'a>(
388    path: &'a Path,
389    cwd: &Path,
390    traversal_root: &Path,
391) -> Option<&'a Path> {
392    if path.is_relative() {
393        return Some(path);
394    }
395    path.strip_prefix(cwd)
396        .or_else(|_| path.strip_prefix(traversal_root))
397        .ok()
398}
399
400fn ignore_directory(path: &Path, ignore_dirs: &BTreeSet<PathBuf>, cwd: &Path) -> bool {
401    if ignore_dirs.is_empty() {
402        return false;
403    }
404    let path = gix::path::realpath_opts(path, cwd, 32);
405    path.is_ok_and(|path| {
406        let ignored = ignore_dirs.contains(&path);
407        if ignored {
408            log::debug!("Ignored {}", path.display());
409        }
410        ignored
411    })
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_ignore_directories() {
420        let cwd = std::env::current_dir().unwrap();
421        #[cfg(unix)]
422        let mut parameters = vec![
423            ("/usr", vec!["/usr"], true),
424            ("/usr/local", vec!["/usr"], false),
425            ("/smth", vec!["/usr"], false),
426            ("/usr/local/..", vec!["/usr/local/.."], true),
427            ("/usr", vec!["/usr/local/.."], true),
428            ("/usr/local/share/../..", vec!["/usr"], true),
429        ];
430
431        #[cfg(windows)]
432        let mut parameters = vec![
433            ("C:\\Windows", vec!["C:\\Windows"], true),
434            ("C:\\Windows\\System", vec!["C:\\Windows"], false),
435            ("C:\\Smth", vec!["C:\\Windows"], false),
436            (
437                "C:\\Windows\\System\\..",
438                vec!["C:\\Windows\\System\\.."],
439                true,
440            ),
441            ("C:\\Windows", vec!["C:\\Windows\\System\\.."], true),
442            (
443                "C:\\Windows\\System\\Speech\\..\\..",
444                vec!["C:\\Windows"],
445                true,
446            ),
447        ];
448
449        parameters.extend([
450            ("src", vec!["src"], true),
451            ("src/interactive", vec!["src"], false),
452            ("src/interactive/..", vec!["src"], true),
453        ]);
454
455        for (path, ignore_dirs, expected_result) in parameters {
456            let ignore_dirs = canonicalize_ignore_dirs(
457                &ignore_dirs.into_iter().map(Into::into).collect::<Vec<_>>(),
458            );
459            assert_eq!(
460                ignore_directory(path.as_ref(), &ignore_dirs, &cwd),
461                expected_result,
462                "result='{expected_result}' for path='{path}' and ignore_dir='{ignore_dirs:?}' "
463            );
464        }
465    }
466
467    #[test]
468    fn explicitly_selected_ignored_root_is_traversed() {
469        let root = tempfile::tempdir().unwrap();
470        let child = root.path().join("child");
471        std::fs::create_dir(&child).unwrap();
472        let options = WalkOptions {
473            threads: 2,
474            count_hard_links: false,
475            apparent_size: false,
476            cross_filesystems: true,
477            ignore_dirs: canonicalize_ignore_dirs(&[root.path().to_owned()]),
478            ignore_patterns: None,
479        };
480
481        let paths = options
482            .iter_from_paths(
483                vec![WalkRoot {
484                    index: 0,
485                    pattern_root: None,
486                    path: root.path().to_owned(),
487                    device_id: crossdev::init(root.path()).unwrap(),
488                }],
489                false,
490                walk::Order::Completion,
491            )
492            .filter_map(|(_, event)| match event {
493                walk::RootEvent::Entry(entry) => Some(entry.unwrap().path()),
494                walk::RootEvent::Finished => None,
495            })
496            .collect::<Vec<_>>();
497
498        assert!(paths.contains(&child));
499    }
500
501    #[test]
502    fn ignore_patterns_use_gitignore_semantics() {
503        let patterns = patterns_from(
504            "# a comment, and the blank line below, match nothing\n\
505             \n\
506             *.log\n\
507             !keep.log\n\
508             build/\n\
509             /anchored\n\
510             **/node_modules/\n",
511        );
512
513        for (path, is_dir, expected) in [
514            ("debug.log", false, true),
515            ("nested/debug.log", false, true),
516            ("keep.log", false, false),
517            ("build", true, true),
518            // A trailing '/' in a pattern only ever matches directories.
519            ("build", false, false),
520            ("anchored", true, true),
521            ("nested/anchored", true, false),
522            ("nested/deeply/node_modules", true, true),
523            ("src", true, false),
524            ("", true, false),
525        ] {
526            assert_eq!(
527                patterns.is_excluded(Path::new(path), is_dir),
528                expected,
529                "expected is_excluded({path:?}, is_dir={is_dir}) to be {expected}"
530            );
531        }
532    }
533
534    #[test]
535    fn later_ignore_files_win_over_earlier_ones() {
536        let dir = tempfile::tempdir().unwrap();
537        let (first, second) = (dir.path().join("first"), dir.path().join("second"));
538        std::fs::write(&first, "*.tmp\n").unwrap();
539        std::fs::write(&second, "!important.tmp\n").unwrap();
540
541        let patterns = IgnorePatterns::from_files(&[first, second])
542            .unwrap()
543            .unwrap();
544
545        assert!(patterns.is_excluded(Path::new("scratch.tmp"), false));
546        assert!(
547            !patterns.is_excluded(Path::new("important.tmp"), false),
548            "the negation in the second file overrides the first file"
549        );
550    }
551
552    #[test]
553    fn unreadable_ignore_files_are_an_error() {
554        let err = IgnorePatterns::from_files(&[PathBuf::from("does-not-exist")])
555            .expect_err("a missing pattern file must not be silently skipped");
556        assert!(err.to_string().contains("does-not-exist"));
557    }
558
559    #[test]
560    fn empty_ignore_files_produce_none() {
561        let file = tempfile::NamedTempFile::new().unwrap();
562        std::fs::write(file.path(), "# comment only\n").unwrap();
563        assert!(
564            IgnorePatterns::from_files(&[file.path().to_owned()])
565                .unwrap()
566                .is_none(),
567            "no patterns means no need to match anything"
568        );
569    }
570
571    #[test]
572    fn matching_entries_are_pruned_from_the_walk() {
573        let root = tempfile::tempdir().unwrap();
574        for dir in ["keep", "build", "keep/node_modules"] {
575            std::fs::create_dir(root.path().join(dir)).unwrap();
576        }
577        for file in [
578            "keep/main.rs",
579            "keep/debug.log",
580            "build/artifact",
581            "keep/node_modules/dep",
582        ] {
583            std::fs::write(root.path().join(file), b"x").unwrap();
584        }
585
586        assert_eq!(
587            walk_with_patterns(root.path(), "*.log\n**/node_modules/\n"),
588            [
589                PathBuf::new(),
590                PathBuf::from("build"),
591                PathBuf::from("build/artifact"),
592                PathBuf::from("keep"),
593                PathBuf::from("keep/main.rs"),
594            ],
595            "excluded directories are pruned along with everything below them, \
596             and excluded files never show up"
597        );
598    }
599
600    #[test]
601    fn patterns_match_the_path_dua_reports() {
602        let here = tempfile::tempdir().unwrap();
603        let elsewhere = tempfile::tempdir().unwrap();
604        let (cwd, outside) = (here.path(), elsewhere.path());
605
606        for dir in ["target", "nested", "nested/target"] {
607            std::fs::create_dir_all(here.path().join(dir)).unwrap();
608        }
609        assert_eq!(
610            walk_with_patterns(here.path(), "/target/\n"),
611            [
612                PathBuf::new(),
613                PathBuf::from("nested"),
614                PathBuf::from("nested/target"),
615            ],
616            "an anchored pattern excludes only the top-level target"
617        );
618
619        // Several inputs below the current directory also keep their reported path.
620        let (entry, root) = (cwd.join("a").join("b"), cwd.join("a"));
621        let reported = Path::new("a").join("b");
622        assert_eq!(
623            pattern_relative_path(&entry, cwd, &root),
624            Some(reported.as_path())
625        );
626
627        // Inputs elsewhere have no path relative to the current directory, so they fall back to
628        // being relative to the input they were found under.
629        let entry = outside.join("syslog");
630        assert_eq!(
631            pattern_relative_path(&entry, cwd, outside),
632            Some(Path::new("syslog"))
633        );
634
635        // Such an input is empty relative to itself, and so never matches a pattern.
636        assert_eq!(
637            pattern_relative_path(outside, cwd, outside),
638            Some(Path::new(""))
639        );
640        assert!(!patterns_from("*\n").is_excluded(Path::new(""), true));
641    }
642
643    #[test]
644    fn subtree_walk_keeps_the_original_pattern_root() {
645        let root = tempfile::tempdir().unwrap();
646        let nested = root.path().join("nested");
647        std::fs::create_dir(&nested).unwrap();
648        std::fs::write(nested.join("secret"), []).unwrap();
649        std::fs::write(nested.join("visible"), []).unwrap();
650        let options = WalkOptions {
651            threads: 1,
652            count_hard_links: false,
653            apparent_size: false,
654            cross_filesystems: true,
655            ignore_dirs: BTreeSet::default(),
656            ignore_patterns: Some(patterns_from("nested/secret\n")),
657        };
658
659        let paths = options
660            .iter_from_paths(
661                vec![WalkRoot {
662                    index: 0,
663                    path: nested,
664                    pattern_root: Some(root.path().to_owned()),
665                    device_id: 0,
666                }],
667                false,
668                walk::Order::Completion,
669            )
670            .filter_map(|(_, event)| match event {
671                walk::RootEvent::Entry(entry) => Some(entry.unwrap().file_name),
672                walk::RootEvent::Finished => None,
673            })
674            .collect::<Vec<_>>();
675
676        assert!(!paths.iter().any(|path| path == "secret"));
677        assert!(paths.iter().any(|path| path == "visible"));
678    }
679
680    #[test]
681    fn excluded_input_paths_are_dropped_before_the_walk() {
682        // Relies on the crate directory being current, like `test_ignore_directories` above.
683        let patterns = patterns_from("src/\n*.toml\n");
684        let cwd = std::env::current_dir().unwrap();
685
686        assert!(
687            patterns.excludes_input_path(Path::new("src"), &cwd),
688            "a directory pattern matches a directory given as input"
689        );
690        assert!(patterns.excludes_input_path(Path::new("Cargo.toml"), &cwd));
691        assert!(!patterns.excludes_input_path(Path::new("README.md"), &cwd));
692    }
693
694    fn patterns_from(contents: &str) -> IgnorePatterns {
695        let file = tempfile::NamedTempFile::new().unwrap();
696        std::fs::write(file.path(), contents).unwrap();
697        IgnorePatterns::from_files(&[file.path().to_owned()])
698            .unwrap()
699            .unwrap()
700    }
701
702    fn walk_with_patterns(root: &Path, contents: &str) -> Vec<PathBuf> {
703        let options = WalkOptions {
704            threads: 2,
705            count_hard_links: false,
706            apparent_size: false,
707            cross_filesystems: true,
708            ignore_dirs: BTreeSet::default(),
709            ignore_patterns: Some(patterns_from(contents)),
710        };
711
712        let mut paths = options
713            .iter_from_paths(
714                vec![WalkRoot {
715                    index: 0,
716                    pattern_root: Some(root.to_owned()),
717                    path: root.to_owned(),
718                    device_id: crossdev::init(root).unwrap(),
719                }],
720                false,
721                walk::Order::Completion,
722            )
723            .filter_map(|(_, event)| match event {
724                walk::RootEvent::Entry(entry) => {
725                    Some(entry.unwrap().path().strip_prefix(root).unwrap().to_owned())
726                }
727                walk::RootEvent::Finished => None,
728            })
729            .collect::<Vec<_>>();
730        paths.sort();
731        paths
732    }
733}