Skip to main content

ctx/
walker.rs

1use std::io;
2use std::path::{Path, PathBuf};
3
4use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
5use ignore::gitignore::{Gitignore, GitignoreBuilder};
6use ignore::WalkBuilder;
7
8use crate::default_ignores::DEFAULT_IGNORES;
9
10/// File filter that can check if individual files should be included.
11/// This is built once and reused for watch mode filtering.
12///
13/// This filter replicates the ignore sources used by WalkBuilder:
14/// - Hidden files/directories (starting with `.`)
15/// - .gitignore files (all levels)
16/// - .git/info/exclude
17/// - Global gitignore (~/.config/git/ignore or core.excludesFile)
18/// - .ignore files (ripgrep-style, all levels)
19/// - .contextignore files (all levels)
20/// - Default ignores from ctx
21/// - Custom ignore patterns from CLI
22pub struct FileFilter {
23    root: PathBuf,
24    /// Matcher for default ignores and custom CLI ignores
25    default_ignore_matcher: Gitignore,
26    /// Combined matcher for .gitignore, .ignore, .git/info/exclude, global gitignore
27    git_ignore_matcher: Option<Gitignore>,
28    /// Combined matcher for .contextignore files at all levels
29    contextignore_matcher: Option<Gitignore>,
30    include_globset: Option<GlobSet>,
31    /// Literal include paths, stored as relative paths for comparison
32    include_literals_rel: Vec<PathBuf>,
33    /// Literal include paths that were absolute, stored as absolute for comparison
34    include_literals_abs: Vec<PathBuf>,
35    use_gitignore: bool,
36}
37
38impl FileFilter {
39    /// Build a file filter from walker configuration.
40    ///
41    /// This builds matchers that replicate WalkBuilder's ignore behavior,
42    /// ensuring watch mode filtering is consistent with initial indexing.
43    pub fn new(root: &Path, config: &WalkerConfig) -> io::Result<Self> {
44        let root = if root.exists() {
45            root.canonicalize()?
46        } else {
47            root.to_path_buf()
48        };
49
50        // Build matcher for default ignores and custom CLI ignores
51        let default_ignore_matcher = build_ignore_matcher(&root, config);
52
53        // Build combined git ignore matcher if enabled
54        // This includes .gitignore, .ignore, .git/info/exclude, and global gitignore
55        let git_ignore_matcher = if config.use_gitignore {
56            build_combined_git_ignore_matcher(&root)
57        } else {
58            None
59        };
60
61        // Build contextignore matcher (all levels)
62        let contextignore_matcher = build_all_contextignore_matcher(&root);
63
64        // Build include patterns (normalize absolute globs relative to root)
65        let include_globset = build_include_globset(&root, &config.include_patterns)
66            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
67
68        // Separate absolute and relative literal paths
69        let literal_paths = get_literal_paths(&config.include_patterns);
70        let mut include_literals_rel = Vec::new();
71        let mut include_literals_abs = Vec::new();
72        for p in literal_paths {
73            if p.is_absolute() {
74                // Store absolute path, canonicalized if possible
75                let abs = p.canonicalize().unwrap_or(p);
76                include_literals_abs.push(abs);
77            } else {
78                include_literals_rel.push(p);
79            }
80        }
81
82        Ok(Self {
83            root,
84            default_ignore_matcher,
85            git_ignore_matcher,
86            contextignore_matcher,
87            include_globset,
88            include_literals_rel,
89            include_literals_abs,
90            use_gitignore: config.use_gitignore,
91        })
92    }
93
94    /// Check if a file should be included.
95    pub fn should_include(&self, file_path: &Path) -> bool {
96        // Get relative path
97        let rel_path = match file_path.strip_prefix(&self.root) {
98            Ok(p) => p,
99            Err(_) => return false,
100        };
101
102        let is_dir = file_path.is_dir();
103
104        // Skip hidden files/directories (WalkBuilder does this by default)
105        // Check if any component of the path starts with '.'
106        for component in rel_path.components() {
107            if let std::path::Component::Normal(name) = component {
108                if let Some(name_str) = name.to_str() {
109                    if name_str.starts_with('.') {
110                        return false;
111                    }
112                }
113            }
114        }
115
116        // Check default/custom ignores
117        if self
118            .default_ignore_matcher
119            .matched_path_or_any_parents(rel_path, is_dir)
120            .is_ignore()
121        {
122            return false;
123        }
124
125        // Check git-related ignores (.gitignore, .ignore, .git/info/exclude, global)
126        if self.use_gitignore {
127            if let Some(ref matcher) = self.git_ignore_matcher {
128                if matcher
129                    .matched_path_or_any_parents(rel_path, is_dir)
130                    .is_ignore()
131                {
132                    return false;
133                }
134            }
135        }
136
137        // Check contextignore (all levels)
138        if let Some(ref matcher) = self.contextignore_matcher {
139            if matcher
140                .matched_path_or_any_parents(rel_path, is_dir)
141                .is_ignore()
142            {
143                return false;
144            }
145        }
146
147        // Check include patterns
148        let has_includes = self.include_globset.is_some()
149            || !self.include_literals_rel.is_empty()
150            || !self.include_literals_abs.is_empty();
151
152        if has_includes {
153            let mut matches = false;
154
155            // Check relative literal paths
156            for literal in &self.include_literals_rel {
157                if rel_path.starts_with(literal) {
158                    matches = true;
159                    break;
160                }
161            }
162
163            // Check absolute literal paths against the absolute file path
164            if !matches {
165                let abs_path = self.root.join(rel_path);
166                for literal in &self.include_literals_abs {
167                    if abs_path.starts_with(literal) || abs_path == *literal {
168                        matches = true;
169                        break;
170                    }
171                }
172            }
173
174            // Check glob patterns
175            if !matches {
176                if let Some(ref globset) = self.include_globset {
177                    matches = globset.is_match(rel_path);
178                }
179            }
180
181            if !matches {
182                return false;
183            }
184        }
185
186        true
187    }
188}
189
190/// Build a combined matcher for all git-related ignore sources.
191/// This includes:
192/// - .gitignore files at all levels
193/// - .ignore files at all levels (ripgrep-style)
194/// - .git/info/exclude
195/// - core.excludesFile from git config (system, global, and local)
196/// - Default global gitignore (~/.config/git/ignore)
197fn build_combined_git_ignore_matcher(root: &Path) -> Option<Gitignore> {
198    let mut builder = GitignoreBuilder::new(root);
199
200    // Add all core.excludesFile values (system, global, local)
201    for excludes_file in get_all_git_excludes_files(root) {
202        let _ = builder.add(&excludes_file);
203    }
204
205    // Add default global gitignore as fallback
206    if let Some(global_gitignore) = find_default_global_gitignore() {
207        let _ = builder.add(&global_gitignore);
208    }
209
210    // Add .git/info/exclude if in a git repo
211    if let Some(git_dir) = find_git_dir(root) {
212        let exclude_path = git_dir.join("info").join("exclude");
213        if exclude_path.exists() {
214            let _ = builder.add(&exclude_path);
215        }
216    }
217
218    // Walk up to find parent .gitignore/.ignore files (for nested repos)
219    let mut current = root.parent();
220    while let Some(parent) = current {
221        for filename in &[".gitignore", ".ignore"] {
222            let ignore_path = parent.join(filename);
223            if ignore_path.exists() {
224                let _ = builder.add(&ignore_path);
225            }
226        }
227        // Stop if we hit a .git directory (repo root)
228        if parent.join(".git").exists() {
229            break;
230        }
231        current = parent.parent();
232    }
233
234    // Add root level .gitignore and .ignore
235    for filename in &[".gitignore", ".ignore"] {
236        let ignore_path = root.join(filename);
237        if ignore_path.exists() {
238            let _ = builder.add(&ignore_path);
239        }
240    }
241
242    // Recursively find all nested .gitignore and .ignore files
243    add_nested_ignore_files(&mut builder, root, &[".gitignore", ".ignore"]);
244
245    builder.build().ok()
246}
247
248/// Build a matcher for .contextignore files at all levels.
249fn build_all_contextignore_matcher(root: &Path) -> Option<Gitignore> {
250    let mut builder = GitignoreBuilder::new(root);
251    let mut found_any = false;
252
253    // Add root .contextignore
254    let contextignore_path = root.join(".contextignore");
255    if contextignore_path.exists() && builder.add(&contextignore_path).is_none() {
256        found_any = true;
257    }
258
259    // Recursively find nested .contextignore files
260    fn add_nested(builder: &mut GitignoreBuilder, dir: &Path, found: &mut bool) {
261        let Ok(entries) = std::fs::read_dir(dir) else {
262            return;
263        };
264
265        for entry in entries.flatten() {
266            let path = entry.path();
267            if path.is_dir() {
268                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
269                if name.starts_with('.') || name == "node_modules" || name == "target" {
270                    continue;
271                }
272
273                let contextignore = path.join(".contextignore");
274                if contextignore.exists() && builder.add(&contextignore).is_none() {
275                    *found = true;
276                }
277
278                add_nested(builder, &path, found);
279            }
280        }
281    }
282
283    add_nested(&mut builder, root, &mut found_any);
284
285    if found_any {
286        builder.build().ok()
287    } else {
288        None
289    }
290}
291
292/// Find the default global gitignore file location (fallback when core.excludesFile not set).
293/// Checks in order:
294/// 1. $XDG_CONFIG_HOME/git/ignore
295/// 2. ~/.config/git/ignore
296/// 3. ~/.gitignore_global (legacy)
297fn find_default_global_gitignore() -> Option<PathBuf> {
298    // Check XDG config location
299    if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
300        let path = PathBuf::from(xdg_config).join("git").join("ignore");
301        if path.exists() {
302            return Some(path);
303        }
304    }
305
306    // Get home directory from environment
307    let home = std::env::var("HOME")
308        .ok()
309        .map(PathBuf::from)
310        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))?;
311
312    // Check ~/.config/git/ignore
313    let path = home.join(".config").join("git").join("ignore");
314    if path.exists() {
315        return Some(path);
316    }
317
318    // Also check ~/.gitignore_global (common legacy location)
319    let legacy_path = home.join(".gitignore_global");
320    if legacy_path.exists() {
321        return Some(legacy_path);
322    }
323
324    None
325}
326
327/// Get all core.excludesFile values from git config (system, global, and local).
328/// Returns paths in order of precedence (local overrides global overrides system).
329fn get_all_git_excludes_files(root: &Path) -> Vec<PathBuf> {
330    use std::process::Command;
331
332    let mut excludes_files = Vec::new();
333
334    // Check all three scopes: system, global, local
335    // Local config is checked from the repository root
336    for scope in &["--system", "--global", "--local"] {
337        let mut cmd = Command::new("git");
338        cmd.args(["config", scope, "core.excludesFile"]);
339
340        // For local config, we need to run from the repo directory
341        if *scope == "--local" {
342            cmd.current_dir(root);
343        }
344
345        if let Ok(output) = cmd.output() {
346            if output.status.success() {
347                let path_str = String::from_utf8_lossy(&output.stdout);
348                let path_str = path_str.trim();
349
350                if !path_str.is_empty() {
351                    if let Some(path) = expand_tilde(path_str) {
352                        if path.exists() {
353                            excludes_files.push(path);
354                        }
355                    }
356                }
357            }
358        }
359    }
360
361    excludes_files
362}
363
364/// Expand ~ to home directory in a path string.
365fn expand_tilde(path_str: &str) -> Option<PathBuf> {
366    if let Some(stripped) = path_str.strip_prefix("~/") {
367        let home = std::env::var("HOME")
368            .ok()
369            .or_else(|| std::env::var("USERPROFILE").ok())?;
370        Some(PathBuf::from(home).join(stripped))
371    } else {
372        Some(PathBuf::from(path_str))
373    }
374}
375
376/// Find the .git directory for a repository.
377fn find_git_dir(root: &Path) -> Option<PathBuf> {
378    let git_dir = root.join(".git");
379    if git_dir.is_dir() {
380        return Some(git_dir);
381    }
382
383    // Walk up to find .git in parent directories
384    let mut current = root.parent();
385    while let Some(parent) = current {
386        let git_dir = parent.join(".git");
387        if git_dir.is_dir() {
388            return Some(git_dir);
389        }
390        current = parent.parent();
391    }
392
393    None
394}
395
396/// Recursively add nested ignore files to the builder.
397fn add_nested_ignore_files(builder: &mut GitignoreBuilder, dir: &Path, filenames: &[&str]) {
398    let Ok(entries) = std::fs::read_dir(dir) else {
399        return;
400    };
401
402    for entry in entries.flatten() {
403        let path = entry.path();
404        if path.is_dir() {
405            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
406            // Skip hidden directories and common non-source directories
407            if name.starts_with('.') || name == "node_modules" || name == "target" {
408                continue;
409            }
410
411            // Check for ignore files in this directory
412            for filename in filenames {
413                let ignore_path = path.join(filename);
414                if ignore_path.exists() {
415                    let _ = builder.add(&ignore_path);
416                }
417            }
418
419            // Recurse into subdirectory
420            add_nested_ignore_files(builder, &path, filenames);
421        }
422    }
423}
424
425/// Represents a discovered file with its metadata.
426#[derive(Debug, Clone)]
427pub struct FileEntry {
428    pub absolute_path: PathBuf,
429    pub relative_path: PathBuf,
430    pub size: u64,
431}
432
433/// Configuration for the file walker.
434#[derive(Debug, Clone)]
435pub struct WalkerConfig {
436    pub use_gitignore: bool,
437    pub use_default_ignores: bool,
438    pub custom_ignores: Vec<String>,
439    pub include_patterns: Vec<String>,
440}
441
442impl Default for WalkerConfig {
443    fn default() -> Self {
444        Self {
445            use_gitignore: true,
446            use_default_ignores: true,
447            custom_ignores: Vec::new(),
448            include_patterns: Vec::new(),
449        }
450    }
451}
452
453/// Check if a pattern is a glob pattern (contains wildcards).
454fn is_glob_pattern(pattern: &str) -> bool {
455    pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
456}
457
458/// Build a GlobSet from include patterns.
459/// Absolute glob patterns are normalized by stripping the root prefix.
460fn build_include_globset(
461    root: &Path,
462    patterns: &[String],
463) -> Result<Option<GlobSet>, globset::Error> {
464    let glob_patterns: Vec<&String> = patterns.iter().filter(|p| is_glob_pattern(p)).collect();
465
466    if glob_patterns.is_empty() {
467        return Ok(None);
468    }
469
470    let root_str = root.to_string_lossy();
471
472    let mut builder = GlobSetBuilder::new();
473    for pattern in glob_patterns {
474        // Normalize absolute patterns by stripping the root prefix
475        // e.g., /tmp/proj/src/**/*.rs -> src/**/*.rs when root is /tmp/proj
476        let normalized = if pattern.starts_with('/') {
477            // Try to strip root prefix from absolute pattern
478            if let Some(rel) = pattern.strip_prefix(root_str.as_ref()) {
479                rel.trim_start_matches('/').to_string()
480            } else if let Some(rel) = pattern.strip_prefix(&format!("{}/", root_str)) {
481                rel.to_string()
482            } else {
483                // Pattern is absolute but doesn't match root - keep as-is
484                // (this won't match anything, but that's correct behavior)
485                pattern.clone()
486            }
487        } else {
488            pattern.clone()
489        };
490
491        // Use literal_separator(true) so that `*` doesn't match `/`
492        // This makes `src/*.rs` match only files directly in src/, not subdirectories
493        let glob = GlobBuilder::new(&normalized)
494            .literal_separator(true)
495            .build()?;
496        builder.add(glob);
497    }
498    Ok(Some(builder.build()?))
499}
500
501/// Get literal paths from patterns (non-glob patterns).
502fn get_literal_paths(patterns: &[String]) -> Vec<PathBuf> {
503    patterns
504        .iter()
505        .filter(|p| !is_glob_pattern(p))
506        .map(PathBuf::from)
507        .collect()
508}
509
510/// Build a Gitignore matcher from default ignores and custom ignores.
511/// This uses the same ignore crate machinery as the walker.
512fn build_ignore_matcher(root: &Path, config: &WalkerConfig) -> Gitignore {
513    let mut builder = GitignoreBuilder::new(root);
514
515    // Add default ignores
516    if config.use_default_ignores {
517        for pattern in DEFAULT_IGNORES {
518            // GitignoreBuilder::add expects gitignore-style patterns
519            let _ = builder.add_line(None, pattern);
520        }
521    }
522
523    // Add custom ignores
524    for pattern in &config.custom_ignores {
525        let _ = builder.add_line(None, pattern);
526    }
527
528    builder.build().unwrap_or_else(|_| Gitignore::empty())
529}
530
531/// Check if a file should be included based on walker configuration.
532///
533/// This is a convenience function that creates a FileFilter and checks a single file.
534/// For checking multiple files, use FileFilter directly for better performance.
535///
536/// This function checks all ignore sources: default ignores, custom ignores,
537/// .gitignore (if enabled), and .contextignore.
538#[cfg(test)]
539pub fn should_include_file(root: &Path, file_path: &Path, config: &WalkerConfig) -> bool {
540    // Build a filter and check - this is less efficient than reusing a FileFilter
541    // but maintains the simple API for tests
542    match FileFilter::new(root, config) {
543        Ok(filter) => filter.should_include(file_path),
544        Err(_) => false,
545    }
546}
547
548/// Walk directories and discover files based on configuration.
549pub fn discover_files(root: &Path, config: &WalkerConfig) -> io::Result<Vec<FileEntry>> {
550    let root = root.canonicalize()?;
551    let mut entries = Vec::new();
552
553    // Build ignore matcher for default and custom ignores
554    let ignore_matcher = build_ignore_matcher(&root, config);
555
556    // Build include glob set for filtering (only applies to root walk, not literal paths)
557    let include_globset = build_include_globset(&root, &config.include_patterns)
558        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
559
560    // Get literal paths (these are walked separately and bypass glob filtering)
561    let literal_paths: Vec<PathBuf> = get_literal_paths(&config.include_patterns)
562        .into_iter()
563        .map(|p| if p.is_absolute() { p } else { root.join(p) })
564        .collect();
565
566    // Determine if we need to walk from root (for glob patterns)
567    let has_globs = config.include_patterns.iter().any(|p| is_glob_pattern(p));
568
569    // Helper to process a single walk
570    let mut process_walk = |start_path: &Path, apply_glob_filter: bool| -> io::Result<()> {
571        if !start_path.exists() {
572            eprintln!("Warning: path does not exist: {}", start_path.display());
573            return Ok(());
574        }
575
576        let mut builder = WalkBuilder::new(start_path);
577
578        // Configure gitignore handling
579        builder.git_ignore(config.use_gitignore);
580        builder.git_global(config.use_gitignore);
581        builder.git_exclude(config.use_gitignore);
582
583        // Look for .contextignore file
584        builder.add_custom_ignore_filename(".contextignore");
585
586        // Walk the directory
587        for result in builder.build() {
588            let entry = match result {
589                Ok(e) => e,
590                Err(err) => {
591                    eprintln!("Warning: {}", err);
592                    continue;
593                }
594            };
595
596            // Skip directories
597            let file_type = match entry.file_type() {
598                Some(ft) => ft,
599                None => continue,
600            };
601            if file_type.is_dir() {
602                continue;
603            }
604
605            let abs_path = entry.path().to_path_buf();
606
607            // Calculate relative path from root
608            let rel_path = match abs_path.strip_prefix(&root) {
609                Ok(p) => p.to_path_buf(),
610                Err(_) => continue,
611            };
612
613            // Apply default/custom ignore filtering
614            if ignore_matcher
615                .matched_path_or_any_parents(&rel_path, false)
616                .is_ignore()
617            {
618                continue;
619            }
620
621            // Apply include glob filter only when walking from root for glob patterns
622            // Literal paths bypass this filter - they're explicitly included
623            if apply_glob_filter {
624                if let Some(ref globset) = include_globset {
625                    if !globset.is_match(&rel_path) {
626                        continue;
627                    }
628                }
629            }
630
631            // Skip binary files (check for null bytes)
632            if is_binary_file(&abs_path) {
633                continue;
634            }
635
636            // Get file size
637            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
638
639            entries.push(FileEntry {
640                absolute_path: abs_path,
641                relative_path: rel_path,
642                size,
643            });
644        }
645        Ok(())
646    };
647
648    if config.include_patterns.is_empty() {
649        // No patterns - walk everything from root
650        process_walk(&root, false)?;
651    } else {
652        // Walk from root with glob filtering if there are glob patterns
653        if has_globs {
654            process_walk(&root, true)?;
655        }
656
657        // Walk literal paths WITHOUT glob filtering (they're explicitly included)
658        for literal_path in &literal_paths {
659            process_walk(literal_path, false)?;
660        }
661    }
662
663    // Sort by relative path for consistent output
664    entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
665
666    // Remove duplicates (can happen with overlapping patterns)
667    entries.dedup_by(|a, b| a.absolute_path == b.absolute_path);
668
669    Ok(entries)
670}
671
672/// Check if a file is likely binary by looking for null bytes.
673fn is_binary_file(path: &Path) -> bool {
674    use std::fs::File;
675    use std::io::Read;
676
677    let mut file = match File::open(path) {
678        Ok(f) => f,
679        Err(_) => return false,
680    };
681
682    let mut buffer = [0u8; 8192];
683    let bytes_read = match file.read(&mut buffer) {
684        Ok(n) => n,
685        Err(_) => return false,
686    };
687
688    buffer[..bytes_read].contains(&0)
689}
690
691/// Format file size in human-readable format.
692pub fn format_size(size: u64) -> String {
693    const KB: u64 = 1024;
694    const MB: u64 = KB * 1024;
695    const GB: u64 = MB * 1024;
696
697    if size >= GB {
698        format!("{:.1} GB", size as f64 / GB as f64)
699    } else if size >= MB {
700        format!("{:.1} MB", size as f64 / MB as f64)
701    } else if size >= KB {
702        format!("{:.1} KB", size as f64 / KB as f64)
703    } else {
704        format!("{} B", size)
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_glob_pattern_matching() {
714        // Test that glob patterns match correctly
715        let root = Path::new("/project");
716        let patterns = vec!["src/*.rs".to_string()];
717        let globset = build_include_globset(root, &patterns).unwrap().unwrap();
718
719        // Should match files directly in src/
720        assert!(globset.is_match(Path::new("src/main.rs")));
721        assert!(globset.is_match(Path::new("src/cli.rs")));
722
723        // Should NOT match files in subdirectories
724        assert!(!globset.is_match(Path::new("src/analytics/mod.rs")));
725        assert!(!globset.is_match(Path::new("src/db/mod.rs")));
726    }
727
728    #[test]
729    fn test_glob_pattern_recursive() {
730        // Test recursive glob pattern
731        let root = Path::new("/project");
732        let patterns = vec!["src/**/*.rs".to_string()];
733        let globset = build_include_globset(root, &patterns).unwrap().unwrap();
734
735        // Should match all .rs files under src/
736        assert!(globset.is_match(Path::new("src/main.rs")));
737        assert!(globset.is_match(Path::new("src/analytics/mod.rs")));
738        assert!(globset.is_match(Path::new("src/db/mod.rs")));
739
740        // Should NOT match files outside src/
741        assert!(!globset.is_match(Path::new("tests/test.rs")));
742    }
743
744    #[test]
745    fn test_glob_pattern_absolute() {
746        // Test absolute glob patterns are normalized
747        let root = Path::new("/project");
748        let patterns = vec!["/project/src/**/*.rs".to_string()];
749        let globset = build_include_globset(root, &patterns).unwrap().unwrap();
750
751        // Should match relative paths after normalization
752        assert!(globset.is_match(Path::new("src/main.rs")));
753        assert!(globset.is_match(Path::new("src/db/mod.rs")));
754
755        // Should NOT match files outside src/
756        assert!(!globset.is_match(Path::new("tests/test.rs")));
757    }
758
759    #[test]
760    fn test_should_include_file_default_ignores() {
761        let root = Path::new("/project");
762        let config = WalkerConfig::default();
763
764        // Default ignores should exclude node_modules, .git, target, etc.
765        assert!(!should_include_file(
766            root,
767            Path::new("/project/node_modules/foo.js"),
768            &config
769        ));
770        assert!(!should_include_file(
771            root,
772            Path::new("/project/.git/config"),
773            &config
774        ));
775        assert!(!should_include_file(
776            root,
777            Path::new("/project/target/debug/main"),
778            &config
779        ));
780
781        // Normal files should be included
782        assert!(should_include_file(
783            root,
784            Path::new("/project/src/main.rs"),
785            &config
786        ));
787    }
788
789    #[test]
790    fn test_should_include_file_custom_ignores() {
791        let root = Path::new("/project");
792        let config = WalkerConfig {
793            use_gitignore: true,
794            use_default_ignores: false, // Disable default ignores
795            custom_ignores: vec!["vendor/".to_string(), "generated/".to_string()],
796            include_patterns: vec![],
797        };
798
799        // Custom ignores should work
800        assert!(!should_include_file(
801            root,
802            Path::new("/project/vendor/lib.rs"),
803            &config
804        ));
805        assert!(!should_include_file(
806            root,
807            Path::new("/project/generated/types.rs"),
808            &config
809        ));
810
811        // Other files should be included
812        assert!(should_include_file(
813            root,
814            Path::new("/project/src/main.rs"),
815            &config
816        ));
817    }
818
819    #[test]
820    fn test_should_include_file_include_patterns() {
821        let root = Path::new("/project");
822        let config = WalkerConfig {
823            use_gitignore: true,
824            use_default_ignores: true,
825            custom_ignores: vec![],
826            include_patterns: vec!["src/**/*.rs".to_string()],
827        };
828
829        // Only src/**/*.rs should match
830        assert!(should_include_file(
831            root,
832            Path::new("/project/src/main.rs"),
833            &config
834        ));
835        assert!(should_include_file(
836            root,
837            Path::new("/project/src/db/mod.rs"),
838            &config
839        ));
840
841        // Other files should NOT match
842        assert!(!should_include_file(
843            root,
844            Path::new("/project/tests/test.rs"),
845            &config
846        ));
847        assert!(!should_include_file(
848            root,
849            Path::new("/project/build.rs"),
850            &config
851        ));
852    }
853
854    #[test]
855    fn test_should_include_file_wildcard_ignores() {
856        let root = Path::new("/project");
857        let config = WalkerConfig::default();
858
859        // Wildcard patterns like *.png, *.lock should be ignored
860        assert!(!should_include_file(
861            root,
862            Path::new("/project/assets/logo.png"),
863            &config
864        ));
865        assert!(!should_include_file(
866            root,
867            Path::new("/project/Cargo.lock"),
868            &config
869        ));
870        assert!(!should_include_file(
871            root,
872            Path::new("/project/package-lock.json"),
873            &config
874        ));
875
876        // But similar-named files that don't match should be fine
877        assert!(should_include_file(
878            root,
879            Path::new("/project/src/lockfile.rs"),
880            &config
881        ));
882    }
883
884    #[test]
885    fn test_should_include_file_no_false_positives() {
886        let root = Path::new("/project");
887        let config = WalkerConfig::default();
888
889        // Hidden files (starting with .) should be filtered - matching WalkBuilder default
890        assert!(!should_include_file(
891            root,
892            Path::new("/project/.env"),
893            &config
894        ));
895        assert!(!should_include_file(
896            root,
897            Path::new("/project/.envrc"),
898            &config
899        ));
900        assert!(!should_include_file(
901            root,
902            Path::new("/project/.github/workflows/ci.yml"),
903            &config
904        ));
905
906        // Non-hidden files with similar names should be included
907        assert!(should_include_file(
908            root,
909            Path::new("/project/src/env.rs"),
910            &config
911        ));
912        assert!(should_include_file(
913            root,
914            Path::new("/project/src/dotenv.rs"),
915            &config
916        ));
917    }
918}