Skip to main content

walker/
walker.rs

1use crate::compiled_glob::CompiledGlob;
2use std::fmt;
3use std::io;
4use std::path::PathBuf;
5use tokio::sync::mpsc;
6
7#[cfg(not(windows))]
8#[path = "walker_unix.rs"]
9mod backend;
10#[cfg(windows)]
11#[path = "walker_windows.rs"]
12mod backend;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum EntryKind {
16    File,
17    Dir,
18    Symlink,
19    Other,
20}
21
22#[derive(Debug)]
23pub struct WalkEvent {
24    pub path: PathBuf,
25    pub kind: EntryKind,
26}
27
28#[derive(Debug)]
29pub enum WalkError {
30    Io {
31        path: PathBuf,
32        source: io::Error,
33    },
34    Unsupported {
35        feature: &'static str,
36        path: PathBuf,
37    },
38}
39
40impl fmt::Display for WalkError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            WalkError::Io { path, source } => {
44                write!(f, "io error at {}: {source}", path.display())
45            }
46            WalkError::Unsupported { feature, path } => {
47                write!(f, "unsupported feature `{feature}` at {}", path.display())
48            }
49        }
50    }
51}
52
53impl std::error::Error for WalkError {}
54
55pub type WalkMessage = Result<WalkEvent, WalkError>;
56
57#[derive(Clone, Debug)]
58pub struct WalkerOptions {
59    pub channel_capacity: usize,
60    pub files_only: bool,
61}
62
63impl Default for WalkerOptions {
64    fn default() -> Self {
65        Self {
66            channel_capacity: 1024,
67            files_only: false,
68        }
69    }
70}
71
72pub struct Walker;
73
74impl Walker {
75    pub fn spawn(compiled: CompiledGlob) -> mpsc::Receiver<WalkMessage> {
76        Self::spawn_with_options(compiled, WalkerOptions::default())
77    }
78
79    pub fn spawn_many(
80        globs: impl IntoIterator<Item = CompiledGlob>,
81    ) -> mpsc::Receiver<WalkMessage> {
82        Self::spawn_many_with_options(globs, WalkerOptions::default())
83    }
84
85    pub fn spawn_with_options(
86        compiled: CompiledGlob,
87        options: WalkerOptions,
88    ) -> mpsc::Receiver<WalkMessage> {
89        Self::spawn_many_with_options([compiled], options)
90    }
91
92    pub fn spawn_many_with_options(
93        globs: impl IntoIterator<Item = CompiledGlob>,
94        options: WalkerOptions,
95    ) -> mpsc::Receiver<WalkMessage> {
96        let merged = match CompiledGlob::merge_many(globs) {
97            Ok(merged) => merged,
98            Err(err) => {
99                let (tx, rx) = mpsc::channel(options.channel_capacity.max(1));
100                tokio::spawn(async move {
101                    let _ = tx
102                        .send(Err(WalkError::Io {
103                            path: PathBuf::from("<spawn_many>"),
104                            source: err,
105                        }))
106                        .await;
107                });
108                return rx;
109            }
110        };
111
112        backend::spawn_single_with_options(merged, options)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    #[cfg(all(unix, not(windows)))]
119    use super::*;
120    use crate::compiled_glob::CompiledGlob;
121    #[cfg(all(unix, not(windows)))]
122    use std::collections::BTreeSet;
123    #[cfg(all(unix, not(windows)))]
124    use std::fs;
125    #[cfg(all(unix, not(windows)))]
126    use std::path::PathBuf;
127    #[cfg(all(unix, not(windows)))]
128    use std::time::Duration;
129    #[cfg(all(unix, not(windows)))]
130    use std::time::{SystemTime, UNIX_EPOCH};
131
132    #[cfg(all(unix, not(windows)))]
133    fn test_root(name: &str) -> PathBuf {
134        let stamp = SystemTime::now()
135            .duration_since(UNIX_EPOCH)
136            .expect("clock should be valid")
137            .as_nanos();
138        std::env::temp_dir().join(format!("walker-{name}-{stamp}"))
139    }
140
141    #[tokio::test]
142    async fn descend_match_equivalence() {
143        let one = CompiledGlob::new("a/**/**/b").expect("glob must parse");
144        let two = CompiledGlob::new("a/**/b").expect("glob must parse");
145        for path in ["a/b", "a/x/b", "a/x/y/b", "a/x/y/c", "x/a/b", "a/x/y/z"] {
146            assert_eq!(one.r#match(path.as_ref()), two.r#match(path.as_ref()));
147        }
148    }
149
150    #[tokio::test]
151    #[cfg(all(unix, not(windows)))]
152    async fn streams_results_before_full_walk() {
153        let root = test_root("stream");
154        fs::create_dir_all(root.join("d1/d2")).expect("create tree");
155        fs::write(root.join("d1/a.txt"), b"a").expect("write file");
156        fs::write(root.join("d1/d2/b.txt"), b"b").expect("write file");
157
158        let pattern = format!("{}/**", root.display());
159        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
160        let mut rx = Walker::spawn(glob);
161
162        let first = tokio::time::timeout(Duration::from_secs(2), rx.recv())
163            .await
164            .expect("must receive quickly");
165        assert!(first.is_some());
166
167        let _ = fs::remove_dir_all(&root);
168    }
169
170    #[tokio::test]
171    #[cfg(all(unix, not(windows)))]
172    async fn finds_expected_paths() {
173        let root = test_root("paths");
174        fs::create_dir_all(root.join("src/bin")).expect("create tree");
175        fs::create_dir_all(root.join("docs")).expect("create tree");
176        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
177        fs::write(root.join("src/bin/tool.rs"), b"fn main(){}").expect("write file");
178        fs::write(root.join("docs/readme.md"), b"# hi").expect("write file");
179
180        let pattern = format!("{}/**/*.rs", root.display());
181        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
182        let mut rx = Walker::spawn(glob);
183
184        let mut got = BTreeSet::new();
185        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
186            .await
187            .expect("channel should respond")
188        {
189            if let Ok(ev) = msg {
190                got.insert(
191                    ev.path
192                        .strip_prefix(&root)
193                        .expect("path under root")
194                        .to_path_buf(),
195                );
196            }
197        }
198
199        let expected: BTreeSet<PathBuf> = ["src/main.rs", "src/bin/tool.rs"]
200            .iter()
201            .map(PathBuf::from)
202            .collect();
203        assert_eq!(got, expected);
204        let _ = fs::remove_dir_all(&root);
205    }
206
207    #[tokio::test]
208    #[cfg(all(unix, not(windows)))]
209    async fn symlink_loop_does_not_hang() {
210        use std::os::unix::fs::symlink;
211
212        let root = test_root("loop");
213        fs::create_dir_all(root.join("a/b")).expect("create tree");
214        fs::write(root.join("a/b/file.txt"), b"1").expect("write file");
215        symlink(root.join("a"), root.join("a/b/link_to_a")).expect("create symlink");
216
217        let pattern = format!("{}/**", root.display());
218        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
219        let mut rx = Walker::spawn(glob);
220
221        let mut count = 0usize;
222        while let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(250), rx.recv()).await {
223            count += 1;
224            if count > 64 {
225                break;
226            }
227        }
228        assert!(count > 0);
229        assert!(count <= 64);
230
231        let _ = fs::remove_dir_all(&root);
232    }
233
234    #[tokio::test]
235    #[cfg(all(unix, not(windows)))]
236    async fn permission_denied_does_not_abort_descend_walk() {
237        use std::os::unix::fs::PermissionsExt;
238
239        let root = test_root("perm");
240        fs::create_dir_all(root.join("ok")).expect("create tree");
241        fs::create_dir_all(root.join("blocked")).expect("create tree");
242        fs::write(root.join("ok/keep.rs"), b"fn main(){}").expect("write file");
243        fs::set_permissions(root.join("blocked"), fs::Permissions::from_mode(0o0))
244            .expect("chmod blocked");
245
246        let pattern = format!("{}/**.rs", root.display());
247        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
248        let mut rx = Walker::spawn(glob);
249
250        let mut got_ok = false;
251        while let Some(msg) = tokio::time::timeout(Duration::from_secs(3), rx.recv())
252            .await
253            .expect("channel should respond")
254        {
255            match msg {
256                Ok(ev) => {
257                    if ev.path == root.join("ok/keep.rs") {
258                        got_ok = true;
259                    }
260                }
261                Err(WalkError::Io { .. }) => {}
262                Err(WalkError::Unsupported { .. }) => {}
263            }
264        }
265
266        assert!(got_ok, "accessible matches should still be emitted");
267
268        fs::set_permissions(root.join("blocked"), fs::Permissions::from_mode(0o755))
269            .expect("restore perms");
270        let _ = fs::remove_dir_all(&root);
271    }
272
273    #[tokio::test]
274    #[cfg(all(unix, not(windows)))]
275    async fn spawn_many_matches_union_of_patterns() {
276        let root = test_root("many_union");
277        fs::create_dir_all(root.join("src")).expect("create tree");
278        fs::create_dir_all(root.join("docs")).expect("create tree");
279        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
280        fs::write(root.join("docs/readme.md"), b"# hi").expect("write file");
281
282        let g1 =
283            CompiledGlob::new(&format!("{}/**/*.rs", root.display())).expect("glob must parse");
284        let g2 =
285            CompiledGlob::new(&format!("{}/**/*.md", root.display())).expect("glob must parse");
286        let mut rx = Walker::spawn_many(vec![g1, g2]);
287
288        let mut got = BTreeSet::new();
289        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
290            .await
291            .expect("channel should respond")
292        {
293            if let Ok(ev) = msg {
294                got.insert(
295                    ev.path
296                        .strip_prefix(&root)
297                        .expect("path under root")
298                        .to_path_buf(),
299                );
300            }
301        }
302
303        let expected: BTreeSet<PathBuf> = ["src/main.rs", "docs/readme.md"]
304            .iter()
305            .map(PathBuf::from)
306            .collect();
307        assert_eq!(got, expected);
308        let _ = fs::remove_dir_all(&root);
309    }
310
311    #[tokio::test]
312    #[cfg(all(unix, not(windows)))]
313    async fn spawn_many_single_equivalent_to_spawn() {
314        let root = test_root("many_single");
315        fs::create_dir_all(root.join("src/bin")).expect("create tree");
316        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
317        fs::write(root.join("src/bin/tool.rs"), b"fn main(){}").expect("write file");
318
319        let pattern = format!("{}/**/*.rs", root.display());
320        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
321
322        let mut single_rx = Walker::spawn(CompiledGlob::new(&pattern).expect("glob must parse"));
323        let mut many_rx = Walker::spawn_many(vec![glob]);
324
325        let mut single = BTreeSet::new();
326        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), single_rx.recv())
327            .await
328            .expect("channel should respond")
329        {
330            if let Ok(ev) = msg {
331                single.insert(
332                    ev.path
333                        .strip_prefix(&root)
334                        .expect("path under root")
335                        .to_path_buf(),
336                );
337            }
338        }
339
340        let mut many = BTreeSet::new();
341        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), many_rx.recv())
342            .await
343            .expect("channel should respond")
344        {
345            if let Ok(ev) = msg {
346                many.insert(
347                    ev.path
348                        .strip_prefix(&root)
349                        .expect("path under root")
350                        .to_path_buf(),
351                );
352            }
353        }
354
355        assert_eq!(single, many);
356        let _ = fs::remove_dir_all(&root);
357    }
358
359    #[tokio::test]
360    #[cfg(all(unix, not(windows)))]
361    async fn spawn_many_duplicate_patterns_no_duplicate_path() {
362        let root = test_root("many_dup");
363        fs::create_dir_all(root.join("src")).expect("create tree");
364        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
365
366        let pattern = format!("{}/**/*.rs", root.display());
367        let g1 = CompiledGlob::new(&pattern).expect("glob must parse");
368        let g2 = CompiledGlob::new(&pattern).expect("glob must parse");
369        let mut rx = Walker::spawn_many(vec![g1, g2]);
370
371        let mut count = 0usize;
372        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
373            .await
374            .expect("channel should respond")
375        {
376            if let Ok(ev) = msg
377                && ev.path.ends_with("src/main.rs")
378            {
379                count += 1;
380            }
381        }
382
383        assert_eq!(count, 1);
384        let _ = fs::remove_dir_all(&root);
385    }
386
387    #[tokio::test]
388    #[cfg(all(unix, not(windows)))]
389    async fn spawn_many_applies_last_match_wins_with_excludes() {
390        let root = test_root("many_exclude");
391        fs::create_dir_all(root.join("target")).expect("create tree");
392        fs::write(root.join("target/keep.txt"), b"x").expect("write file");
393        fs::write(root.join("target/ignore.txt"), b"x").expect("write file");
394
395        let include =
396            CompiledGlob::new(&format!("{}/**/*.txt", root.display())).expect("glob must parse");
397        let exclude = CompiledGlob::new(&format!("!{}/**/ignore.txt", root.display()))
398            .expect("glob must parse");
399        let reinclude = CompiledGlob::new(&format!("{}/**/ignore.txt", root.display()))
400            .expect("glob must parse");
401        let mut rx = Walker::spawn_many(vec![include, exclude, reinclude]);
402
403        let mut got = BTreeSet::new();
404        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
405            .await
406            .expect("channel should respond")
407        {
408            if let Ok(ev) = msg {
409                got.insert(
410                    ev.path
411                        .strip_prefix(&root)
412                        .expect("path under root")
413                        .to_path_buf(),
414                );
415            }
416        }
417
418        let expected: BTreeSet<PathBuf> = ["target/keep.txt", "target/ignore.txt"]
419            .iter()
420            .map(PathBuf::from)
421            .collect();
422        assert_eq!(got, expected);
423        let _ = fs::remove_dir_all(&root);
424    }
425
426    #[tokio::test]
427    #[cfg(all(unix, not(windows)))]
428    async fn files_only_does_not_emit_directories() {
429        let root = test_root("files_only");
430        fs::create_dir_all(root.join("src/bin")).expect("create tree");
431        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
432        fs::write(root.join("src/bin/tool.rs"), b"fn main(){}").expect("write file");
433
434        let pattern = format!("{}/**", root.display());
435        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
436        let options = WalkerOptions {
437            files_only: true,
438            ..WalkerOptions::default()
439        };
440        let mut rx = Walker::spawn_with_options(glob, options);
441
442        let mut got = BTreeSet::new();
443        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
444            .await
445            .expect("channel should respond")
446        {
447            if let Ok(ev) = msg {
448                assert_eq!(ev.kind, EntryKind::File);
449                got.insert(
450                    ev.path
451                        .strip_prefix(&root)
452                        .expect("path under root")
453                        .to_path_buf(),
454                );
455            }
456        }
457
458        let expected: BTreeSet<PathBuf> = ["src/main.rs", "src/bin/tool.rs"]
459            .iter()
460            .map(PathBuf::from)
461            .collect();
462        assert_eq!(got, expected);
463        let _ = fs::remove_dir_all(&root);
464    }
465
466    #[tokio::test]
467    #[cfg(all(unix, not(windows)))]
468    async fn shard_capacity_does_not_drop_late_directories() {
469        let root = test_root("shard_capacity");
470        for idx in 0..10usize {
471            let dir = root.join(format!("d{idx:02}"));
472            fs::create_dir_all(&dir).expect("create dir");
473            fs::write(dir.join("file.txt"), b"x").expect("write file");
474        }
475
476        let pattern = format!("{}/**/file.txt", root.display());
477        let glob = CompiledGlob::new(&pattern).expect("glob must parse");
478        let options = WalkerOptions {
479            ..WalkerOptions::default()
480        };
481        let mut rx = Walker::spawn_with_options(glob, options);
482
483        let mut got = BTreeSet::new();
484        while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
485            .await
486            .expect("channel should respond")
487        {
488            if let Ok(ev) = msg {
489                got.insert(
490                    ev.path
491                        .strip_prefix(&root)
492                        .expect("path under root")
493                        .to_path_buf(),
494                );
495            }
496        }
497
498        let expected: BTreeSet<PathBuf> = (0..10usize)
499            .map(|idx| PathBuf::from(format!("d{idx:02}/file.txt")))
500            .collect();
501        assert_eq!(got, expected);
502        let _ = fs::remove_dir_all(&root);
503    }
504
505    #[tokio::test]
506    #[cfg(all(unix, not(windows)))]
507    async fn dropping_receiver_terminates_run_promptly() {
508        let root = test_root("drop_rx");
509        fs::create_dir_all(root.join("d1/d2/d3")).expect("create tree");
510        for idx in 0..200usize {
511            fs::write(root.join(format!("d1/d2/d3/file-{idx}.txt")), b"x").expect("write file");
512        }
513
514        let glob = CompiledGlob::new(&format!("{}/**", root.display())).expect("glob must parse");
515        for _ in 0..40usize {
516            let rx = Walker::spawn(glob.clone());
517            drop(rx);
518        }
519
520        // If background tasks fail to terminate, this timeout tends to trip.
521        tokio::time::timeout(std::time::Duration::from_secs(2), async {
522            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
523        })
524        .await
525        .expect("drop should not stall runtime");
526
527        let _ = fs::remove_dir_all(&root);
528    }
529
530    #[tokio::test]
531    #[cfg(all(unix, not(windows)))]
532    async fn repeated_spawn_with_completion_is_stable() {
533        let root = test_root("repeat_spawn");
534        fs::create_dir_all(root.join("src/bin")).expect("create tree");
535        fs::write(root.join("src/main.rs"), b"fn main(){}").expect("write file");
536        fs::write(root.join("src/bin/tool.rs"), b"fn main(){}").expect("write file");
537        let glob =
538            CompiledGlob::new(&format!("{}/**/*.rs", root.display())).expect("glob must parse");
539
540        for _ in 0..30usize {
541            let mut rx = Walker::spawn(glob.clone());
542            let mut files = 0usize;
543            while let Some(msg) = tokio::time::timeout(Duration::from_secs(2), rx.recv())
544                .await
545                .expect("channel should respond")
546            {
547                if let Ok(ev) = msg
548                    && ev.kind == EntryKind::File
549                {
550                    files += 1;
551                }
552            }
553            assert!(files > 0, "at least one file event should be produced");
554        }
555
556        let _ = fs::remove_dir_all(&root);
557    }
558}