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    /// Suppress yielding entries whose containing directory is at depth less
61    /// than this. Descent is unaffected — deeper entries are still found.
62    /// `None` and `Some(0)` are equivalent (yield everything).
63    pub min_depth: Option<usize>,
64    /// Skip files whose size exceeds this many bytes. Files for which the
65    /// underlying `WalkerFs::file_size` returns `None` (size unknown) are
66    /// always yielded regardless of the limit.
67    pub max_filesize: Option<u64>,
68    /// Types of entries to include.
69    pub entry_types: EntryTypes,
70    /// Respect .gitignore files and default ignores.
71    pub respect_gitignore: bool,
72    /// Include hidden files (starting with .).
73    pub include_hidden: bool,
74    /// Include/exclude filters.
75    pub filter: IncludeExclude,
76    /// Follow symbolic links into directories (default `false`).
77    /// When false, symlink directories are yielded as files rather than recursed.
78    /// When true, cycle detection prevents infinite loops.
79    pub follow_symlinks: bool,
80    /// Optional callback for non-fatal errors (unreadable dirs, bad .gitignore).
81    /// Default `None` silently skips errors (preserving original behavior).
82    pub on_error: Option<ErrorCallback>,
83    /// File-type filter using ripgrep's `ignore::types::Types`.
84    /// Builds e.g. with `TypesBuilder::new().add_defaults().select("rust")`.
85    /// Pure path-name matching — no I/O.
86    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
140/// Async file walker, generic over any `WalkerFs` implementation.
141///
142/// # Examples
143/// ```ignore
144/// use kaish_glob::{FileWalker, WalkOptions, GlobPath};
145///
146/// let walker = FileWalker::new(&my_fs, "src")
147///     .with_pattern(GlobPath::new("**/*.rs").unwrap())
148///     .with_options(WalkOptions::default());
149///
150/// let files = walker.collect().await?;
151/// ```
152pub 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    /// Create a new file walker starting at the given root.
162    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    /// Set a glob pattern to filter results.
173    pub fn with_pattern(mut self, pattern: GlobPath) -> Self {
174        self.pattern = Some(pattern);
175        self
176    }
177
178    /// Set walk options.
179    pub fn with_options(mut self, options: WalkOptions) -> Self {
180        self.options = options;
181        self
182    }
183
184    /// Set the ignore filter explicitly.
185    pub fn with_ignore(mut self, filter: IgnoreFilter) -> Self {
186        self.ignore_filter = Some(filter);
187        self
188    }
189
190    /// Collect all matching paths.
191    pub async fn collect(mut self) -> Result<Vec<PathBuf>, crate::WalkerError> {
192        // Set up base ignore filter
193        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            // Try to load .gitignore from root
200            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        // Track visited directories for symlink cycle detection (only when following symlinks)
218        let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
219        if self.options.follow_symlinks {
220            visited_dirs.insert(self.root.clone());
221        }
222        // Stack carries: (directory, depth, ignore_filter for this dir)
223        let mut stack = vec![(self.root.clone(), 0usize, base_filter.clone())];
224
225        while let Some((dir, depth, current_filter)) = stack.pop() {
226            // Check max depth
227            if let Some(max) = self.options.max_depth
228                && depth > max {
229                    continue;
230                }
231
232            // List directory contents
233            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            // Sort entries by name for deterministic traversal order
244            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            // Collect directories to push in reverse order so alphabetically-first
256            // directories are popped first from the LIFO stack.
257            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                // Hidden-file rule (bash, no `dotglob`). With a glob pattern the
263                // leading-dot decision is made per-component by `matches_pattern`
264                // (yield) and `could_descend` (traversal) below: `*` skips
265                // dotfiles while `.*`/`.github`/`**/.env` reach them. With no
266                // pattern — a plain recursive walk — hide dot entries unless
267                // `include_hidden`.
268                if !self.options.include_hidden
269                    && self.pattern.is_none()
270                    && entry_name.starts_with('.')
271                {
272                    continue;
273                }
274
275                // Check ignore filter
276                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                // Check type filter (-tjs / -Trust style filename matching).
284                // `Types::matched` returns Match::None for directories, so dirs
285                // always pass through and we can still recurse into them.
286                if let Some(ref types) = self.options.types
287                    && types.matched(&full_path, entry_is_dir).is_ignore() {
288                        continue;
289                    }
290
291                // Check include/exclude filter. Both the relative path and the
292                // bare filename are offered (patterns like "*_test.rs" are
293                // written against filenames); a directory is only pruned by an
294                // explicit exclude — an include list must not stop traversal.
295                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                    // Symlink directory handling
309                    if entry_is_symlink && !self.options.follow_symlinks {
310                        // Don't recurse into symlink dirs — yield as a file entry
311                        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                    // Cycle detection when following symlinks
322                    if entry_is_symlink && self.options.follow_symlinks {
323                        let canonical = self.fs.canonicalize(&full_path).await;
324                        if !visited_dirs.insert(canonical) {
325                            // Already visited this real directory — symlink cycle
326                            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                    // Check for nested .gitignore in this directory
337                    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                                    // Merge with parent filter
343                                    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                    // Only recurse if some entry beneath this directory could
363                    // still match. `could_descend` honours the leading-dot rule,
364                    // so `**` enters visible dirs but not hidden ones (without
365                    // dotglob), while an explicitly named `.github` is entered.
366                    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                    // Yield directory if wanted
379                    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                    // Yield file if wanted
387                    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            // Push directories in reverse order so alphabetically-first dirs
398            // are popped first from the LIFO stack.
399            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    /// Whether an entry at the given containing-directory depth should be
424    /// yielded under the current `min_depth` setting.
425    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    /// Whether a file at `path` is within the configured `max_filesize`.
433    /// Files whose size cannot be determined (`file_size` returns `None`)
434    /// are always considered within the limit.
435    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    /// Simple in-memory dir entry for testing.
455    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    /// In-memory filesystem for testing the walker.
469    ///
470    /// Supports files, directories, and symbolic links (directory symlinks).
471    struct MemoryFs {
472        files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
473        dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
474        /// Symlink path → target path (for directory symlinks)
475        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            // Ensure parent dirs exist
492            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        /// Add a directory symlink: `link` points to `target`.
503        /// The symlink appears as a directory entry and is listed under its parent.
504        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            // Ensure parent of link exists
508            if let Some(parent) = link_path.parent() {
509                self.ensure_dirs(parent).await;
510            }
511            // Register as a directory so it appears in listings
512            self.dirs.write().await.insert(link_path.clone());
513            self.symlinks.write().await.insert(link_path, target_path);
514        }
515
516        /// Resolve symlinks in a path by checking each prefix component.
517        /// This mimics how a real filesystem resolves intermediate symlinks.
518        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                // Check if the current prefix is a symlink and resolve it
523                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            // Resolve symlinks in the path: check each prefix to see if it's a symlink
548            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            // Find files directly under this dir
557            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            // Find subdirs directly under this dir
575            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        // `.*` explicitly names a leading dot, so it matches dotfiles (bash).
716        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        // A bare `*` never matches a leading dot without dotglob.
737        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        // An explicitly named `.github` directory is descended into.
757        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        // `.github/*` reaches into the named dot dir, but `*` still skips the
775        // dot-prefixed children inside it.
776        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        // `**` does not descend into hidden directories without dotglob.
796        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        // `**/.env` reaches a dotfile at the root and inside visible dirs, but
815        // not inside a hidden dir (which `**` cannot traverse without dotglob).
816        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        // `**/.github/*.yml` enters the named dot dir at any depth.
839        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        // include_hidden == dotglob: `**` then reaches hidden directories.
858        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        // Files at depth 1 (directly under /)
887        assert!(files.iter().any(|p| p.ends_with("README.md")));
888        // Files at depth 2 (under /src)
889        assert!(files.iter().any(|p| p.ends_with("main.rs")));
890        // Files at depth 3 (under /src/lib) should NOT be present
891        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    /// FS that reports a stub file size for every file.
965    /// Used for max_filesize tests.
966    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); // 1 KB
994        sizes.insert(PathBuf::from("/big.bin"), 2 * 1_048_576); // 2 MB
995        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), // 1 MB cap
1000            ..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        // file_size returning None means "unknown" — must NOT be skipped.
1012        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), // even with zero cap, unknown sizes pass
1018            ..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), // skip yields when containing dir is at depth < 1
1037            ..Default::default()
1038        });
1039
1040        let files = walker.collect().await.unwrap();
1041
1042        // /at_root.txt is at depth 0 (containing dir = root, depth 0) — skipped.
1043        assert!(!files.iter().any(|p| p.ends_with("at_root.txt")));
1044        // /sub/nested.txt is at depth 1 — yielded.
1045        assert!(files.iter().any(|p| p.ends_with("nested.txt")));
1046        // /sub/deeper/deep.txt is at depth 2 — yielded.
1047        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        // Rust files excluded.
1098        assert!(!files.iter().any(|p| p.ends_with("main.rs")));
1099        // Other files yielded.
1100        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        // min_depth must NOT prevent descent — only suppress yields above the threshold.
1107        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        /// Filesystem that returns errors for specific directories.
1127        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        // Add directories and files in non-alphabetical order
1195        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        // Results should be in alphabetical traversal order:
1210        // alpha/a.txt, bravo/b.txt, charlie/c.txt
1211        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        // Run again to verify determinism
1217        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        // /link → /real (symlink directory)
1232        fs.add_dir_symlink("/link", "/real").await;
1233
1234        let walker = FileWalker::new(&fs, "/").with_options(WalkOptions {
1235            respect_gitignore: false,
1236            // follow_symlinks defaults to false
1237            ..Default::default()
1238        });
1239
1240        let files = walker.collect().await.unwrap();
1241
1242        // /real/data.txt should be found
1243        assert!(files.iter().any(|p| p.ends_with("real/data.txt")));
1244        // /link should be yielded as a file entry (not recursed)
1245        assert!(files.iter().any(|p| p.ends_with("link")));
1246        // Should NOT find files under /link/ since we don't follow
1247        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        // /link → /real
1257        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        // Both the real path and symlinked path should have data.txt
1268        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        // Create a cycle: /a → /b, /b → /a
1279        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        // /a/link_to_b → /b, /b/link_to_a → /a
1284        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        // Real files should be found
1302        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        // Cycle should be detected and reported
1306        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        // Walk should terminate (not infinite loop) — the fact we got here proves it
1313    }
1314}