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 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}