Skip to main content

kaish_glob/
walker.rs

1//! Core async file walker, generic over `WalkerFs`.
2//!
3//! Provides recursive directory traversal with filtering support.
4
5use 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/// Types of entries to include in walk results.
16#[derive(Debug, Clone, Copy, Default)]
17pub struct EntryTypes {
18    /// Include regular files.
19    pub files: bool,
20    /// Include directories.
21    pub dirs: bool,
22}
23
24impl EntryTypes {
25    /// Include only files.
26    pub fn files_only() -> Self {
27        Self {
28            files: true,
29            dirs: false,
30        }
31    }
32
33    /// Include only directories.
34    pub fn dirs_only() -> Self {
35        Self {
36            files: false,
37            dirs: true,
38        }
39    }
40
41    /// Include both files and directories.
42    pub fn all() -> Self {
43        Self {
44            files: true,
45            dirs: true,
46        }
47    }
48}
49
50/// Callback invoked when a non-fatal error occurs during walking.
51///
52/// Receives the path where the error occurred and the error itself.
53/// This allows callers to log or collect errors without aborting the walk.
54pub type ErrorCallback = Arc<dyn Fn(&Path, &WalkerError) + Send + Sync>;
55
56/// Options for file walking.
57pub struct WalkOptions {
58    /// Maximum depth to recurse (None = unlimited).
59    pub max_depth: Option<usize>,
60    /// Types of entries to include.
61    pub entry_types: EntryTypes,
62    /// Respect .gitignore files and default ignores.
63    pub respect_gitignore: bool,
64    /// Include hidden files (starting with .).
65    pub include_hidden: bool,
66    /// Include/exclude filters.
67    pub filter: IncludeExclude,
68    /// Follow symbolic links into directories (default `false`).
69    /// When false, symlink directories are yielded as files rather than recursed.
70    /// When true, cycle detection prevents infinite loops.
71    pub follow_symlinks: bool,
72    /// Optional callback for non-fatal errors (unreadable dirs, bad .gitignore).
73    /// Default `None` silently skips errors (preserving original behavior).
74    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
119/// Async file walker, generic over any `WalkerFs` implementation.
120///
121/// # Examples
122/// ```ignore
123/// use kaish_glob::{FileWalker, WalkOptions, GlobPath};
124///
125/// let walker = FileWalker::new(&my_fs, "src")
126///     .with_pattern(GlobPath::new("**/*.rs").unwrap())
127///     .with_options(WalkOptions::default());
128///
129/// let files = walker.collect().await?;
130/// ```
131pub 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    /// Create a new file walker starting at the given root.
141    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    /// Set a glob pattern to filter results.
152    pub fn with_pattern(mut self, pattern: GlobPath) -> Self {
153        self.pattern = Some(pattern);
154        self
155    }
156
157    /// Set walk options.
158    pub fn with_options(mut self, options: WalkOptions) -> Self {
159        self.options = options;
160        self
161    }
162
163    /// Set the ignore filter explicitly.
164    pub fn with_ignore(mut self, filter: IgnoreFilter) -> Self {
165        self.ignore_filter = Some(filter);
166        self
167    }
168
169    /// Collect all matching paths.
170    pub async fn collect(mut self) -> Result<Vec<PathBuf>, crate::WalkerError> {
171        // Set up base ignore filter
172        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            // Try to load .gitignore from root
179            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        // Track visited directories for symlink cycle detection (only when following symlinks)
197        let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
198        if self.options.follow_symlinks {
199            visited_dirs.insert(self.root.clone());
200        }
201        // Stack carries: (directory, depth, ignore_filter for this dir)
202        let mut stack = vec![(self.root.clone(), 0usize, base_filter.clone())];
203
204        while let Some((dir, depth, current_filter)) = stack.pop() {
205            // Check max depth
206            if let Some(max) = self.options.max_depth
207                && depth > max {
208                    continue;
209                }
210
211            // List directory contents
212            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            // Sort entries by name for deterministic traversal order
223            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            // Collect directories to push in reverse order so alphabetically-first
235            // directories are popped first from the LIFO stack.
236            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                // Check hidden files
242                if !self.options.include_hidden && entry_name.starts_with('.') {
243                    continue;
244                }
245
246                // Check ignore filter
247                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                // Check include/exclude filter
255                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                    // Also check filename only for patterns like "*_test.rs"
261                    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                    // Symlink directory handling
273                    if entry_is_symlink && !self.options.follow_symlinks {
274                        // Don't recurse into symlink dirs — yield as a file entry
275                        if self.options.entry_types.files && self.matches_pattern(&full_path) {
276                            results.push(full_path);
277                        }
278                        continue;
279                    }
280
281                    // Cycle detection when following symlinks
282                    if entry_is_symlink && self.options.follow_symlinks {
283                        let canonical = self.fs.canonicalize(&full_path).await;
284                        if !visited_dirs.insert(canonical) {
285                            // Already visited this real directory — symlink cycle
286                            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                    // Check for nested .gitignore in this directory
297                    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                                    // Merge with parent filter
303                                    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                    // Only recurse if the pattern requires it
323                    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                    // Yield directory if wanted
341                    if self.options.entry_types.dirs && self.matches_pattern(&full_path) {
342                        results.push(full_path);
343                    }
344                } else {
345                    // Yield file if wanted
346                    if self.options.entry_types.files && self.matches_pattern(&full_path) {
347                        results.push(full_path);
348                    }
349                }
350            }
351
352            // Push directories in reverse order so alphabetically-first dirs
353            // are popped first from the LIFO stack.
354            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    /// Simple in-memory dir entry for testing.
388    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    /// In-memory filesystem for testing the walker.
402    ///
403    /// Supports files, directories, and symbolic links (directory symlinks).
404    struct MemoryFs {
405        files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
406        dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
407        /// Symlink path → target path (for directory symlinks)
408        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            // Ensure parent dirs exist
425            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        /// Add a directory symlink: `link` points to `target`.
436        /// The symlink appears as a directory entry and is listed under its parent.
437        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            // Ensure parent of link exists
441            if let Some(parent) = link_path.parent() {
442                self.ensure_dirs(parent).await;
443            }
444            // Register as a directory so it appears in listings
445            self.dirs.write().await.insert(link_path.clone());
446            self.symlinks.write().await.insert(link_path, target_path);
447        }
448
449        /// Resolve symlinks in a path by checking each prefix component.
450        /// This mimics how a real filesystem resolves intermediate symlinks.
451        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                // Check if the current prefix is a symlink and resolve it
456                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            // Resolve symlinks in the path: check each prefix to see if it's a symlink
481            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            // Find files directly under this dir
490            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            // Find subdirs directly under this dir
508            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        // Files at depth 1 (directly under /)
660        assert!(files.iter().any(|p| p.ends_with("README.md")));
661        // Files at depth 2 (under /src)
662        assert!(files.iter().any(|p| p.ends_with("main.rs")));
663        // Files at depth 3 (under /src/lib) should NOT be present
664        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        /// Filesystem that returns errors for specific directories.
742        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        // Add directories and files in non-alphabetical order
810        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        // Results should be in alphabetical traversal order:
825        // alpha/a.txt, bravo/b.txt, charlie/c.txt
826        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        // Run again to verify determinism
832        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        // /link → /real (symlink directory)
847        fs.add_dir_symlink("/link", "/real").await;
848
849        let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
850            respect_gitignore: false,
851            // follow_symlinks defaults to false
852            ..Default::default()
853        });
854
855        let files = walker.collect().await.unwrap();
856
857        // /real/data.txt should be found
858        assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
859        // /link should be yielded as a file entry (not recursed)
860        assert!(files.iter().any(|p| p.ends_with("link")));
861        // Should NOT find files under /link/ since we don't follow
862        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        // /link → /real
872        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        // Both the real path and symlinked path should have data.txt
883        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        // Create a cycle: /a → /b, /b → /a
894        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        // /a/link_to_b → /b, /b/link_to_a → /a
899        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        // Real files should be found
917        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        // Cycle should be detected and reported
921        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        // Walk should terminate (not infinite loop) — the fact we got here proves it
928    }
929}