Skip to main content

ctx/
walker.rs

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