1use std::collections::HashSet;
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::{WalkerDirEntry, WalkerError, WalkerFs};
11use crate::glob_path::GlobPath;
12use crate::ignore::IgnoreFilter;
13use crate::filter::IncludeExclude;
14
15#[derive(Debug, Clone, Copy, Default)]
17pub struct EntryTypes {
18 pub files: bool,
20 pub dirs: bool,
22}
23
24impl EntryTypes {
25 pub fn files_only() -> Self {
27 Self {
28 files: true,
29 dirs: false,
30 }
31 }
32
33 pub fn dirs_only() -> Self {
35 Self {
36 files: false,
37 dirs: true,
38 }
39 }
40
41 pub fn all() -> Self {
43 Self {
44 files: true,
45 dirs: true,
46 }
47 }
48}
49
50pub type ErrorCallback = Arc<dyn Fn(&Path, &WalkerError) + Send + Sync>;
55
56pub struct WalkOptions {
58 pub max_depth: Option<usize>,
60 pub min_depth: Option<usize>,
64 pub max_filesize: Option<u64>,
68 pub entry_types: EntryTypes,
70 pub respect_gitignore: bool,
72 pub include_hidden: bool,
74 pub filter: IncludeExclude,
76 pub follow_symlinks: bool,
80 pub on_error: Option<ErrorCallback>,
83 pub types: Option<Arc<ignore::types::Types>>,
87}
88
89impl fmt::Debug for WalkOptions {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.debug_struct("WalkOptions")
92 .field("max_depth", &self.max_depth)
93 .field("min_depth", &self.min_depth)
94 .field("max_filesize", &self.max_filesize)
95 .field("entry_types", &self.entry_types)
96 .field("respect_gitignore", &self.respect_gitignore)
97 .field("include_hidden", &self.include_hidden)
98 .field("filter", &self.filter)
99 .field("follow_symlinks", &self.follow_symlinks)
100 .field("on_error", &self.on_error.as_ref().map(|_| "..."))
101 .field("types", &self.types.as_ref().map(|_| "..."))
102 .finish()
103 }
104}
105
106impl Clone for WalkOptions {
107 fn clone(&self) -> Self {
108 Self {
109 max_depth: self.max_depth,
110 min_depth: self.min_depth,
111 max_filesize: self.max_filesize,
112 entry_types: self.entry_types,
113 respect_gitignore: self.respect_gitignore,
114 include_hidden: self.include_hidden,
115 filter: self.filter.clone(),
116 follow_symlinks: self.follow_symlinks,
117 on_error: self.on_error.clone(),
118 types: self.types.clone(),
119 }
120 }
121}
122
123impl Default for WalkOptions {
124 fn default() -> Self {
125 Self {
126 max_depth: None,
127 min_depth: None,
128 max_filesize: None,
129 entry_types: EntryTypes::files_only(),
130 respect_gitignore: true,
131 include_hidden: false,
132 filter: IncludeExclude::new(),
133 follow_symlinks: false,
134 on_error: None,
135 types: None,
136 }
137 }
138}
139
140pub struct FileWalker<'a, F: WalkerFs> {
153 fs: &'a F,
154 root: PathBuf,
155 pattern: Option<GlobPath>,
156 options: WalkOptions,
157 ignore_filter: Option<IgnoreFilter>,
158}
159
160impl<'a, F: WalkerFs> FileWalker<'a, F> {
161 pub fn new(fs: &'a F, root: impl AsRef<Path>) -> Self {
163 Self {
164 fs,
165 root: root.as_ref().to_path_buf(),
166 pattern: None,
167 options: WalkOptions::default(),
168 ignore_filter: None,
169 }
170 }
171
172 pub fn with_pattern(mut self, pattern: GlobPath) -> Self {
174 self.pattern = Some(pattern);
175 self
176 }
177
178 pub fn with_options(mut self, options: WalkOptions) -> Self {
180 self.options = options;
181 self
182 }
183
184 pub fn with_ignore(mut self, filter: IgnoreFilter) -> Self {
186 self.ignore_filter = Some(filter);
187 self
188 }
189
190 pub async fn collect(mut self) -> Result<Vec<PathBuf>, crate::WalkerError> {
192 let base_filter = if self.options.respect_gitignore {
194 let mut filter = self
195 .ignore_filter
196 .take()
197 .unwrap_or_else(IgnoreFilter::with_defaults);
198
199 let gitignore_path = self.root.join(".gitignore");
201 if self.fs.exists(&gitignore_path).await {
202 match IgnoreFilter::from_gitignore(&gitignore_path, self.fs).await {
203 Ok(gitignore) => filter.merge(&gitignore),
204 Err(err) => {
205 if let Some(ref cb) = self.options.on_error {
206 cb(&gitignore_path, &err);
207 }
208 }
209 }
210 }
211 Some(filter)
212 } else {
213 self.ignore_filter.take()
214 };
215
216 let mut results = Vec::new();
217 let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
219 if self.options.follow_symlinks {
220 visited_dirs.insert(self.root.clone());
221 }
222 let mut stack = vec![(self.root.clone(), 0usize, base_filter.clone())];
224
225 while let Some((dir, depth, current_filter)) = stack.pop() {
226 if let Some(max) = self.options.max_depth
228 && depth > max {
229 continue;
230 }
231
232 let entries = match self.fs.list_dir(&dir).await {
234 Ok(entries) => entries,
235 Err(err) => {
236 if let Some(ref cb) = self.options.on_error {
237 cb(&dir, &err);
238 }
239 continue;
240 }
241 };
242
243 let mut entries: Vec<_> = entries
245 .into_iter()
246 .map(|e| {
247 let name = e.name().to_string();
248 let is_dir = e.is_dir();
249 let is_symlink = e.is_symlink();
250 (name, is_dir, is_symlink)
251 })
252 .collect();
253 entries.sort_by(|a, b| a.0.cmp(&b.0));
254
255 let mut dirs_to_push = Vec::new();
258
259 for (entry_name, entry_is_dir, entry_is_symlink) in entries {
260 let full_path = dir.join(&entry_name);
261
262 if !self.options.include_hidden
269 && self.pattern.is_none()
270 && entry_name.starts_with('.')
271 {
272 continue;
273 }
274
275 if let Some(ref filter) = current_filter {
277 let relative = self.relative_path(&full_path);
278 if filter.is_ignored(&relative, entry_is_dir) {
279 continue;
280 }
281 }
282
283 if let Some(ref types) = self.options.types
287 && types.matched(&full_path, entry_is_dir).is_ignore() {
288 continue;
289 }
290
291 if !self.options.filter.is_empty() {
296 let relative = self.relative_path(&full_path);
297 let name = full_path.file_name().map(Path::new);
298 if self
299 .options
300 .filter
301 .excludes_entry(&relative, name, entry_is_dir)
302 {
303 continue;
304 }
305 }
306
307 if entry_is_dir {
308 if entry_is_symlink && !self.options.follow_symlinks {
310 if self.options.entry_types.files
312 && self.matches_pattern(&full_path)
313 && self.depth_yields(depth)
314 && self.size_within_limit(self.fs, &full_path).await
315 {
316 results.push(full_path);
317 }
318 continue;
319 }
320
321 if entry_is_symlink && self.options.follow_symlinks {
323 let canonical = self.fs.canonicalize(&full_path).await;
324 if !visited_dirs.insert(canonical) {
325 if let Some(ref cb) = self.options.on_error {
327 cb(
328 &full_path,
329 &WalkerError::SymlinkCycle(full_path.display().to_string()),
330 );
331 }
332 continue;
333 }
334 }
335
336 let child_filter = if self.options.respect_gitignore {
338 let gitignore_path = full_path.join(".gitignore");
339 if self.fs.exists(&gitignore_path).await {
340 match IgnoreFilter::from_gitignore(&gitignore_path, self.fs).await {
341 Ok(nested_gitignore) => {
342 current_filter
344 .as_ref()
345 .map(|f| f.merged_with(&nested_gitignore))
346 .or(Some(nested_gitignore))
347 }
348 Err(err) => {
349 if let Some(ref cb) = self.options.on_error {
350 cb(&gitignore_path, &err);
351 }
352 current_filter.clone()
353 }
354 }
355 } else {
356 current_filter.clone()
357 }
358 } else {
359 current_filter.clone()
360 };
361
362 let should_recurse = match &self.pattern {
367 None => true,
368 Some(pat) => {
369 let relative = self.relative_path(&full_path);
370 pat.could_descend(&relative, self.options.include_hidden)
371 }
372 };
373
374 if should_recurse {
375 dirs_to_push.push((full_path.clone(), depth + 1, child_filter));
376 }
377
378 if self.options.entry_types.dirs
380 && self.matches_pattern(&full_path)
381 && self.depth_yields(depth)
382 {
383 results.push(full_path);
384 }
385 } else {
386 if self.options.entry_types.files
388 && self.matches_pattern(&full_path)
389 && self.depth_yields(depth)
390 && self.size_within_limit(self.fs, &full_path).await
391 {
392 results.push(full_path);
393 }
394 }
395 }
396
397 dirs_to_push.reverse();
400 stack.extend(dirs_to_push);
401 }
402
403 Ok(results)
404 }
405
406 fn relative_path(&self, full_path: &Path) -> PathBuf {
407 full_path
408 .strip_prefix(&self.root)
409 .map(|p| p.to_path_buf())
410 .unwrap_or_else(|_| full_path.to_path_buf())
411 }
412
413 fn matches_pattern(&self, path: &Path) -> bool {
414 match &self.pattern {
415 Some(pattern) => {
416 let relative = self.relative_path(path);
417 pattern.matches_walk(&relative, self.options.include_hidden)
418 }
419 None => true,
420 }
421 }
422
423 fn depth_yields(&self, depth: usize) -> bool {
426 match self.options.min_depth {
427 None | Some(0) => true,
428 Some(min) => depth >= min,
429 }
430 }
431
432 async fn size_within_limit(&self, fs: &F, path: &Path) -> bool {
436 let Some(limit) = self.options.max_filesize else {
437 return true;
438 };
439 match fs.file_size(path).await {
440 Some(size) => size <= limit,
441 None => true,
442 }
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::{WalkerDirEntry, WalkerError, WalkerFs};
450 use std::collections::HashMap;
451 use std::sync::Arc;
452 use tokio::sync::RwLock;
453
454 struct MemEntry {
456 name: String,
457 is_dir: bool,
458 is_symlink: bool,
459 }
460
461 impl WalkerDirEntry for MemEntry {
462 fn name(&self) -> &str { &self.name }
463 fn is_dir(&self) -> bool { self.is_dir }
464 fn is_file(&self) -> bool { !self.is_dir }
465 fn is_symlink(&self) -> bool { self.is_symlink }
466 }
467
468 struct MemoryFs {
472 files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
473 dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
474 symlinks: Arc<RwLock<HashMap<PathBuf, PathBuf>>>,
476 }
477
478 impl MemoryFs {
479 fn new() -> Self {
480 let mut dirs = std::collections::HashSet::new();
481 dirs.insert(PathBuf::from("/"));
482 Self {
483 files: Arc::new(RwLock::new(HashMap::new())),
484 dirs: Arc::new(RwLock::new(dirs)),
485 symlinks: Arc::new(RwLock::new(HashMap::new())),
486 }
487 }
488
489 async fn add_file(&self, path: &str, content: &[u8]) {
490 let path = PathBuf::from(path);
491 if let Some(parent) = path.parent() {
493 self.ensure_dirs(parent).await;
494 }
495 self.files.write().await.insert(path, content.to_vec());
496 }
497
498 async fn add_dir(&self, path: &str) {
499 self.ensure_dirs(&PathBuf::from(path)).await;
500 }
501
502 async fn add_dir_symlink(&self, link: &str, target: &str) {
505 let link_path = PathBuf::from(link);
506 let target_path = PathBuf::from(target);
507 if let Some(parent) = link_path.parent() {
509 self.ensure_dirs(parent).await;
510 }
511 self.dirs.write().await.insert(link_path.clone());
513 self.symlinks.write().await.insert(link_path, target_path);
514 }
515
516 fn resolve_path(path: &Path, symlinks: &HashMap<PathBuf, PathBuf>) -> PathBuf {
519 let mut resolved = PathBuf::new();
520 for component in path.components() {
521 resolved.push(component);
522 if let Some(target) = symlinks.get(&resolved) {
524 resolved = target.clone();
525 }
526 }
527 resolved
528 }
529
530 async fn ensure_dirs(&self, path: &Path) {
531 let mut dirs = self.dirs.write().await;
532 let mut current = PathBuf::new();
533 for component in path.components() {
534 current.push(component);
535 dirs.insert(current.clone());
536 }
537 }
538 }
539
540 #[async_trait::async_trait]
541 impl WalkerFs for MemoryFs {
542 type DirEntry = MemEntry;
543
544 async fn list_dir(&self, path: &Path) -> Result<Vec<MemEntry>, WalkerError> {
545 let symlinks = self.symlinks.read().await;
546
547 let resolved = Self::resolve_path(path, &symlinks);
549
550 let files = self.files.read().await;
551 let dirs = self.dirs.read().await;
552
553 let mut entries = Vec::new();
554 let mut seen = std::collections::HashSet::new();
555
556 for file_path in files.keys() {
558 if let Some(parent) = file_path.parent() {
559 if parent == resolved {
560 if let Some(name) = file_path.file_name() {
561 let name_str = name.to_string_lossy().to_string();
562 if seen.insert(name_str.clone()) {
563 entries.push(MemEntry {
564 name: name_str,
565 is_dir: false,
566 is_symlink: false,
567 });
568 }
569 }
570 }
571 }
572 }
573
574 for dir_path in dirs.iter() {
576 if let Some(parent) = dir_path.parent() {
577 if parent == resolved && dir_path != &resolved {
578 if let Some(name) = dir_path.file_name() {
579 let name_str = name.to_string_lossy().to_string();
580 if seen.insert(name_str.clone()) {
581 let is_symlink = symlinks.contains_key(dir_path);
582 entries.push(MemEntry {
583 name: name_str,
584 is_dir: true,
585 is_symlink,
586 });
587 }
588 }
589 }
590 }
591 }
592
593 Ok(entries)
594 }
595
596 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
597 let files = self.files.read().await;
598 files.get(path)
599 .cloned()
600 .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
601 }
602
603 async fn is_dir(&self, path: &Path) -> bool {
604 self.dirs.read().await.contains(path)
605 }
606
607 async fn exists(&self, path: &Path) -> bool {
608 self.files.read().await.contains_key(path)
609 || self.dirs.read().await.contains(path)
610 }
611
612 async fn canonicalize(&self, path: &Path) -> PathBuf {
613 let symlinks = self.symlinks.read().await;
614 Self::resolve_path(path, &symlinks)
615 }
616 }
617
618 async fn make_test_fs() -> MemoryFs {
619 let fs = MemoryFs::new();
620
621 fs.add_dir("/src").await;
622 fs.add_dir("/src/lib").await;
623 fs.add_dir("/test").await;
624 fs.add_dir("/.git").await;
625 fs.add_dir("/node_modules").await;
626
627 fs.add_file("/src/main.rs", b"fn main() {}").await;
628 fs.add_file("/src/lib.rs", b"pub mod lib;").await;
629 fs.add_file("/src/lib/utils.rs", b"pub fn util() {}").await;
630 fs.add_file("/test/main_test.rs", b"#[test]").await;
631 fs.add_file("/README.md", b"# Test").await;
632 fs.add_file("/.hidden", b"secret").await;
633 fs.add_file("/.git/config", b"[core]").await;
634 fs.add_file("/node_modules/pkg.json", b"{}").await;
635
636 fs
637 }
638
639 #[tokio::test]
640 async fn test_walk_all_files() {
641 let fs = make_test_fs().await;
642
643 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
644 respect_gitignore: false,
645 include_hidden: true,
646 ..Default::default()
647 });
648
649 let files = walker.collect().await.unwrap();
650
651 assert!(files.iter().any(|p| p.ends_with("main.rs")));
652 assert!(files.iter().any(|p| p.ends_with("lib.rs")));
653 assert!(files.iter().any(|p| p.ends_with("README.md")));
654 assert!(files.iter().any(|p| p.ends_with(".hidden")));
655 }
656
657 #[tokio::test]
658 async fn test_walk_with_pattern() {
659 let fs = make_test_fs().await;
660
661 let walker = FileWalker::new(&fs, "/")
662 .with_pattern(GlobPath::new("**/*.rs").unwrap())
663 .with_options(WalkOptions {
664 respect_gitignore: false,
665 ..Default::default()
666 });
667
668 let files = walker.collect().await.unwrap();
669
670 assert!(files.iter().any(|p| p.ends_with("main.rs")));
671 assert!(files.iter().any(|p| p.ends_with("lib.rs")));
672 assert!(files.iter().any(|p| p.ends_with("utils.rs")));
673 assert!(!files.iter().any(|p| p.ends_with("README.md")));
674 }
675
676 #[tokio::test]
677 async fn test_walk_respects_gitignore() {
678 let fs = make_test_fs().await;
679
680 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
681 respect_gitignore: true,
682 ..Default::default()
683 });
684
685 let files = walker.collect().await.unwrap();
686
687 assert!(!files
688 .iter()
689 .any(|p| p.to_string_lossy().contains(".git")));
690 assert!(!files
691 .iter()
692 .any(|p| p.to_string_lossy().contains("node_modules")));
693
694 assert!(files.iter().any(|p| p.ends_with("main.rs")));
695 }
696
697 #[tokio::test]
698 async fn test_walk_hides_dotfiles() {
699 let fs = make_test_fs().await;
700
701 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
702 include_hidden: false,
703 respect_gitignore: false,
704 ..Default::default()
705 });
706
707 let files = walker.collect().await.unwrap();
708
709 assert!(!files.iter().any(|p| p.ends_with(".hidden")));
710 assert!(files.iter().any(|p| p.ends_with("main.rs")));
711 }
712
713 #[tokio::test]
714 async fn test_dot_pattern_matches_dotfiles() {
715 let fs = MemoryFs::new();
717 fs.add_file("/.gitignore", b"x").await;
718 fs.add_file("/.env", b"x").await;
719 fs.add_file("/visible.txt", b"x").await;
720
721 let walker = FileWalker::new(&fs, "/")
722 .with_pattern(GlobPath::new(".*").unwrap())
723 .with_options(WalkOptions {
724 respect_gitignore: false,
725 ..Default::default()
726 });
727 let files = walker.collect().await.unwrap();
728
729 assert!(files.iter().any(|p| p.ends_with(".gitignore")));
730 assert!(files.iter().any(|p| p.ends_with(".env")));
731 assert!(!files.iter().any(|p| p.ends_with("visible.txt")));
732 }
733
734 #[tokio::test]
735 async fn test_star_skips_dotfiles() {
736 let fs = MemoryFs::new();
738 fs.add_file("/.env", b"x").await;
739 fs.add_file("/visible.txt", b"x").await;
740
741 let walker = FileWalker::new(&fs, "/")
742 .with_pattern(GlobPath::new("*").unwrap())
743 .with_options(WalkOptions {
744 respect_gitignore: false,
745 entry_types: EntryTypes::all(),
746 ..Default::default()
747 });
748 let files = walker.collect().await.unwrap();
749
750 assert!(!files.iter().any(|p| p.ends_with(".env")));
751 assert!(files.iter().any(|p| p.ends_with("visible.txt")));
752 }
753
754 #[tokio::test]
755 async fn test_literal_dotdir_is_traversed() {
756 let fs = MemoryFs::new();
758 fs.add_file("/.github/workflows/ci.yml", b"x").await;
759 fs.add_file("/.github/.secret", b"x").await;
760
761 let walker = FileWalker::new(&fs, "/")
762 .with_pattern(GlobPath::new(".github/**/*.yml").unwrap())
763 .with_options(WalkOptions {
764 respect_gitignore: false,
765 ..Default::default()
766 });
767 let files = walker.collect().await.unwrap();
768
769 assert!(files.iter().any(|p| p.ends_with("ci.yml")));
770 }
771
772 #[tokio::test]
773 async fn test_dotdir_star_excludes_nested_dotfiles() {
774 let fs = MemoryFs::new();
777 fs.add_file("/.github/config.yml", b"x").await;
778 fs.add_file("/.github/.secret", b"x").await;
779
780 let walker = FileWalker::new(&fs, "/")
781 .with_pattern(GlobPath::new(".github/*").unwrap())
782 .with_options(WalkOptions {
783 respect_gitignore: false,
784 entry_types: EntryTypes::all(),
785 ..Default::default()
786 });
787 let files = walker.collect().await.unwrap();
788
789 assert!(files.iter().any(|p| p.ends_with("config.yml")));
790 assert!(!files.iter().any(|p| p.ends_with(".secret")));
791 }
792
793 #[tokio::test]
794 async fn test_globstar_skips_dotdirs_without_dotglob() {
795 let fs = MemoryFs::new();
797 fs.add_file("/.github/buried.rs", b"x").await;
798 fs.add_file("/top.rs", b"x").await;
799
800 let walker = FileWalker::new(&fs, "/")
801 .with_pattern(GlobPath::new("**/*.rs").unwrap())
802 .with_options(WalkOptions {
803 respect_gitignore: false,
804 ..Default::default()
805 });
806 let files = walker.collect().await.unwrap();
807
808 assert!(files.iter().any(|p| p.ends_with("top.rs")));
809 assert!(!files.iter().any(|p| p.ends_with("buried.rs")));
810 }
811
812 #[tokio::test]
813 async fn test_globstar_then_explicit_dotfile() {
814 let fs = MemoryFs::new();
817 fs.add_file("/.env", b"x").await;
818 fs.add_file("/sub/.env", b"x").await;
819 fs.add_file("/.hidden/.env", b"x").await;
820 fs.add_file("/sub/visible.txt", b"x").await;
821
822 let walker = FileWalker::new(&fs, "/")
823 .with_pattern(GlobPath::new("**/.env").unwrap())
824 .with_options(WalkOptions {
825 respect_gitignore: false,
826 ..Default::default()
827 });
828 let files = walker.collect().await.unwrap();
829
830 assert_eq!(files.iter().filter(|p| p.ends_with(".env")).count(), 2, "{files:?}");
831 assert!(files.iter().any(|p| p == &PathBuf::from("/.env")));
832 assert!(files.iter().any(|p| p == &PathBuf::from("/sub/.env")));
833 assert!(!files.iter().any(|p| p.starts_with("/.hidden")));
834 }
835
836 #[tokio::test]
837 async fn test_globstar_then_explicit_dotdir() {
838 let fs = MemoryFs::new();
840 fs.add_file("/.github/ci.yml", b"x").await;
841 fs.add_file("/sub/.github/release.yml", b"x").await;
842
843 let walker = FileWalker::new(&fs, "/")
844 .with_pattern(GlobPath::new("**/.github/*.yml").unwrap())
845 .with_options(WalkOptions {
846 respect_gitignore: false,
847 ..Default::default()
848 });
849 let files = walker.collect().await.unwrap();
850
851 assert!(files.iter().any(|p| p.ends_with("ci.yml")), "{files:?}");
852 assert!(files.iter().any(|p| p.ends_with("release.yml")), "{files:?}");
853 }
854
855 #[tokio::test]
856 async fn test_include_hidden_acts_like_dotglob() {
857 let fs = MemoryFs::new();
859 fs.add_file("/.github/buried.rs", b"x").await;
860
861 let walker = FileWalker::new(&fs, "/")
862 .with_pattern(GlobPath::new("**/*.rs").unwrap())
863 .with_options(WalkOptions {
864 respect_gitignore: false,
865 include_hidden: true,
866 ..Default::default()
867 });
868 let files = walker.collect().await.unwrap();
869
870 assert!(files.iter().any(|p| p.ends_with("buried.rs")));
871 }
872
873 #[tokio::test]
874 async fn test_walk_max_depth() {
875 let fs = make_test_fs().await;
876
877 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
878 max_depth: Some(1),
879 respect_gitignore: false,
880 include_hidden: true,
881 ..Default::default()
882 });
883
884 let files = walker.collect().await.unwrap();
885
886 assert!(files.iter().any(|p| p.ends_with("README.md")));
888 assert!(files.iter().any(|p| p.ends_with("main.rs")));
890 assert!(!files.iter().any(|p| p.ends_with("utils.rs")));
892 }
893
894 #[tokio::test]
895 async fn test_walk_directories() {
896 let fs = make_test_fs().await;
897
898 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
899 entry_types: EntryTypes::dirs_only(),
900 respect_gitignore: false,
901 ..Default::default()
902 });
903
904 let dirs = walker.collect().await.unwrap();
905
906 assert!(dirs.iter().any(|p| p.ends_with("src")));
907 assert!(dirs.iter().any(|p| p.ends_with("lib")));
908 assert!(!dirs.iter().any(|p| p.ends_with("main.rs")));
909 }
910
911 #[tokio::test]
912 async fn test_walk_with_filter() {
913 let fs = make_test_fs().await;
914
915 let mut filter = IncludeExclude::new();
916 filter.exclude("*_test.rs");
917
918 let walker = FileWalker::new(&fs, "/")
919 .with_pattern(GlobPath::new("**/*.rs").unwrap())
920 .with_options(WalkOptions {
921 filter,
922 respect_gitignore: false,
923 ..Default::default()
924 });
925
926 let files = walker.collect().await.unwrap();
927
928 assert!(files.iter().any(|p| p.ends_with("main.rs")));
929 assert!(!files.iter().any(|p| p.ends_with("main_test.rs")));
930 }
931
932 #[tokio::test]
933 async fn test_walk_nested_gitignore() {
934 let fs = MemoryFs::new();
935
936 fs.add_dir("/src").await;
937 fs.add_dir("/src/subdir").await;
938 fs.add_file("/root.rs", b"root").await;
939 fs.add_file("/src/main.rs", b"main").await;
940 fs.add_file("/src/ignored.log", b"log").await;
941 fs.add_file("/src/subdir/util.rs", b"util").await;
942 fs.add_file("/src/subdir/local_ignore.txt", b"ignored").await;
943
944 fs.add_file("/.gitignore", b"*.log").await;
945 fs.add_file("/src/subdir/.gitignore", b"*.txt").await;
946
947 let walker = FileWalker::new(&fs, "/")
948 .with_options(WalkOptions {
949 respect_gitignore: true,
950 include_hidden: true,
951 ..Default::default()
952 });
953
954 let files = walker.collect().await.unwrap();
955
956 assert!(files.iter().any(|p| p.ends_with("root.rs")));
957 assert!(files.iter().any(|p| p.ends_with("main.rs")));
958 assert!(files.iter().any(|p| p.ends_with("util.rs")));
959
960 assert!(!files.iter().any(|p| p.ends_with("ignored.log")));
961 assert!(!files.iter().any(|p| p.ends_with("local_ignore.txt")));
962 }
963
964 struct SizedFs {
967 inner: MemoryFs,
968 sizes: HashMap<PathBuf, u64>,
969 }
970
971 #[async_trait::async_trait]
972 impl WalkerFs for SizedFs {
973 type DirEntry = MemEntry;
974 async fn list_dir(&self, path: &Path) -> Result<Vec<MemEntry>, WalkerError> {
975 self.inner.list_dir(path).await
976 }
977 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
978 self.inner.read_file(path).await
979 }
980 async fn is_dir(&self, path: &Path) -> bool { self.inner.is_dir(path).await }
981 async fn exists(&self, path: &Path) -> bool { self.inner.exists(path).await }
982 async fn file_size(&self, path: &Path) -> Option<u64> {
983 self.sizes.get(path).copied()
984 }
985 }
986
987 #[tokio::test]
988 async fn test_walk_max_filesize_skips_large_files() {
989 let inner = MemoryFs::new();
990 inner.add_file("/small.txt", b"tiny").await;
991 inner.add_file("/big.bin", b"larger payload").await;
992 let mut sizes = HashMap::new();
993 sizes.insert(PathBuf::from("/small.txt"), 1_024); sizes.insert(PathBuf::from("/big.bin"), 2 * 1_048_576); let fs = SizedFs { inner, sizes };
996
997 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
998 respect_gitignore: false,
999 max_filesize: Some(1_048_576), ..Default::default()
1001 });
1002
1003 let files = walker.collect().await.unwrap();
1004
1005 assert!(files.iter().any(|p| p.ends_with("small.txt")));
1006 assert!(!files.iter().any(|p| p.ends_with("big.bin")));
1007 }
1008
1009 #[tokio::test]
1010 async fn test_walk_max_filesize_unknown_size_yields() {
1011 let fs = MemoryFs::new();
1013 fs.add_file("/unknown.txt", b"x").await;
1014
1015 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1016 respect_gitignore: false,
1017 max_filesize: Some(0), ..Default::default()
1019 });
1020
1021 let files = walker.collect().await.unwrap();
1022 assert!(files.iter().any(|p| p.ends_with("unknown.txt")));
1023 }
1024
1025 #[tokio::test]
1026 async fn test_walk_min_depth_skips_root_files() {
1027 let fs = MemoryFs::new();
1028 fs.add_file("/at_root.txt", b"r").await;
1029 fs.add_dir("/sub").await;
1030 fs.add_file("/sub/nested.txt", b"n").await;
1031 fs.add_dir("/sub/deeper").await;
1032 fs.add_file("/sub/deeper/deep.txt", b"d").await;
1033
1034 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1035 respect_gitignore: false,
1036 min_depth: Some(1), ..Default::default()
1038 });
1039
1040 let files = walker.collect().await.unwrap();
1041
1042 assert!(!files.iter().any(|p| p.ends_with("at_root.txt")));
1044 assert!(files.iter().any(|p| p.ends_with("nested.txt")));
1046 assert!(files.iter().any(|p| p.ends_with("deep.txt")));
1048 }
1049
1050 #[tokio::test]
1051 async fn test_walk_types_select_only_rust() {
1052 let fs = MemoryFs::new();
1053 fs.add_file("/src/main.rs", b"r").await;
1054 fs.add_file("/src/main.py", b"p").await;
1055 fs.add_file("/src/main.js", b"j").await;
1056 fs.add_file("/README.md", b"m").await;
1057
1058 let mut tb = ignore::types::TypesBuilder::new();
1059 tb.add_defaults();
1060 tb.select("rust");
1061 let types = std::sync::Arc::new(tb.build().expect("types build"));
1062
1063 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1064 respect_gitignore: false,
1065 types: Some(types),
1066 ..Default::default()
1067 });
1068
1069 let files = walker.collect().await.unwrap();
1070
1071 assert!(files.iter().any(|p| p.ends_with("main.rs")));
1072 assert!(!files.iter().any(|p| p.ends_with("main.py")));
1073 assert!(!files.iter().any(|p| p.ends_with("main.js")));
1074 assert!(!files.iter().any(|p| p.ends_with("README.md")));
1075 }
1076
1077 #[tokio::test]
1078 async fn test_walk_types_negate_excludes() {
1079 let fs = MemoryFs::new();
1080 fs.add_file("/src/main.rs", b"r").await;
1081 fs.add_file("/src/main.py", b"p").await;
1082 fs.add_file("/README.md", b"m").await;
1083
1084 let mut tb = ignore::types::TypesBuilder::new();
1085 tb.add_defaults();
1086 tb.negate("rust");
1087 let types = std::sync::Arc::new(tb.build().expect("types build"));
1088
1089 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1090 respect_gitignore: false,
1091 types: Some(types),
1092 ..Default::default()
1093 });
1094
1095 let files = walker.collect().await.unwrap();
1096
1097 assert!(!files.iter().any(|p| p.ends_with("main.rs")));
1099 assert!(files.iter().any(|p| p.ends_with("main.py")));
1101 assert!(files.iter().any(|p| p.ends_with("README.md")));
1102 }
1103
1104 #[tokio::test]
1105 async fn test_walk_min_depth_still_descends() {
1106 let fs = MemoryFs::new();
1108 fs.add_dir("/level1").await;
1109 fs.add_dir("/level1/level2").await;
1110 fs.add_file("/level1/level2/found.txt", b"f").await;
1111
1112 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1113 respect_gitignore: false,
1114 min_depth: Some(2),
1115 ..Default::default()
1116 });
1117
1118 let files = walker.collect().await.unwrap();
1119 assert!(files.iter().any(|p| p.ends_with("found.txt")));
1120 }
1121
1122 #[tokio::test]
1123 async fn test_walk_error_callback() {
1124 use std::sync::Mutex;
1125
1126 struct ErrorFs {
1128 inner: MemoryFs,
1129 error_paths: Vec<PathBuf>,
1130 }
1131
1132 #[async_trait::async_trait]
1133 impl WalkerFs for ErrorFs {
1134 type DirEntry = MemEntry;
1135
1136 async fn list_dir(&self, path: &Path) -> Result<Vec<MemEntry>, WalkerError> {
1137 if self.error_paths.iter().any(|p| p == path) {
1138 return Err(WalkerError::PermissionDenied(path.display().to_string()));
1139 }
1140 self.inner.list_dir(path).await
1141 }
1142
1143 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
1144 self.inner.read_file(path).await
1145 }
1146
1147 async fn is_dir(&self, path: &Path) -> bool {
1148 self.inner.is_dir(path).await
1149 }
1150
1151 async fn exists(&self, path: &Path) -> bool {
1152 self.inner.exists(path).await
1153 }
1154 }
1155
1156 let inner = MemoryFs::new();
1157 inner.add_dir("/readable").await;
1158 inner.add_dir("/forbidden").await;
1159 inner.add_file("/readable/ok.txt", b"ok").await;
1160 inner.add_file("/forbidden/secret.txt", b"secret").await;
1161
1162 let fs = ErrorFs {
1163 inner,
1164 error_paths: vec![PathBuf::from("/forbidden")],
1165 };
1166
1167 let errors: Arc<Mutex<Vec<(PathBuf, String)>>> = Arc::new(Mutex::new(Vec::new()));
1168 let errors_cb = errors.clone();
1169
1170 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1171 respect_gitignore: false,
1172 include_hidden: true,
1173 on_error: Some(Arc::new(move |path, err| {
1174 errors_cb.lock().unwrap().push((path.to_path_buf(), err.to_string()));
1175 })),
1176 ..Default::default()
1177 });
1178
1179 let files = walker.collect().await.unwrap();
1180
1181 assert!(files.iter().any(|p| p.ends_with("ok.txt")));
1182 assert!(!files.iter().any(|p| p.ends_with("secret.txt")));
1183
1184 let errors = errors.lock().unwrap();
1185 assert_eq!(errors.len(), 1);
1186 assert_eq!(errors[0].0, PathBuf::from("/forbidden"));
1187 assert!(errors[0].1.contains("permission denied"));
1188 }
1189
1190 #[tokio::test]
1191 async fn test_walk_deterministic_order() {
1192 let fs = MemoryFs::new();
1193
1194 fs.add_dir("/charlie").await;
1196 fs.add_dir("/alpha").await;
1197 fs.add_dir("/bravo").await;
1198 fs.add_file("/charlie/c.txt", b"c").await;
1199 fs.add_file("/alpha/a.txt", b"a").await;
1200 fs.add_file("/bravo/b.txt", b"b").await;
1201
1202 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1203 respect_gitignore: false,
1204 ..Default::default()
1205 });
1206
1207 let files = walker.collect().await.unwrap();
1208
1209 assert_eq!(files.len(), 3);
1212 assert!(files[0].ends_with("alpha/a.txt"));
1213 assert!(files[1].ends_with("bravo/b.txt"));
1214 assert!(files[2].ends_with("charlie/c.txt"));
1215
1216 let walker2 = FileWalker::new(&fs, "/").with_options(WalkOptions {
1218 respect_gitignore: false,
1219 ..Default::default()
1220 });
1221 let files2 = walker2.collect().await.unwrap();
1222 assert_eq!(files, files2);
1223 }
1224
1225 #[tokio::test]
1226 async fn test_symlinks_not_followed_by_default() {
1227 let fs = MemoryFs::new();
1228
1229 fs.add_dir("/real").await;
1230 fs.add_file("/real/data.txt", b"data").await;
1231 fs.add_dir_symlink("/link", "/real").await;
1233
1234 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1235 respect_gitignore: false,
1236 ..Default::default()
1238 });
1239
1240 let files = walker.collect().await.unwrap();
1241
1242 assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
1244 assert!(files.iter().any(|p| p.ends_with("link")));
1246 assert!(!files.iter().any(|p| p.to_string_lossy().contains("link/data")));
1248 }
1249
1250 #[tokio::test]
1251 async fn test_symlinks_followed() {
1252 let fs = MemoryFs::new();
1253
1254 fs.add_dir("/real").await;
1255 fs.add_file("/real/data.txt", b"data").await;
1256 fs.add_dir_symlink("/link", "/real").await;
1258
1259 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1260 respect_gitignore: false,
1261 follow_symlinks: true,
1262 ..Default::default()
1263 });
1264
1265 let files = walker.collect().await.unwrap();
1266
1267 assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
1269 assert!(files.iter().any(|p| p.ends_with("link/data.txt")));
1270 }
1271
1272 #[tokio::test]
1273 async fn test_symlink_cycle_detection() {
1274 use std::sync::Mutex;
1275
1276 let fs = MemoryFs::new();
1277
1278 fs.add_dir("/a").await;
1280 fs.add_dir("/b").await;
1281 fs.add_file("/a/file_a.txt", b"a").await;
1282 fs.add_file("/b/file_b.txt", b"b").await;
1283 fs.add_dir_symlink("/a/link_to_b", "/b").await;
1285 fs.add_dir_symlink("/b/link_to_a", "/a").await;
1286
1287 let errors: Arc<Mutex<Vec<(PathBuf, String)>>> = Arc::new(Mutex::new(Vec::new()));
1288 let errors_cb = errors.clone();
1289
1290 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1291 respect_gitignore: false,
1292 follow_symlinks: true,
1293 on_error: Some(Arc::new(move |path, err| {
1294 errors_cb.lock().unwrap().push((path.to_path_buf(), err.to_string()));
1295 })),
1296 ..Default::default()
1297 });
1298
1299 let files = walker.collect().await.unwrap();
1300
1301 assert!(files.iter().any(|p| p.ends_with("file_a.txt")));
1303 assert!(files.iter().any(|p| p.ends_with("file_b.txt")));
1304
1305 let errors = errors.lock().unwrap();
1307 assert!(
1308 errors.iter().any(|(_, msg)| msg.contains("symlink cycle")),
1309 "expected symlink cycle error, got: {errors:?}"
1310 );
1311
1312 }
1314}