xvc_walker/
lib.rs

1//! Xvc walker traverses directory trees with ignore rules.
2//!
3//! Ignore rules are similar to [.gitignore](https://git-scm.com/docs/gitignore) and child
4//! directories are not traversed if ignored.
5//!
6//! [walk_parallel] function is the most useful element in this module.
7//! It walks and sends [PathMetadata] through a channel, also updating the ignore rules and sending
8//! them.
9#![warn(missing_docs)]
10#![forbid(unsafe_code)]
11pub mod abspath;
12pub mod error;
13pub mod ignore_rules;
14pub mod notify;
15pub mod pattern;
16pub mod sync;
17pub mod walk_parallel;
18pub mod walk_serial;
19
20pub use pattern::MatchResult;
21pub use pattern::PathKind;
22pub use pattern::Pattern;
23pub use pattern::PatternEffect;
24pub use pattern::PatternRelativity;
25pub use pattern::Source;
26
27pub use walk_parallel::walk_parallel;
28pub use walk_serial::walk_serial;
29
30pub use walk_serial::path_metadata_map_from_file_targets;
31
32pub use abspath::AbsolutePath;
33pub use error::{Error, Result};
34
35pub use ignore_rules::IgnoreRules;
36pub use ignore_rules::SharedIgnoreRules;
37
38pub use std::hash::Hash;
39pub use sync::{PathSync, PathSyncSingleton};
40use xvc_logging::warn;
41
42pub use notify::make_polling_watcher;
43pub use notify::make_watcher;
44pub use notify::PathEvent;
45pub use notify::RecommendedWatcher;
46
47pub use fast_glob::Glob;
48
49use xvc_logging::watch;
50
51use std::{
52    fmt::Debug,
53    fs::{self, Metadata},
54    path::{Path, PathBuf},
55};
56
57use anyhow::anyhow;
58
59static MAX_THREADS_PARALLEL_WALK: usize = 8;
60
61/// Combine a path and its metadata in a single struct
62#[derive(Debug, Clone)]
63pub struct PathMetadata {
64    /// path
65    pub path: PathBuf,
66    /// metadata
67    pub metadata: Metadata,
68}
69
70/// What's the ignore file name and should we add directories to the result?
71#[derive(Debug, Clone)]
72pub struct WalkOptions {
73    /// The ignore filename (`.gitignore`, `.xvcignore`, `.ignore`, etc.) or `None` for not
74    /// ignoring anything.
75    pub ignore_filename: Option<String>,
76    /// Should the results include directories themselves?
77    /// Note that they are always traversed, but may not be listed if we're only interested in
78    /// actual files.
79    pub include_dirs: bool,
80}
81
82impl WalkOptions {
83    /// Instantiate a Git repository walker that uses `.gitignore` as ignore file name and includes
84    /// directories in results.
85    pub fn gitignore() -> Self {
86        Self {
87            ignore_filename: Some(".gitignore".into()),
88            include_dirs: true,
89        }
90    }
91
92    /// Instantiate a Xvc repository walker that uses `.xvcignore` as ignore file name and includes
93    /// directories in results.
94    pub fn xvcignore() -> Self {
95        Self {
96            ignore_filename: Some(".xvcignore".into()),
97            include_dirs: true,
98        }
99    }
100
101    /// Return options with `include_dirs` turned off.
102    /// `WalkOptions::xvcignore().without_dirs()` specifies a `xvcignore` walker that only lists
103    /// files.
104    pub fn without_dirs(self) -> Self {
105        Self {
106            ignore_filename: self.ignore_filename,
107            include_dirs: false,
108        }
109    }
110    /// Return the same option with `include_dirs` turned on.
111    pub fn with_dirs(self) -> Self {
112        Self {
113            ignore_filename: self.ignore_filename,
114            include_dirs: true,
115        }
116    }
117}
118
119/// Build the ignore rules with the given directory
120pub fn build_ignore_patterns(
121    given: &str,
122    ignore_root: &Path,
123    ignore_filename: &str,
124) -> Result<IgnoreRules> {
125    watch!(ignore_filename);
126    watch!(ignore_root);
127
128    let ignore_rules = IgnoreRules::from_global_patterns(ignore_root, Some(ignore_filename), given);
129
130    let dirs_under = |p: &Path| -> Vec<PathBuf> {
131        p.read_dir()
132            .unwrap()
133            .filter_map(|p| {
134                if let Ok(p) = p {
135                    if p.path().is_dir() {
136                        Some(p.path())
137                    } else {
138                        None
139                    }
140                } else {
141                    None
142                }
143            })
144            .filter_map(|p| match ignore_rules.check(&p) {
145                MatchResult::NoMatch | MatchResult::Whitelist => Some(p),
146                MatchResult::Ignore => None,
147            })
148            .collect()
149    };
150
151    let mut dir_stack: Vec<PathBuf> = vec![ignore_root.to_path_buf()];
152
153    let ignore_fn = ignore_rules.ignore_filename.as_deref().unwrap();
154
155    while let Some(dir) = dir_stack.pop() {
156        let ignore_filename = dir.join(ignore_fn);
157        if ignore_filename.is_file() {
158            let ignore_content = fs::read_to_string(&ignore_filename)?;
159            let new_patterns =
160                content_to_patterns(ignore_root, Some(&ignore_filename), &ignore_content);
161            ignore_rules.add_patterns(new_patterns)?;
162        }
163        let mut new_dirs = dirs_under(&dir);
164        dir_stack.append(&mut new_dirs);
165    }
166
167    Ok(ignore_rules)
168}
169
170/// convert a set of rules in `content` to glob patterns.
171/// patterns may come from `source`.
172/// the root directory of all search is in `ignore_root`.
173pub fn content_to_patterns(
174    ignore_root: &Path,
175    source: Option<&Path>,
176    content: &str,
177) -> Vec<Pattern> {
178    let patterns: Vec<Pattern> = content
179        .lines()
180        .enumerate()
181        // A line starting with # serves as a comment. Put a backslash ("\") in front of the first hash for patterns that begin with a hash.
182        .filter(|(_, line)| !(line.trim().is_empty() || line.starts_with('#')))
183        // Trailing spaces are ignored unless they are quoted with backslash ("\").
184        .map(|(i, line)| {
185            if !line.ends_with("\\ ") {
186                (i, line.trim_end())
187            } else {
188                (i, line)
189            }
190        })
191        // if source file is not given, set the source Global
192        .map(|(i, line)| {
193            (
194                line,
195                match source {
196                    Some(p) => Source::File {
197                        path: p
198                            .strip_prefix(ignore_root)
199                            .expect("path must be within ignore_root")
200                            .to_path_buf(),
201                        line: (i + 1),
202                    },
203                    None => Source::Global,
204                },
205            )
206        })
207        .map(|(line, source)| Pattern::new(source, line))
208        .collect();
209
210    patterns
211}
212
213/// Updates the ignore rules from a given directory.
214///
215/// Gets ignore filename from the ignore rules, concatenates it with the directory path and reads
216/// the file if it exists. Then updates the ignore rules with the new patterns.
217pub fn update_ignore_rules(dir: &Path, ignore_rules: &IgnoreRules) -> Result<()> {
218    if let Some(ref ignore_filename) = ignore_rules.ignore_filename {
219        let ignore_root = &ignore_rules.root;
220        let ignore_path = dir.join(ignore_filename);
221        if ignore_path.is_file() {
222            let new_patterns: Vec<Pattern> = {
223                let content = fs::read_to_string(&ignore_path)?;
224                content_to_patterns(ignore_root, Some(ignore_path).as_deref(), &content)
225            };
226
227            ignore_rules.add_patterns(new_patterns)?;
228        }
229    }
230    Ok(())
231}
232/// Return all childs of a directory regardless of any ignore rules
233/// If there is an error to obtain the metadata, error is added to the element instead
234pub fn directory_list(dir: &Path) -> Result<Vec<Result<PathMetadata>>> {
235    let elements = dir
236        .read_dir()
237        .map_err(|e| anyhow!("Error reading directory: {:?}, {:?}", dir, e))?;
238    let mut child_paths = Vec::<Result<PathMetadata>>::new();
239
240    for entry in elements {
241        match entry {
242            Err(err) => child_paths.push(Err(Error::from(anyhow!(
243                "Error reading entry in dir {:?} {:?}",
244                dir,
245                err
246            )))),
247            Ok(entry) => match entry.metadata() {
248                Err(err) => child_paths.push(Err(Error::from(anyhow!(
249                    "Error getting metadata {:?} {:?}",
250                    entry,
251                    err
252                )))),
253                Ok(md) => {
254                    child_paths.push(Ok(PathMetadata {
255                        path: entry.path(),
256                        metadata: md.clone(),
257                    }));
258                }
259            },
260        }
261    }
262    Ok(child_paths)
263}
264
265#[cfg(test)]
266mod tests {
267
268    use super::*;
269
270    use log::LevelFilter;
271    use test_case::test_case;
272
273    use crate::error::Result;
274    use crate::AbsolutePath;
275    use xvc_test_helper::*;
276
277    #[test_case("!mydir/*/file" => matches PatternEffect::Whitelist ; "t1159938339")]
278    #[test_case("!mydir/myfile" => matches PatternEffect::Whitelist ; "t1302522194")]
279    #[test_case("!myfile" => matches PatternEffect::Whitelist ; "t3599739725")]
280    #[test_case("!myfile/" => matches PatternEffect::Whitelist ; "t389990097")]
281    #[test_case("/my/file" => matches PatternEffect::Ignore ; "t3310011546")]
282    #[test_case("mydir/*" => matches PatternEffect::Ignore ; "t1461510927")]
283    #[test_case("mydir/file" => matches PatternEffect::Ignore; "t4096563949")]
284    #[test_case("myfile" => matches PatternEffect::Ignore; "t4042406621")]
285    #[test_case("myfile*" => matches PatternEffect::Ignore ; "t3367706249")]
286    #[test_case("myfile/" => matches PatternEffect::Ignore ; "t1204466627")]
287    fn test_pattern_effect(line: &str) -> PatternEffect {
288        let pat = Pattern::new(Source::Global, line);
289        pat.effect
290    }
291
292    #[test_case("", "!mydir/*/file" => matches PatternRelativity::RelativeTo { directory } if directory.is_empty() ; "t500415168")]
293    #[test_case("", "!mydir/myfile" => matches PatternRelativity::RelativeTo {directory} if directory.is_empty() ; "t1158125354")]
294    #[test_case("dir/", "!mydir/*/file" => matches PatternRelativity::RelativeTo { directory } if directory == "/dir" ; "t3052699971")]
295    #[test_case("dir/", "!mydir/myfile" => matches PatternRelativity::RelativeTo {directory} if directory == "/dir" ; "t885029019")]
296    #[test_case("", "!myfile" => matches PatternRelativity::Anywhere; "t3101661374")]
297    #[test_case("", "!myfile/" => matches PatternRelativity::Anywhere ; "t3954695505")]
298    #[test_case("", "/my/file" => matches PatternRelativity::RelativeTo { directory } if directory.is_empty() ; "t1154256567")]
299    #[test_case("", "mydir/*" => matches PatternRelativity::RelativeTo { directory } if directory.is_empty() ; "t865348822")]
300    #[test_case("", "mydir/file" => matches PatternRelativity::RelativeTo { directory } if directory.is_empty() ; "t809589695")]
301    #[test_case("root/", "/my/file" => matches PatternRelativity::RelativeTo { directory } if directory == "/root" ; "t7154256567")]
302    #[test_case("root/", "mydir/*" => matches PatternRelativity::RelativeTo { directory } if directory == "/root" ; "t765348822")]
303    #[test_case("root/", "mydir/file" => matches PatternRelativity::RelativeTo { directory } if directory == "/root" ; "t709589695")]
304    #[test_case("", "myfile" => matches PatternRelativity::Anywhere; "t949952742")]
305    #[test_case("", "myfile*" => matches PatternRelativity::Anywhere ; "t2212007572")]
306    #[test_case("", "myfile/" => matches PatternRelativity::Anywhere; "t900104620")]
307    fn test_pattern_relativity(dir: &str, line: &str) -> PatternRelativity {
308        let source = Source::File {
309            path: PathBuf::from(dir).join(".gitignore"),
310            line: 1,
311        };
312        let pat = Pattern::new(source, line);
313        pat.relativity
314    }
315
316    #[test_case("", "!mydir/*/file" => matches PathKind::Any ; "t4069397926")]
317    #[test_case("", "!mydir/myfile" => matches PathKind::Any ; "t206435934")]
318    #[test_case("", "!myfile" => matches PathKind::Any ; "t4262638148")]
319    #[test_case("", "!myfile/" => matches PathKind::Directory ; "t214237847")]
320    #[test_case("", "/my/file" => matches PathKind::Any ; "t187692643")]
321    #[test_case("", "mydir/*" => matches PathKind::Any ; "t1159784957")]
322    #[test_case("", "mydir/file" => matches PathKind::Any ; "t2011171465")]
323    #[test_case("", "myfile" => matches PathKind::Any ; "t167946945")]
324    #[test_case("", "myfile*" => matches PathKind::Any ; "t3091563211")]
325    #[test_case("", "myfile/" => matches PathKind::Directory ; "t1443554623")]
326    fn test_path_kind(dir: &str, line: &str) -> PathKind {
327        let source = Source::File {
328            path: PathBuf::from(dir).join(".gitignore"),
329            line: 1,
330        };
331        let pat = Pattern::new(source, line);
332        pat.path_kind
333    }
334
335    #[test_case("" => 0)]
336    #[test_case("myfile" => 1)]
337    #[test_case("mydir/myfile" => 1)]
338    #[test_case("mydir/myfile\n!myfile" => 2)]
339    #[test_case("mydir/myfile\n/another" => 2)]
340    #[test_case("mydir/myfile\n\n\nanother" => 2)]
341    #[test_case("#comment\nmydir/myfile\n\n\nanother" => 2)]
342    #[test_case("#mydir/myfile" => 0)]
343    fn test_content_to_patterns_count(contents: &str) -> usize {
344        let patterns = content_to_patterns(Path::new(""), None, contents);
345        patterns.len()
346    }
347
348    fn create_patterns(root: &str, dir: Option<&str>, patterns: &str) -> Vec<Pattern> {
349        content_to_patterns(Path::new(root), dir.map(Path::new), patterns)
350    }
351
352    fn new_dir_with_ignores(
353        root: &str,
354        dir: Option<&str>,
355        initial_patterns: &str,
356    ) -> Result<IgnoreRules> {
357        let patterns = create_patterns(root, dir, initial_patterns);
358        let initialized = IgnoreRules::empty(&PathBuf::from(root), None);
359
360        initialized.add_patterns(patterns)?;
361        Ok(initialized)
362    }
363
364    #[test_case(".", "" ; "empty_dwi")]
365    #[test_case("dir", "myfile")]
366    #[test_case("dir", "mydir/myfile")]
367    #[test_case("dir", "mydir/myfile\n!myfile")]
368    #[test_case("dir", "mydir/myfile\n/another")]
369    #[test_case("dir", "mydir/myfile\n\n\nanother")]
370    #[test_case("dir", "#comment\nmydir/myfile\n\n\nanother")]
371    #[test_case("dir", "#mydir/myfile" ; "single ignored lined")]
372    fn test_dir_with_ignores(dir: &str, contents: &str) {
373        new_dir_with_ignores(dir, None, contents).unwrap();
374    }
375
376    #[test_case("/dir", "/mydir/myfile/" => matches PatternRelativity::RelativeTo { directory } if directory == "/dir" ; "t868594159")]
377    #[test_case("/dir", "mydir" => matches PatternRelativity::Anywhere ; "t4030766779")]
378    #[test_case("/dir/", "mydir/myfile" => matches PatternRelativity::RelativeTo { directory } if directory == "/dir" ; "t2043231107")]
379    #[test_case("dir", "myfile" => matches PatternRelativity::Anywhere; "t871610344" )]
380    #[test_case("dir/", "mydir/myfile" => matches PatternRelativity::RelativeTo { directory } if directory == "/dir" ; "t21398102")]
381    #[test_case("dir/", "myfile" => matches PatternRelativity::Anywhere ; "t1846637197")]
382    #[test_case("dir//", "/mydir/myfile" => matches PatternRelativity::RelativeTo { directory } if directory == "/dir" ; "t2556287848")]
383    fn test_path_relativity(dir: &str, pattern: &str) -> PatternRelativity {
384        let source = Source::File {
385            path: PathBuf::from(format!("{dir}/.gitignore")),
386            line: 1,
387        };
388        let pattern = Pattern::new(source, pattern);
389        pattern.relativity
390    }
391    // ---- tests::test_pattern_line::t1242345310 stdout ----
392    // thread 'tests::test_pattern_line::t1242345310' panicked at walker/src/lib.rs:391:5:
393    // assertion `left == right` failed
394    //   left: "myfile"
395    //  right: "**/myfile"
396    //
397    // ---- tests::test_pattern_line::t1142345310 stdout ----
398    // thread 'tests::test_pattern_line::t1142345310' panicked at walker/src/lib.rs:391:5:
399    // assertion `left == right` failed
400    //   left: "myfile"
401    //  right: "**/myfile"
402    // note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
403    //
404    // ---- tests::test_pattern_line::t1427001291 stdout ----
405    // thread 'tests::test_pattern_line::t1427001291' panicked at walker/src/lib.rs:391:5:
406    // assertion `left == right` failed
407    //   left: "myfile"
408    //  right: "/**/myfile"
409    //
410    // ---- tests::test_pattern_line::t21199018562 stdout ----
411    // thread 'tests::test_pattern_line::t21199018562' panicked at walker/src/lib.rs:391:5:
412    // assertion `left == right` failed
413    //   left: "mydir/myfile"
414    //  right: "/**/mydir/myfile"
415    //
416    // ---- tests::test_pattern_line::t21199018162 stdout ----
417    // thread 'tests::test_pattern_line::t21199018162' panicked at walker/src/lib.rs:391:5:
418    // assertion `left == right` failed
419    //   left: "mydir/myfile"
420    //  right: "/**/mydir/myfile"
421    //
422    // ---- tests::test_pattern_line::t31199018162 stdout ----
423    // thread 'tests::test_pattern_line::t31199018162' panicked at walker/src/lib.rs:391:5:
424    // assertion `left == right` failed
425    //   left: "myfile.*"
426    //  right: "**/myfile.*"
427    //
428    #[test_case("dir", "myfile" => "**/myfile" ; "t1242345310")]
429    #[test_case("dir", "/myfile" => "/**/myfile" ; "t3427001291")]
430    #[test_case("dir", "myfile/" => "**/myfile/**" ; "t759151905")]
431    #[test_case("dir", "mydir/myfile" => "/**/mydir/myfile" ; "t21199018562")]
432    #[test_case("dir", "/my/file.*" => "/**/my/file.*" ; "t61199018162")]
433    #[test_case("dir", "/mydir/**.*" => "/**/mydir/**.*" ; "t47199018162")]
434    fn test_pattern_line(dir: &str, pattern: &str) -> String {
435        let source = Source::File {
436            path: PathBuf::from(format!("{dir}.gitignore")),
437            line: 1,
438        };
439        let pattern = Pattern::new(source, pattern);
440        pattern.glob
441    }
442
443    // Blank file tests
444    #[test_case("", "#mydir/myfile", ""  => matches MatchResult::NoMatch ; "t01")]
445    #[test_case("", "", ""  => matches MatchResult::NoMatch ; "t02" )]
446    #[test_case("", "\n\n  \n", ""  => matches MatchResult::NoMatch; "t03"  )]
447    #[test_case("", "dir-0001", ""  => matches MatchResult::NoMatch ; "t04" )]
448    #[test_case("", "dir-0001/file-0001.bin", ""  => matches MatchResult::NoMatch ; "t05" )]
449    #[test_case("", "dir-0001/*", ""  => matches MatchResult::NoMatch ; "t06" )]
450    #[test_case("", "dir-0001/**", ""  => matches MatchResult::NoMatch ; "t07" )]
451    #[test_case("", "dir-0001/dir-0001**", ""  => matches MatchResult::NoMatch ; "t08" )]
452    #[test_case("", "dir-0001/dir-00*", ""  => matches MatchResult::NoMatch ; "t09" )]
453    #[test_case("", "dir-00**/", ""  => matches MatchResult::NoMatch ; "t10" )]
454    #[test_case("", "dir-00**/*/file-0001.bin", ""  => matches MatchResult::NoMatch ; "t11" )]
455    #[test_case("", "dir-00**/*/*.bin", ""  => matches MatchResult::NoMatch ; "t12" )]
456    #[test_case("", "dir-00**/", ""  => matches MatchResult::NoMatch ; "t13" )]
457    #[test_case("", "#mydir/myfile", ""  => matches MatchResult::NoMatch ; "t148864489901")]
458    // No Match Tests
459    #[test_case("", "", "dir-0001/file-0002.bin"  => matches MatchResult::NoMatch ; "t172475356002" )]
460    #[test_case("", "\n\n  \n", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch; "t8688937603"  )]
461    #[test_case("", "dir-0001", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t132833780304" )]
462    #[test_case("", "dir-0001/file-0001.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t173193800505" )]
463    #[test_case("", "dir-0001/dir-0001**", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t318664043308" )]
464    #[test_case("", "dir-0001/dir-00*", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t269908728009" )]
465    #[test_case("", "dir-00**/*/file-0001.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t142240004811" )]
466    #[test_case("", "dir-00**/*/*.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t414921892712" )]
467    #[test_case("", "dir-00**/", "dir-0001/file-0002.bin" => matches MatchResult::Ignore; "t256322548613" )]
468    // Ignore tests
469    #[test_case("", "dir-0001/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t3378553489" )]
470    #[test_case("", "dir-0001/file-0001.*", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t3449646229" )]
471    #[test_case("", "dir-0001/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t1232001745" )]
472    #[test_case("", "dir-0001/*", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t2291655464" )]
473    #[test_case("", "dir-0001/**/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t355659763" )]
474    #[test_case("", "dir-0001/**", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t1888678340" )]
475    #[test_case("", "dir-000?/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t1603222532" )]
476    #[test_case("", "dir-000?/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t2528090273" )]
477    #[test_case("", "dir-*/*", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t3141482339" )]
478    // Whitelist Tests
479    #[test_case("", "!dir-0001", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t2963495371" )]
480    #[test_case("", "!dir-0001/file-0001.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t3935333051" )]
481    #[test_case("", "!dir-0001/dir-0001**", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t3536143628" )]
482    #[test_case("", "!dir-0001/dir-00*", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t4079058836" )]
483    #[test_case("", "!dir-00**/", "dir-0001/file-0002.bin" => matches MatchResult::Whitelist ; "t3713155445" )]
484    #[test_case("", "!dir-00**/*/file-0001.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t1434153118" )]
485    #[test_case("", "!dir-00**/*/*.bin", "dir-0001/file-0002.bin" => matches MatchResult::NoMatch ; "t1650195998" )]
486    #[test_case("", "!dir-0001/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t1569068369" )]
487    #[test_case("", "!dir-0001/file-0001.*", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t2919165396" )]
488    #[test_case("", "!dir-0001/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t2682012728" )]
489    #[test_case("", "!dir-0001/*", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t4009543743" )]
490    #[test_case("", "!dir-0001/**/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t3333689486" )]
491    #[test_case("", "!dir-0001/**", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t4259364613" )]
492    #[test_case("", "!dir-000?/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t3424909626" )]
493    #[test_case("", "!dir-000?/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t3741545053" )]
494    #[test_case("", "!dir-*/*", "dir-0001/file-0001.bin" => matches MatchResult::Whitelist ; "t1793504005" )]
495    // Ignore in child dir
496    #[test_case("dir-0001", "/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t1295565113" )]
497    #[test_case("dir-0001", "/file-0001.*", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t4048655621" )]
498    #[test_case("dir-0001", "/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t2580936986" )]
499    #[test_case("dir-0001", "/*", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t109602877" )]
500    #[test_case("dir-0001", "/**/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t112292599" )]
501    #[test_case("dir-0001", "/**", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t1323958164" )]
502    #[test_case("dir-0001", "/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t4225367752" )]
503    #[test_case("dir-0001", "/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::Ignore ; "t3478922394" )]
504    // NoMatch in child_dir
505    #[test_case("dir-0002", "/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t345532514" )]
506    #[test_case("dir-0002", "/file-0001.*", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t1313276210" )]
507    #[test_case("dir-0002", "/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t657078396" )]
508    #[test_case("dir-0002", "/*", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t2456576806" )]
509    #[test_case("dir-0002", "/**/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t2629832143" )]
510    #[test_case("dir-0002", "/**", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t2090580478" )]
511    #[test_case("dir-0002", "/file-0001.bin", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t1588943529" )]
512    #[test_case("dir-0002", "/*.bin", "dir-0001/file-0001.bin" => matches MatchResult::NoMatch ; "t371313784" )]
513    fn test_match_result(dir: &str, contents: &str, path: &str) -> MatchResult {
514        test_logging(LevelFilter::Trace);
515
516        let root = create_directory_hierarchy(false).unwrap();
517        let source_file = format!("{root}/{dir}/.gitignore");
518        let path = root.as_ref().join(path).to_owned();
519        let dwi =
520            new_dir_with_ignores(root.to_str().unwrap(), Some(&source_file), contents).unwrap();
521
522        dwi.check(&path)
523    }
524
525    // TODO: Patterns shouldn't have / prefix, but an appropriate PathKind
526    #[test_case(true => matches Ok(_); "this is to refresh the dir for each test run")]
527    // This builds a directory hierarchy to run the tests
528    fn create_directory_hierarchy(force: bool) -> Result<AbsolutePath> {
529        let temp_dir: PathBuf = seeded_temp_dir("xvc-walker", Some(20220615));
530
531        if force && temp_dir.exists() {
532            fs::remove_dir_all(&temp_dir)?;
533        }
534
535        if !temp_dir.exists() {
536            // in parallel tests, sometimes this fail
537            fs::create_dir(&temp_dir)?;
538            create_directory_tree(&temp_dir, 10, 10, 1000, None)?;
539            // root/dir1 may have another tree
540            let level_1 = &temp_dir.join("dir-0001");
541            create_directory_tree(level_1, 10, 10, 1000, None)?;
542            // and another level
543            let level_2 = &level_1.join("dir-0001");
544            create_directory_tree(level_2, 10, 10, 1000, None)?;
545        }
546
547        Ok(AbsolutePath::from(temp_dir))
548    }
549}