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 entry_types: EntryTypes,
62 pub respect_gitignore: bool,
64 pub include_hidden: bool,
66 pub filter: IncludeExclude,
68 pub follow_symlinks: bool,
72 pub on_error: Option<ErrorCallback>,
75}
76
77impl fmt::Debug for WalkOptions {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.debug_struct("WalkOptions")
80 .field("max_depth", &self.max_depth)
81 .field("entry_types", &self.entry_types)
82 .field("respect_gitignore", &self.respect_gitignore)
83 .field("include_hidden", &self.include_hidden)
84 .field("filter", &self.filter)
85 .field("follow_symlinks", &self.follow_symlinks)
86 .field("on_error", &self.on_error.as_ref().map(|_| "..."))
87 .finish()
88 }
89}
90
91impl Clone for WalkOptions {
92 fn clone(&self) -> Self {
93 Self {
94 max_depth: self.max_depth,
95 entry_types: self.entry_types,
96 respect_gitignore: self.respect_gitignore,
97 include_hidden: self.include_hidden,
98 filter: self.filter.clone(),
99 follow_symlinks: self.follow_symlinks,
100 on_error: self.on_error.clone(),
101 }
102 }
103}
104
105impl Default for WalkOptions {
106 fn default() -> Self {
107 Self {
108 max_depth: None,
109 entry_types: EntryTypes::files_only(),
110 respect_gitignore: true,
111 include_hidden: false,
112 filter: IncludeExclude::new(),
113 follow_symlinks: false,
114 on_error: None,
115 }
116 }
117}
118
119pub struct FileWalker<'a, F: WalkerFs> {
132 fs: &'a F,
133 root: PathBuf,
134 pattern: Option<GlobPath>,
135 options: WalkOptions,
136 ignore_filter: Option<IgnoreFilter>,
137}
138
139impl<'a, F: WalkerFs> FileWalker<'a, F> {
140 pub fn new(fs: &'a F, root: impl AsRef<Path>) -> Self {
142 Self {
143 fs,
144 root: root.as_ref().to_path_buf(),
145 pattern: None,
146 options: WalkOptions::default(),
147 ignore_filter: None,
148 }
149 }
150
151 pub fn with_pattern(mut self, pattern: GlobPath) -> Self {
153 self.pattern = Some(pattern);
154 self
155 }
156
157 pub fn with_options(mut self, options: WalkOptions) -> Self {
159 self.options = options;
160 self
161 }
162
163 pub fn with_ignore(mut self, filter: IgnoreFilter) -> Self {
165 self.ignore_filter = Some(filter);
166 self
167 }
168
169 pub async fn collect(mut self) -> Result<Vec<PathBuf>, crate::WalkerError> {
171 let base_filter = if self.options.respect_gitignore {
173 let mut filter = self
174 .ignore_filter
175 .take()
176 .unwrap_or_else(IgnoreFilter::with_defaults);
177
178 let gitignore_path = self.root.join(".gitignore");
180 if self.fs.exists(&gitignore_path).await {
181 match IgnoreFilter::from_gitignore(&gitignore_path, self.fs).await {
182 Ok(gitignore) => filter.merge(&gitignore),
183 Err(err) => {
184 if let Some(ref cb) = self.options.on_error {
185 cb(&gitignore_path, &err);
186 }
187 }
188 }
189 }
190 Some(filter)
191 } else {
192 self.ignore_filter.take()
193 };
194
195 let mut results = Vec::new();
196 let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
198 if self.options.follow_symlinks {
199 visited_dirs.insert(self.root.clone());
200 }
201 let mut stack = vec![(self.root.clone(), 0usize, base_filter.clone())];
203
204 while let Some((dir, depth, current_filter)) = stack.pop() {
205 if let Some(max) = self.options.max_depth
207 && depth > max {
208 continue;
209 }
210
211 let entries = match self.fs.list_dir(&dir).await {
213 Ok(entries) => entries,
214 Err(err) => {
215 if let Some(ref cb) = self.options.on_error {
216 cb(&dir, &err);
217 }
218 continue;
219 }
220 };
221
222 let mut entries: Vec<_> = entries
224 .into_iter()
225 .map(|e| {
226 let name = e.name().to_string();
227 let is_dir = e.is_dir();
228 let is_symlink = e.is_symlink();
229 (name, is_dir, is_symlink)
230 })
231 .collect();
232 entries.sort_by(|a, b| a.0.cmp(&b.0));
233
234 let mut dirs_to_push = Vec::new();
237
238 for (entry_name, entry_is_dir, entry_is_symlink) in entries {
239 let full_path = dir.join(&entry_name);
240
241 if !self.options.include_hidden && entry_name.starts_with('.') {
243 continue;
244 }
245
246 if let Some(ref filter) = current_filter {
248 let relative = self.relative_path(&full_path);
249 if filter.is_ignored(&relative, entry_is_dir) {
250 continue;
251 }
252 }
253
254 if !self.options.filter.is_empty() {
256 let relative = self.relative_path(&full_path);
257 if self.options.filter.should_exclude(&relative) {
258 continue;
259 }
260 if let Some(name) = full_path.file_name()
262 && self
263 .options
264 .filter
265 .should_exclude(Path::new(name))
266 {
267 continue;
268 }
269 }
270
271 if entry_is_dir {
272 if entry_is_symlink && !self.options.follow_symlinks {
274 if self.options.entry_types.files && self.matches_pattern(&full_path) {
276 results.push(full_path);
277 }
278 continue;
279 }
280
281 if entry_is_symlink && self.options.follow_symlinks {
283 let canonical = self.fs.canonicalize(&full_path).await;
284 if !visited_dirs.insert(canonical) {
285 if let Some(ref cb) = self.options.on_error {
287 cb(
288 &full_path,
289 &WalkerError::SymlinkCycle(full_path.display().to_string()),
290 );
291 }
292 continue;
293 }
294 }
295
296 let child_filter = if self.options.respect_gitignore {
298 let gitignore_path = full_path.join(".gitignore");
299 if self.fs.exists(&gitignore_path).await {
300 match IgnoreFilter::from_gitignore(&gitignore_path, self.fs).await {
301 Ok(nested_gitignore) => {
302 current_filter
304 .as_ref()
305 .map(|f| f.merged_with(&nested_gitignore))
306 .or(Some(nested_gitignore))
307 }
308 Err(err) => {
309 if let Some(ref cb) = self.options.on_error {
310 cb(&gitignore_path, &err);
311 }
312 current_filter.clone()
313 }
314 }
315 } else {
316 current_filter.clone()
317 }
318 } else {
319 current_filter.clone()
320 };
321
322 let should_recurse = match &self.pattern {
324 None => true,
325 Some(pat) => {
326 if pat.has_globstar() {
327 true
328 } else if let Some(fixed) = pat.fixed_depth() {
329 depth + 1 < fixed
330 } else {
331 true
332 }
333 }
334 };
335
336 if should_recurse {
337 dirs_to_push.push((full_path.clone(), depth + 1, child_filter));
338 }
339
340 if self.options.entry_types.dirs && self.matches_pattern(&full_path) {
342 results.push(full_path);
343 }
344 } else {
345 if self.options.entry_types.files && self.matches_pattern(&full_path) {
347 results.push(full_path);
348 }
349 }
350 }
351
352 dirs_to_push.reverse();
355 stack.extend(dirs_to_push);
356 }
357
358 Ok(results)
359 }
360
361 fn relative_path(&self, full_path: &Path) -> PathBuf {
362 full_path
363 .strip_prefix(&self.root)
364 .map(|p| p.to_path_buf())
365 .unwrap_or_else(|_| full_path.to_path_buf())
366 }
367
368 fn matches_pattern(&self, path: &Path) -> bool {
369 match &self.pattern {
370 Some(pattern) => {
371 let relative = self.relative_path(path);
372 pattern.matches(&relative)
373 }
374 None => true,
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::{WalkerDirEntry, WalkerError, WalkerFs};
383 use std::collections::HashMap;
384 use std::sync::Arc;
385 use tokio::sync::RwLock;
386
387 struct MemEntry {
389 name: String,
390 is_dir: bool,
391 is_symlink: bool,
392 }
393
394 impl WalkerDirEntry for MemEntry {
395 fn name(&self) -> &str { &self.name }
396 fn is_dir(&self) -> bool { self.is_dir }
397 fn is_file(&self) -> bool { !self.is_dir }
398 fn is_symlink(&self) -> bool { self.is_symlink }
399 }
400
401 struct MemoryFs {
405 files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
406 dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
407 symlinks: Arc<RwLock<HashMap<PathBuf, PathBuf>>>,
409 }
410
411 impl MemoryFs {
412 fn new() -> Self {
413 let mut dirs = std::collections::HashSet::new();
414 dirs.insert(PathBuf::from("/"));
415 Self {
416 files: Arc::new(RwLock::new(HashMap::new())),
417 dirs: Arc::new(RwLock::new(dirs)),
418 symlinks: Arc::new(RwLock::new(HashMap::new())),
419 }
420 }
421
422 async fn add_file(&self, path: &str, content: &[u8]) {
423 let path = PathBuf::from(path);
424 if let Some(parent) = path.parent() {
426 self.ensure_dirs(parent).await;
427 }
428 self.files.write().await.insert(path, content.to_vec());
429 }
430
431 async fn add_dir(&self, path: &str) {
432 self.ensure_dirs(&PathBuf::from(path)).await;
433 }
434
435 async fn add_dir_symlink(&self, link: &str, target: &str) {
438 let link_path = PathBuf::from(link);
439 let target_path = PathBuf::from(target);
440 if let Some(parent) = link_path.parent() {
442 self.ensure_dirs(parent).await;
443 }
444 self.dirs.write().await.insert(link_path.clone());
446 self.symlinks.write().await.insert(link_path, target_path);
447 }
448
449 fn resolve_path(path: &Path, symlinks: &HashMap<PathBuf, PathBuf>) -> PathBuf {
452 let mut resolved = PathBuf::new();
453 for component in path.components() {
454 resolved.push(component);
455 if let Some(target) = symlinks.get(&resolved) {
457 resolved = target.clone();
458 }
459 }
460 resolved
461 }
462
463 async fn ensure_dirs(&self, path: &Path) {
464 let mut dirs = self.dirs.write().await;
465 let mut current = PathBuf::new();
466 for component in path.components() {
467 current.push(component);
468 dirs.insert(current.clone());
469 }
470 }
471 }
472
473 #[async_trait::async_trait]
474 impl WalkerFs for MemoryFs {
475 type DirEntry = MemEntry;
476
477 async fn list_dir(&self, path: &Path) -> Result<Vec<MemEntry>, WalkerError> {
478 let symlinks = self.symlinks.read().await;
479
480 let resolved = Self::resolve_path(path, &symlinks);
482
483 let files = self.files.read().await;
484 let dirs = self.dirs.read().await;
485
486 let mut entries = Vec::new();
487 let mut seen = std::collections::HashSet::new();
488
489 for file_path in files.keys() {
491 if let Some(parent) = file_path.parent() {
492 if parent == resolved {
493 if let Some(name) = file_path.file_name() {
494 let name_str = name.to_string_lossy().to_string();
495 if seen.insert(name_str.clone()) {
496 entries.push(MemEntry {
497 name: name_str,
498 is_dir: false,
499 is_symlink: false,
500 });
501 }
502 }
503 }
504 }
505 }
506
507 for dir_path in dirs.iter() {
509 if let Some(parent) = dir_path.parent() {
510 if parent == resolved && dir_path != &resolved {
511 if let Some(name) = dir_path.file_name() {
512 let name_str = name.to_string_lossy().to_string();
513 if seen.insert(name_str.clone()) {
514 let is_symlink = symlinks.contains_key(dir_path);
515 entries.push(MemEntry {
516 name: name_str,
517 is_dir: true,
518 is_symlink,
519 });
520 }
521 }
522 }
523 }
524 }
525
526 Ok(entries)
527 }
528
529 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
530 let files = self.files.read().await;
531 files.get(path)
532 .cloned()
533 .ok_or_else(|| WalkerError::NotFound(path.display().to_string()))
534 }
535
536 async fn is_dir(&self, path: &Path) -> bool {
537 self.dirs.read().await.contains(path)
538 }
539
540 async fn exists(&self, path: &Path) -> bool {
541 self.files.read().await.contains_key(path)
542 || self.dirs.read().await.contains(path)
543 }
544
545 async fn canonicalize(&self, path: &Path) -> PathBuf {
546 let symlinks = self.symlinks.read().await;
547 Self::resolve_path(path, &symlinks)
548 }
549 }
550
551 async fn make_test_fs() -> MemoryFs {
552 let fs = MemoryFs::new();
553
554 fs.add_dir("/src").await;
555 fs.add_dir("/src/lib").await;
556 fs.add_dir("/test").await;
557 fs.add_dir("/.git").await;
558 fs.add_dir("/node_modules").await;
559
560 fs.add_file("/src/main.rs", b"fn main() {}").await;
561 fs.add_file("/src/lib.rs", b"pub mod lib;").await;
562 fs.add_file("/src/lib/utils.rs", b"pub fn util() {}").await;
563 fs.add_file("/test/main_test.rs", b"#[test]").await;
564 fs.add_file("/README.md", b"# Test").await;
565 fs.add_file("/.hidden", b"secret").await;
566 fs.add_file("/.git/config", b"[core]").await;
567 fs.add_file("/node_modules/pkg.json", b"{}").await;
568
569 fs
570 }
571
572 #[tokio::test]
573 async fn test_walk_all_files() {
574 let fs = make_test_fs().await;
575
576 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
577 respect_gitignore: false,
578 include_hidden: true,
579 ..Default::default()
580 });
581
582 let files = walker.collect().await.unwrap();
583
584 assert!(files.iter().any(|p| p.ends_with("main.rs")));
585 assert!(files.iter().any(|p| p.ends_with("lib.rs")));
586 assert!(files.iter().any(|p| p.ends_with("README.md")));
587 assert!(files.iter().any(|p| p.ends_with(".hidden")));
588 }
589
590 #[tokio::test]
591 async fn test_walk_with_pattern() {
592 let fs = make_test_fs().await;
593
594 let walker = FileWalker::new(&fs, "/")
595 .with_pattern(GlobPath::new("**/*.rs").unwrap())
596 .with_options(WalkOptions {
597 respect_gitignore: false,
598 ..Default::default()
599 });
600
601 let files = walker.collect().await.unwrap();
602
603 assert!(files.iter().any(|p| p.ends_with("main.rs")));
604 assert!(files.iter().any(|p| p.ends_with("lib.rs")));
605 assert!(files.iter().any(|p| p.ends_with("utils.rs")));
606 assert!(!files.iter().any(|p| p.ends_with("README.md")));
607 }
608
609 #[tokio::test]
610 async fn test_walk_respects_gitignore() {
611 let fs = make_test_fs().await;
612
613 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
614 respect_gitignore: true,
615 ..Default::default()
616 });
617
618 let files = walker.collect().await.unwrap();
619
620 assert!(!files
621 .iter()
622 .any(|p| p.to_string_lossy().contains(".git")));
623 assert!(!files
624 .iter()
625 .any(|p| p.to_string_lossy().contains("node_modules")));
626
627 assert!(files.iter().any(|p| p.ends_with("main.rs")));
628 }
629
630 #[tokio::test]
631 async fn test_walk_hides_dotfiles() {
632 let fs = make_test_fs().await;
633
634 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
635 include_hidden: false,
636 respect_gitignore: false,
637 ..Default::default()
638 });
639
640 let files = walker.collect().await.unwrap();
641
642 assert!(!files.iter().any(|p| p.ends_with(".hidden")));
643 assert!(files.iter().any(|p| p.ends_with("main.rs")));
644 }
645
646 #[tokio::test]
647 async fn test_walk_max_depth() {
648 let fs = make_test_fs().await;
649
650 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
651 max_depth: Some(1),
652 respect_gitignore: false,
653 include_hidden: true,
654 ..Default::default()
655 });
656
657 let files = walker.collect().await.unwrap();
658
659 assert!(files.iter().any(|p| p.ends_with("README.md")));
661 assert!(files.iter().any(|p| p.ends_with("main.rs")));
663 assert!(!files.iter().any(|p| p.ends_with("utils.rs")));
665 }
666
667 #[tokio::test]
668 async fn test_walk_directories() {
669 let fs = make_test_fs().await;
670
671 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
672 entry_types: EntryTypes::dirs_only(),
673 respect_gitignore: false,
674 ..Default::default()
675 });
676
677 let dirs = walker.collect().await.unwrap();
678
679 assert!(dirs.iter().any(|p| p.ends_with("src")));
680 assert!(dirs.iter().any(|p| p.ends_with("lib")));
681 assert!(!dirs.iter().any(|p| p.ends_with("main.rs")));
682 }
683
684 #[tokio::test]
685 async fn test_walk_with_filter() {
686 let fs = make_test_fs().await;
687
688 let mut filter = IncludeExclude::new();
689 filter.exclude("*_test.rs");
690
691 let walker = FileWalker::new(&fs, "/")
692 .with_pattern(GlobPath::new("**/*.rs").unwrap())
693 .with_options(WalkOptions {
694 filter,
695 respect_gitignore: false,
696 ..Default::default()
697 });
698
699 let files = walker.collect().await.unwrap();
700
701 assert!(files.iter().any(|p| p.ends_with("main.rs")));
702 assert!(!files.iter().any(|p| p.ends_with("main_test.rs")));
703 }
704
705 #[tokio::test]
706 async fn test_walk_nested_gitignore() {
707 let fs = MemoryFs::new();
708
709 fs.add_dir("/src").await;
710 fs.add_dir("/src/subdir").await;
711 fs.add_file("/root.rs", b"root").await;
712 fs.add_file("/src/main.rs", b"main").await;
713 fs.add_file("/src/ignored.log", b"log").await;
714 fs.add_file("/src/subdir/util.rs", b"util").await;
715 fs.add_file("/src/subdir/local_ignore.txt", b"ignored").await;
716
717 fs.add_file("/.gitignore", b"*.log").await;
718 fs.add_file("/src/subdir/.gitignore", b"*.txt").await;
719
720 let walker = FileWalker::new(&fs, "/")
721 .with_options(WalkOptions {
722 respect_gitignore: true,
723 include_hidden: true,
724 ..Default::default()
725 });
726
727 let files = walker.collect().await.unwrap();
728
729 assert!(files.iter().any(|p| p.ends_with("root.rs")));
730 assert!(files.iter().any(|p| p.ends_with("main.rs")));
731 assert!(files.iter().any(|p| p.ends_with("util.rs")));
732
733 assert!(!files.iter().any(|p| p.ends_with("ignored.log")));
734 assert!(!files.iter().any(|p| p.ends_with("local_ignore.txt")));
735 }
736
737 #[tokio::test]
738 async fn test_walk_error_callback() {
739 use std::sync::Mutex;
740
741 struct ErrorFs {
743 inner: MemoryFs,
744 error_paths: Vec<PathBuf>,
745 }
746
747 #[async_trait::async_trait]
748 impl WalkerFs for ErrorFs {
749 type DirEntry = MemEntry;
750
751 async fn list_dir(&self, path: &Path) -> Result<Vec<MemEntry>, WalkerError> {
752 if self.error_paths.iter().any(|p| p == path) {
753 return Err(WalkerError::PermissionDenied(path.display().to_string()));
754 }
755 self.inner.list_dir(path).await
756 }
757
758 async fn read_file(&self, path: &Path) -> Result<Vec<u8>, WalkerError> {
759 self.inner.read_file(path).await
760 }
761
762 async fn is_dir(&self, path: &Path) -> bool {
763 self.inner.is_dir(path).await
764 }
765
766 async fn exists(&self, path: &Path) -> bool {
767 self.inner.exists(path).await
768 }
769 }
770
771 let inner = MemoryFs::new();
772 inner.add_dir("/readable").await;
773 inner.add_dir("/forbidden").await;
774 inner.add_file("/readable/ok.txt", b"ok").await;
775 inner.add_file("/forbidden/secret.txt", b"secret").await;
776
777 let fs = ErrorFs {
778 inner,
779 error_paths: vec![PathBuf::from("/forbidden")],
780 };
781
782 let errors: Arc<Mutex<Vec<(PathBuf, String)>>> = Arc::new(Mutex::new(Vec::new()));
783 let errors_cb = errors.clone();
784
785 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
786 respect_gitignore: false,
787 include_hidden: true,
788 on_error: Some(Arc::new(move |path, err| {
789 errors_cb.lock().unwrap().push((path.to_path_buf(), err.to_string()));
790 })),
791 ..Default::default()
792 });
793
794 let files = walker.collect().await.unwrap();
795
796 assert!(files.iter().any(|p| p.ends_with("ok.txt")));
797 assert!(!files.iter().any(|p| p.ends_with("secret.txt")));
798
799 let errors = errors.lock().unwrap();
800 assert_eq!(errors.len(), 1);
801 assert_eq!(errors[0].0, PathBuf::from("/forbidden"));
802 assert!(errors[0].1.contains("permission denied"));
803 }
804
805 #[tokio::test]
806 async fn test_walk_deterministic_order() {
807 let fs = MemoryFs::new();
808
809 fs.add_dir("/charlie").await;
811 fs.add_dir("/alpha").await;
812 fs.add_dir("/bravo").await;
813 fs.add_file("/charlie/c.txt", b"c").await;
814 fs.add_file("/alpha/a.txt", b"a").await;
815 fs.add_file("/bravo/b.txt", b"b").await;
816
817 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
818 respect_gitignore: false,
819 ..Default::default()
820 });
821
822 let files = walker.collect().await.unwrap();
823
824 assert_eq!(files.len(), 3);
827 assert!(files[0].ends_with("alpha/a.txt"));
828 assert!(files[1].ends_with("bravo/b.txt"));
829 assert!(files[2].ends_with("charlie/c.txt"));
830
831 let walker2 = FileWalker::new(&fs, "/").with_options(WalkOptions {
833 respect_gitignore: false,
834 ..Default::default()
835 });
836 let files2 = walker2.collect().await.unwrap();
837 assert_eq!(files, files2);
838 }
839
840 #[tokio::test]
841 async fn test_symlinks_not_followed_by_default() {
842 let fs = MemoryFs::new();
843
844 fs.add_dir("/real").await;
845 fs.add_file("/real/data.txt", b"data").await;
846 fs.add_dir_symlink("/link", "/real").await;
848
849 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
850 respect_gitignore: false,
851 ..Default::default()
853 });
854
855 let files = walker.collect().await.unwrap();
856
857 assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
859 assert!(files.iter().any(|p| p.ends_with("link")));
861 assert!(!files.iter().any(|p| p.to_string_lossy().contains("link/data")));
863 }
864
865 #[tokio::test]
866 async fn test_symlinks_followed() {
867 let fs = MemoryFs::new();
868
869 fs.add_dir("/real").await;
870 fs.add_file("/real/data.txt", b"data").await;
871 fs.add_dir_symlink("/link", "/real").await;
873
874 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
875 respect_gitignore: false,
876 follow_symlinks: true,
877 ..Default::default()
878 });
879
880 let files = walker.collect().await.unwrap();
881
882 assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
884 assert!(files.iter().any(|p| p.ends_with("link/data.txt")));
885 }
886
887 #[tokio::test]
888 async fn test_symlink_cycle_detection() {
889 use std::sync::Mutex;
890
891 let fs = MemoryFs::new();
892
893 fs.add_dir("/a").await;
895 fs.add_dir("/b").await;
896 fs.add_file("/a/file_a.txt", b"a").await;
897 fs.add_file("/b/file_b.txt", b"b").await;
898 fs.add_dir_symlink("/a/link_to_b", "/b").await;
900 fs.add_dir_symlink("/b/link_to_a", "/a").await;
901
902 let errors: Arc<Mutex<Vec<(PathBuf, String)>>> = Arc::new(Mutex::new(Vec::new()));
903 let errors_cb = errors.clone();
904
905 let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
906 respect_gitignore: false,
907 follow_symlinks: true,
908 on_error: Some(Arc::new(move |path, err| {
909 errors_cb.lock().unwrap().push((path.to_path_buf(), err.to_string()));
910 })),
911 ..Default::default()
912 });
913
914 let files = walker.collect().await.unwrap();
915
916 assert!(files.iter().any(|p| p.ends_with("file_a.txt")));
918 assert!(files.iter().any(|p| p.ends_with("file_b.txt")));
919
920 let errors = errors.lock().unwrap();
922 assert!(
923 errors.iter().any(|(_, msg)| msg.contains("symlink cycle")),
924 "expected symlink cycle error, got: {errors:?}"
925 );
926
927 }
929}