Skip to main content

common/
filter.rs

1//! Pattern-based file filtering for include/exclude operations
2//!
3//! This module provides glob pattern matching for filtering files during copy, link, and remove operations.
4//!
5//! # Pattern Syntax
6//!
7//! - `*` matches anything except `/`
8//! - `**` matches anything including `/` (crosses directories)
9//! - `?` matches a single character (except `/`)
10//! - `[...]` character classes
11//! - Leading `/` anchors to source root
12//! - Trailing `/` matches only directories
13//!
14//! # Examples
15//!
16//! ```
17//! use common::filter::{FilterSettings, FilterResult};
18//! use std::path::Path;
19//!
20//! let mut settings = FilterSettings::default();
21//! settings.add_exclude("*.log").unwrap();
22//! settings.add_exclude("target/").unwrap();
23//!
24//! // .log files are excluded
25//! assert!(matches!(
26//!     settings.should_include(Path::new("debug.log"), false),
27//!     FilterResult::ExcludedByPattern(_)
28//! ));
29//!
30//! // other files are included
31//! assert!(matches!(
32//!     settings.should_include(Path::new("main.rs"), false),
33//!     FilterResult::Included
34//! ));
35//! ```
36
37use anyhow::{anyhow, Context};
38use serde::{Deserialize, Deserializer, Serialize, Serializer};
39use std::path::Path;
40
41/// A compiled filter pattern with metadata about its original form
42#[derive(Debug, Clone)]
43pub struct FilterPattern {
44    /// original pattern string for dry-run explain output
45    pub original: String,
46    /// compiled glob matcher
47    matcher: globset::GlobMatcher,
48    /// pattern ends with / (matches only directories)
49    pub dir_only: bool,
50    /// pattern starts with / (anchored to root)
51    pub anchored: bool,
52}
53
54impl FilterPattern {
55    /// Parse a pattern string into a FilterPattern
56    pub fn parse(pattern: &str) -> Result<Self, anyhow::Error> {
57        if pattern.is_empty() {
58            return Err(anyhow!("empty pattern is not allowed"));
59        }
60        let original = pattern.to_string();
61        let dir_only = pattern.ends_with('/');
62        let anchored = pattern.starts_with('/');
63        // strip leading/trailing markers for glob compilation
64        let pattern_str = pattern.trim_start_matches('/').trim_end_matches('/');
65        if pattern_str.is_empty() {
66            return Err(anyhow!(
67                "pattern '{}' results in empty glob after stripping / markers",
68                pattern
69            ));
70        }
71        // build glob with appropriate settings
72        let glob = globset::GlobBuilder::new(pattern_str)
73            .literal_separator(true) // * doesn't match /
74            .build()
75            .with_context(|| format!("invalid glob pattern: {}", pattern))?;
76        let matcher = glob.compile_matcher();
77        Ok(Self {
78            original,
79            matcher,
80            dir_only,
81            anchored,
82        })
83    }
84    /// Check if this pattern contains path separators (excluding leading/trailing / markers).
85    /// Path patterns require full path matching, while simple patterns can match filenames.
86    fn is_path_pattern(&self) -> bool {
87        // strip leading / (anchored marker) and trailing / (dir-only marker)
88        let core = self.original.trim_start_matches('/').trim_end_matches('/');
89        core.contains('/')
90    }
91    /// Check if this pattern matches the given path
92    pub fn matches(&self, relative_path: &Path, is_dir: bool) -> bool {
93        // directory-only patterns only match directories
94        if self.dir_only && !is_dir {
95            return false;
96        }
97        if self.anchored {
98            // anchored patterns match from the root only
99            self.matcher.is_match(relative_path)
100        } else {
101            // non-anchored patterns can match any component or the full path
102            // first try full path match
103            if self.matcher.is_match(relative_path) {
104                return true;
105            }
106            // for non-anchored patterns, also try matching against just the filename
107            // unless it's a path pattern (in which case full path match is required)
108            if !self.is_path_pattern() {
109                if let Some(file_name) = relative_path.file_name() {
110                    if self.matcher.is_match(Path::new(file_name)) {
111                        return true;
112                    }
113                }
114            }
115            false
116        }
117    }
118}
119
120/// Result of checking whether a path should be included
121#[derive(Debug, Clone)]
122pub enum FilterResult {
123    /// path should be processed
124    Included,
125    /// path was excluded because include patterns exist but none matched
126    ExcludedByDefault,
127    /// path was excluded by a specific pattern
128    ExcludedByPattern(String),
129}
130
131/// Settings for filtering files based on include/exclude patterns
132#[derive(Debug, Clone, Default)]
133pub struct FilterSettings {
134    /// patterns for files to include (if non-empty, only matching files are included)
135    pub includes: Vec<FilterPattern>,
136    /// patterns for files to exclude
137    pub excludes: Vec<FilterPattern>,
138}
139
140impl FilterSettings {
141    /// Create new empty filter settings
142    pub fn new() -> Self {
143        Self::default()
144    }
145    /// Add an include pattern
146    pub fn add_include(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
147        self.includes.push(FilterPattern::parse(pattern)?);
148        Ok(())
149    }
150    /// Add an exclude pattern
151    pub fn add_exclude(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
152        self.excludes.push(FilterPattern::parse(pattern)?);
153        Ok(())
154    }
155    /// Check if this filter has any patterns
156    pub fn is_empty(&self) -> bool {
157        self.includes.is_empty() && self.excludes.is_empty()
158    }
159    /// Check if this filter has any include patterns
160    pub fn has_includes(&self) -> bool {
161        !self.includes.is_empty()
162    }
163    /// Determine if a root item (the source itself) should be included based on filter patterns.
164    ///
165    /// This is a specialized version of `should_include` for root items (the source directory
166    /// or file being copied). Anchored patterns (starting with `/`) are skipped because they
167    /// are meant to match paths INSIDE the source root, not the source root itself.
168    ///
169    /// For root files, only non-anchored simple patterns (no `/`) can directly match the name.
170    /// For root directories, they're always traversed if include patterns exist (to find
171    /// matching content inside).
172    ///
173    /// For example, pattern `/bar` on source `foo/` should match `foo/bar`, not filter out `foo`.
174    pub fn should_include_root_item(&self, name: &Path, is_dir: bool) -> FilterResult {
175        // check excludes first - only non-anchored, simple patterns apply to root items
176        // (patterns with path separators like `bar/baz` match content inside, not the root)
177        // note: trailing `/` is a dir-only marker, not a path separator
178        for pattern in &self.excludes {
179            if !pattern.anchored
180                && !Self::is_path_pattern(&pattern.original)
181                && pattern.matches(name, is_dir)
182            {
183                return FilterResult::ExcludedByPattern(pattern.original.clone());
184            }
185        }
186        // if there are include patterns...
187        if !self.includes.is_empty() {
188            // for root files, check if any non-anchored simple pattern matches
189            if !is_dir {
190                for pattern in &self.includes {
191                    if !pattern.anchored
192                        && !Self::is_path_pattern(&pattern.original)
193                        && pattern.matches(name, false)
194                    {
195                        return FilterResult::Included;
196                    }
197                }
198                // no simple pattern matched the root file
199                return FilterResult::ExcludedByDefault;
200            }
201            // for root directories, always traverse to find matching content inside
202            // (both anchored patterns like /bar and path patterns like bar/*.txt
203            // need us to traverse the directory to find matches)
204            return FilterResult::Included;
205        }
206        // no includes and not excluded = included
207        FilterResult::Included
208    }
209    /// Check if a pattern is a path pattern (contains `/` other than leading/trailing markers)
210    fn is_path_pattern(original: &str) -> bool {
211        let trimmed = original.trim_start_matches('/').trim_end_matches('/');
212        trimmed.contains('/')
213    }
214    /// Determine if a path should be included based on filter patterns
215    ///
216    /// # Precedence
217    /// - If only excludes: include everything except matches
218    /// - If only includes: include only matches (exclude everything else by default)
219    /// - If both: excludes take priority (excludes checked first, then includes)
220    ///
221    /// # Directory handling
222    /// Directories are traversed when include patterns exist if they could potentially contain
223    /// matching files. For non-anchored patterns (like `*.txt`), all directories are traversed.
224    /// For anchored patterns (like `/bar`), only directories matching the pattern prefix are traversed.
225    pub fn should_include(&self, relative_path: &Path, is_dir: bool) -> FilterResult {
226        // check excludes first - if matched, path is excluded
227        for pattern in &self.excludes {
228            if pattern.matches(relative_path, is_dir) {
229                return FilterResult::ExcludedByPattern(pattern.original.clone());
230            }
231        }
232        // if there are include patterns, at least one must match
233        if !self.includes.is_empty() {
234            // first check if this path matches any include pattern
235            for pattern in &self.includes {
236                if pattern.matches(relative_path, is_dir) {
237                    return FilterResult::Included;
238                }
239            }
240            // for directories that don't directly match, check if they could contain matches
241            if is_dir {
242                for pattern in &self.includes {
243                    if self.could_contain_matches(relative_path, pattern) {
244                        return FilterResult::Included;
245                    }
246                }
247            }
248            return FilterResult::ExcludedByDefault;
249        }
250        // no includes specified and not excluded = included
251        FilterResult::Included
252    }
253    /// Check if a path directly matches any include pattern (not just could_contain_matches).
254    /// Used to determine if an empty directory should be kept.
255    ///
256    /// Returns true if:
257    /// - No include patterns exist (everything is directly included)
258    /// - At least one include pattern directly matches the path
259    ///
260    /// Returns false if:
261    /// - Include patterns exist but none match the path (directory was only traversed)
262    pub fn directly_matches_include(&self, relative_path: &std::path::Path, is_dir: bool) -> bool {
263        if self.includes.is_empty() {
264            return true; // no includes = everything directly included
265        }
266        for pattern in &self.includes {
267            if pattern.matches(relative_path, is_dir) {
268                return true;
269            }
270        }
271        false
272    }
273    /// Check if a directory could potentially contain files matching the pattern
274    pub fn could_contain_matches(&self, dir_path: &Path, pattern: &FilterPattern) -> bool {
275        // non-anchored simple patterns (no path separators) can match anywhere
276        if !pattern.anchored && !pattern.is_path_pattern() {
277            return true;
278        }
279        // extract the non-wildcard prefix from the pattern
280        // e.g., "/src/**" -> "src", "src/foo/**/*.rs" -> "src/foo", "**/*.rs" -> ""
281        let pattern_path = pattern
282            .original
283            .trim_start_matches('/')
284            .trim_end_matches('/');
285        let prefix = Self::extract_literal_prefix(pattern_path);
286        let dir_str = dir_path.to_string_lossy();
287        // if no literal prefix (pattern starts with wildcard like "**/*.rs"),
288        // it can match anywhere
289        if prefix.is_empty() {
290            return true;
291        }
292        // empty dir_path (root) is always an ancestor of any prefix
293        if dir_str.is_empty() {
294            return true;
295        }
296        // check if dir_path could lead to matches:
297        // 1. dir_path is an ancestor of prefix (e.g., "src" for prefix "src/foo")
298        // 2. dir_path equals the prefix
299        // 3. dir_path is a descendant of prefix (e.g., "src/foo/bar" for prefix "src")
300        // case 1 & 2: prefix starts with dir_path
301        if prefix.starts_with(&*dir_str) {
302            let after_dir = &prefix[dir_str.len()..];
303            // dir_path is ancestor if followed by '/' or is exact match
304            if after_dir.is_empty() || after_dir.starts_with('/') {
305                return true;
306            }
307        }
308        // case 3: dir_path is descendant of prefix
309        if let Some(after_prefix) = dir_str.strip_prefix(prefix) {
310            if after_prefix.is_empty() || after_prefix.starts_with('/') {
311                return true;
312            }
313        }
314        false
315    }
316    /// Extract the literal (non-wildcard) prefix from a pattern.
317    /// Returns the portion before any wildcard characters (*, ?, [), trimmed to complete path components.
318    /// Examples:
319    /// - "src/**" -> "src"
320    /// - "src/foo/**/*.rs" -> "src/foo"
321    /// - "**/*.rs" -> ""
322    /// - "bar" -> "bar" (no wildcards = entire pattern is literal)
323    /// - "bar/*.txt" -> "bar"
324    /// - "*.txt" -> ""
325    fn extract_literal_prefix(pattern: &str) -> &str {
326        // find first wildcard character
327        let wildcard_pos = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
328        // if no wildcards, entire pattern is literal
329        if wildcard_pos == pattern.len() {
330            return pattern;
331        }
332        // if wildcard at start, no prefix
333        if wildcard_pos == 0 {
334            return "";
335        }
336        // find the last '/' before the wildcard to get a complete path component
337        let prefix = &pattern[..wildcard_pos];
338        match prefix.rfind('/') {
339            Some(pos) => &pattern[..pos],
340            None => {
341                // no '/' before wildcard - pattern like "src*.txt" has no usable prefix
342                ""
343            }
344        }
345    }
346    /// Parse filter settings from a file
347    ///
348    /// # File Format
349    /// ```text
350    /// # comments supported
351    /// --include *.rs
352    /// --include Cargo.toml
353    /// --exclude target/
354    /// --exclude *.log
355    /// ```
356    pub fn from_file(path: &Path) -> Result<Self, anyhow::Error> {
357        let content = std::fs::read_to_string(path)
358            .with_context(|| format!("failed to read filter file: {:?}", path))?;
359        Self::parse_content(&content)
360    }
361    /// Parse filter settings from a string (filter file format)
362    pub fn parse_content(content: &str) -> Result<Self, anyhow::Error> {
363        let mut settings = Self::new();
364        for (line_num, line) in content.lines().enumerate() {
365            let line = line.trim();
366            // skip empty lines and comments
367            if line.is_empty() || line.starts_with('#') {
368                continue;
369            }
370            let line_num = line_num + 1; // 1-based for error messages
371            if let Some(pattern) = line.strip_prefix("--include ") {
372                let pattern = pattern.trim();
373                settings
374                    .add_include(pattern)
375                    .with_context(|| format!("line {}: invalid include pattern", line_num))?;
376            } else if let Some(pattern) = line.strip_prefix("--exclude ") {
377                let pattern = pattern.trim();
378                settings
379                    .add_exclude(pattern)
380                    .with_context(|| format!("line {}: invalid exclude pattern", line_num))?;
381            } else {
382                return Err(anyhow!(
383                    "line {}: invalid syntax '{}', expected '--include PATTERN' or '--exclude PATTERN'",
384                    line_num, line
385                ));
386            }
387        }
388        Ok(settings)
389    }
390}
391
392/// Data transfer object for FilterSettings serialization.
393/// Used for passing filter settings across process boundaries (e.g., to rcpd).
394#[derive(Serialize, Deserialize)]
395struct FilterSettingsDto {
396    includes: Vec<String>,
397    excludes: Vec<String>,
398}
399
400impl Serialize for FilterSettings {
401    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
402        let dto = FilterSettingsDto {
403            includes: self.includes.iter().map(|p| p.original.clone()).collect(),
404            excludes: self.excludes.iter().map(|p| p.original.clone()).collect(),
405        };
406        dto.serialize(serializer)
407    }
408}
409
410impl<'de> Deserialize<'de> for FilterSettings {
411    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
412        let dto = FilterSettingsDto::deserialize(deserializer)?;
413        let mut settings = FilterSettings::new();
414        for pattern in dto.includes {
415            settings
416                .add_include(&pattern)
417                .map_err(serde::de::Error::custom)?;
418        }
419        for pattern in dto.excludes {
420            settings
421                .add_exclude(&pattern)
422                .map_err(serde::de::Error::custom)?;
423        }
424        Ok(settings)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    #[test]
432    fn test_pattern_basic_glob() {
433        let pattern = FilterPattern::parse("*.rs").unwrap();
434        assert!(pattern.matches(Path::new("foo.rs"), false));
435        assert!(pattern.matches(Path::new("main.rs"), false));
436        assert!(!pattern.matches(Path::new("foo.txt"), false));
437        // simple patterns match against filename, so src/foo.rs matches via its filename
438        assert!(pattern.matches(Path::new("src/foo.rs"), false));
439    }
440    #[test]
441    fn test_pattern_double_star() {
442        let pattern = FilterPattern::parse("**/*.rs").unwrap();
443        assert!(pattern.matches(Path::new("src/foo.rs"), false));
444        assert!(pattern.matches(Path::new("a/b/c/d.rs"), false));
445        // ** can match zero segments, so **/*.rs matches foo.rs
446        assert!(pattern.matches(Path::new("foo.rs"), false));
447    }
448    #[test]
449    fn test_pattern_question_mark() {
450        let pattern = FilterPattern::parse("file?.txt").unwrap();
451        assert!(pattern.matches(Path::new("file1.txt"), false));
452        assert!(pattern.matches(Path::new("fileA.txt"), false));
453        assert!(!pattern.matches(Path::new("file12.txt"), false));
454        assert!(!pattern.matches(Path::new("file.txt"), false));
455    }
456    #[test]
457    fn test_pattern_character_class() {
458        let pattern = FilterPattern::parse("[abc].txt").unwrap();
459        assert!(pattern.matches(Path::new("a.txt"), false));
460        assert!(pattern.matches(Path::new("b.txt"), false));
461        assert!(pattern.matches(Path::new("c.txt"), false));
462        assert!(!pattern.matches(Path::new("d.txt"), false));
463    }
464    #[test]
465    fn test_pattern_anchored() {
466        let pattern = FilterPattern::parse("/src").unwrap();
467        assert!(pattern.anchored);
468        // matches only at root level
469        assert!(pattern.matches(Path::new("src"), true));
470        assert!(!pattern.matches(Path::new("foo/src"), true));
471    }
472    #[test]
473    fn test_pattern_dir_only() {
474        let pattern = FilterPattern::parse("build/").unwrap();
475        assert!(pattern.dir_only);
476        // only matches directories
477        assert!(pattern.matches(Path::new("build"), true));
478        assert!(!pattern.matches(Path::new("build"), false)); // file named build
479    }
480    #[test]
481    fn test_include_only_mode() {
482        let mut settings = FilterSettings::new();
483        settings.add_include("*.rs").unwrap();
484        settings.add_include("Cargo.toml").unwrap();
485        assert!(matches!(
486            settings.should_include(Path::new("main.rs"), false),
487            FilterResult::Included
488        ));
489        assert!(matches!(
490            settings.should_include(Path::new("Cargo.toml"), false),
491            FilterResult::Included
492        ));
493        assert!(matches!(
494            settings.should_include(Path::new("README.md"), false),
495            FilterResult::ExcludedByDefault
496        ));
497    }
498    #[test]
499    fn test_exclude_only_mode() {
500        let mut settings = FilterSettings::new();
501        settings.add_exclude("*.log").unwrap();
502        settings.add_exclude("target/").unwrap();
503        assert!(matches!(
504            settings.should_include(Path::new("main.rs"), false),
505            FilterResult::Included
506        ));
507        match settings.should_include(Path::new("debug.log"), false) {
508            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
509            other => panic!("expected ExcludedByPattern, got {:?}", other),
510        }
511        match settings.should_include(Path::new("target"), true) {
512            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "target/"),
513            other => panic!("expected ExcludedByPattern, got {:?}", other),
514        }
515    }
516    #[test]
517    fn test_include_then_exclude() {
518        let mut settings = FilterSettings::new();
519        settings.add_include("*.rs").unwrap();
520        settings.add_exclude("test_*.rs").unwrap();
521        // regular .rs files are included
522        assert!(matches!(
523            settings.should_include(Path::new("main.rs"), false),
524            FilterResult::Included
525        ));
526        // test_*.rs files are excluded even though they match *.rs
527        match settings.should_include(Path::new("test_foo.rs"), false) {
528            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
529            other => panic!("expected ExcludedByPattern, got {:?}", other),
530        }
531        // non-.rs files are excluded by default
532        assert!(matches!(
533            settings.should_include(Path::new("README.md"), false),
534            FilterResult::ExcludedByDefault
535        ));
536    }
537    #[test]
538    fn test_filter_file_basic() {
539        let content = r#"
540# this is a comment
541--include *.rs
542--include Cargo.toml
543
544--exclude target/
545--exclude *.log
546"#;
547        let settings = FilterSettings::parse_content(content).unwrap();
548        assert_eq!(settings.includes.len(), 2);
549        assert_eq!(settings.excludes.len(), 2);
550    }
551    #[test]
552    fn test_filter_file_comments() {
553        let content = "# only comments\n# and empty lines\n\n";
554        let settings = FilterSettings::parse_content(content).unwrap();
555        assert!(settings.is_empty());
556    }
557    #[test]
558    fn test_filter_file_syntax_error() {
559        let content = "invalid line without prefix";
560        let result = FilterSettings::parse_content(content);
561        assert!(result.is_err());
562        let err = result.unwrap_err().to_string();
563        assert!(err.contains("line 1"));
564        assert!(err.contains("invalid syntax"));
565    }
566    #[test]
567    fn test_empty_pattern_error() {
568        let result = FilterPattern::parse("");
569        assert!(result.is_err());
570    }
571    #[test]
572    fn test_is_empty() {
573        let empty = FilterSettings::new();
574        assert!(empty.is_empty());
575        let mut with_include = FilterSettings::new();
576        with_include.add_include("*.rs").unwrap();
577        assert!(!with_include.is_empty());
578        let mut with_exclude = FilterSettings::new();
579        with_exclude.add_exclude("*.log").unwrap();
580        assert!(!with_exclude.is_empty());
581    }
582    #[test]
583    fn test_has_includes() {
584        let empty = FilterSettings::new();
585        assert!(!empty.has_includes());
586        let mut with_include = FilterSettings::new();
587        with_include.add_include("*.rs").unwrap();
588        assert!(with_include.has_includes());
589        let mut with_exclude = FilterSettings::new();
590        with_exclude.add_exclude("*.log").unwrap();
591        assert!(!with_exclude.has_includes());
592        let mut with_both = FilterSettings::new();
593        with_both.add_include("*.rs").unwrap();
594        with_both.add_exclude("*.log").unwrap();
595        assert!(with_both.has_includes());
596    }
597    #[test]
598    fn test_filename_match_for_simple_patterns() {
599        // simple patterns (no /) should match the filename anywhere in the path
600        let pattern = FilterPattern::parse("*.rs").unwrap();
601        assert!(pattern.matches(Path::new("foo.rs"), false));
602        assert!(pattern.matches(Path::new("src/foo.rs"), false)); // matches filename
603                                                                  // nested paths also match via filename
604        assert!(pattern.matches(Path::new("a/b/c/foo.rs"), false));
605    }
606    #[test]
607    fn test_path_pattern_requires_full_match() {
608        // patterns with / require the full path to match
609        let pattern = FilterPattern::parse("src/*.rs").unwrap();
610        assert!(pattern.matches(Path::new("src/foo.rs"), false));
611        assert!(!pattern.matches(Path::new("foo.rs"), false));
612        assert!(!pattern.matches(Path::new("other/src/foo.rs"), false));
613    }
614    #[test]
615    fn test_double_star_matches_nested_paths() {
616        // **/*.rs should match files at any depth
617        let pattern = FilterPattern::parse("**/*.rs").unwrap();
618        assert!(pattern.matches(Path::new("foo.rs"), false));
619        assert!(pattern.matches(Path::new("src/foo.rs"), false));
620        assert!(pattern.matches(Path::new("src/lib/foo.rs"), false));
621        assert!(pattern.matches(Path::new("a/b/c/d/e.rs"), false));
622    }
623    #[test]
624    fn test_anchored_pattern_matches_only_at_root() {
625        // /src should match only at root, not nested
626        let pattern = FilterPattern::parse("/src").unwrap();
627        assert!(pattern.matches(Path::new("src"), true));
628        assert!(!pattern.matches(Path::new("foo/src"), true));
629        assert!(!pattern.matches(Path::new("a/b/src"), true));
630    }
631    #[test]
632    fn test_nested_directory_pattern() {
633        // src/lib/ should match only that specific nested path
634        let pattern = FilterPattern::parse("src/lib/").unwrap();
635        assert!(pattern.matches(Path::new("src/lib"), true));
636        assert!(!pattern.matches(Path::new("lib"), true));
637        assert!(!pattern.matches(Path::new("other/src/lib"), true));
638    }
639    #[test]
640    fn test_dir_only_simple_pattern_matches_at_any_level() {
641        // target/ (dir-only) should match "target" at any level, like simple patterns
642        // the trailing / is just a dir-only marker, not a path separator
643        let pattern = FilterPattern::parse("target/").unwrap();
644        assert!(pattern.dir_only);
645        assert!(!pattern.anchored);
646        // should match at root
647        assert!(pattern.matches(Path::new("target"), true));
648        // should match nested (filename matching)
649        assert!(pattern.matches(Path::new("foo/target"), true));
650        assert!(pattern.matches(Path::new("a/b/target"), true));
651        // should NOT match files (dir-only)
652        assert!(!pattern.matches(Path::new("target"), false));
653        assert!(!pattern.matches(Path::new("foo/target"), false));
654    }
655    #[test]
656    fn test_dir_only_pattern_could_contain_matches() {
657        // target/ should allow traversal into any directory since it can match anywhere
658        let mut settings = FilterSettings::new();
659        settings.add_include("target/").unwrap();
660        let pattern = &settings.includes[0];
661        // should return true for any directory since target/ can match at any level
662        assert!(settings.could_contain_matches(Path::new("foo"), pattern));
663        assert!(settings.could_contain_matches(Path::new("a/b"), pattern));
664        assert!(settings.could_contain_matches(Path::new("src"), pattern));
665    }
666    #[test]
667    fn test_precedence_exclude_overrides_include() {
668        // when both include and exclude match, exclude wins (excludes checked first)
669        let mut settings = FilterSettings::new();
670        settings.add_include("*.rs").unwrap();
671        settings.add_exclude("test_*.rs").unwrap();
672        // file matching both patterns should be excluded
673        match settings.should_include(Path::new("test_main.rs"), false) {
674            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
675            other => panic!("expected ExcludedByPattern, got {:?}", other),
676        }
677        // file matching only include should be included
678        assert!(matches!(
679            settings.should_include(Path::new("main.rs"), false),
680            FilterResult::Included
681        ));
682    }
683    #[test]
684    fn test_should_include_root_item_non_anchored_exclude() {
685        // non-anchored exclude patterns should apply to root items
686        let mut settings = FilterSettings::new();
687        settings.add_exclude("*.log").unwrap();
688        // root file matching exclude pattern is excluded
689        match settings.should_include_root_item(Path::new("debug.log"), false) {
690            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
691            other => panic!("expected ExcludedByPattern, got {:?}", other),
692        }
693        // root file not matching exclude pattern is included
694        assert!(matches!(
695            settings.should_include_root_item(Path::new("main.rs"), false),
696            FilterResult::Included
697        ));
698    }
699    #[test]
700    fn test_should_include_root_item_anchored_exclude_skipped() {
701        // anchored exclude patterns should NOT apply to root items
702        let mut settings = FilterSettings::new();
703        settings.add_exclude("/target/").unwrap();
704        // root directory "target" should NOT be excluded (pattern is anchored)
705        assert!(matches!(
706            settings.should_include_root_item(Path::new("target"), true),
707            FilterResult::Included
708        ));
709    }
710    #[test]
711    fn test_should_include_root_item_non_anchored_include() {
712        // non-anchored include patterns should apply to root items
713        let mut settings = FilterSettings::new();
714        settings.add_include("*.rs").unwrap();
715        // root file matching include pattern is included
716        assert!(matches!(
717            settings.should_include_root_item(Path::new("main.rs"), false),
718            FilterResult::Included
719        ));
720        // root file not matching include pattern is excluded by default
721        assert!(matches!(
722            settings.should_include_root_item(Path::new("readme.md"), false),
723            FilterResult::ExcludedByDefault
724        ));
725    }
726    #[test]
727    fn test_should_include_root_item_anchored_include_skipped() {
728        // anchored include patterns should NOT apply to root items directly
729        // but root directories should still be traversed if anchored patterns exist
730        let mut settings = FilterSettings::new();
731        settings.add_include("/bar").unwrap();
732        // root directory "foo" should be included (so we can traverse to find /bar inside)
733        assert!(matches!(
734            settings.should_include_root_item(Path::new("foo"), true),
735            FilterResult::Included
736        ));
737        // root file "baz" should be excluded by default (only anchored includes, none match)
738        assert!(matches!(
739            settings.should_include_root_item(Path::new("baz"), false),
740            FilterResult::ExcludedByDefault
741        ));
742    }
743    #[test]
744    fn test_should_include_root_item_mixed_patterns() {
745        // mix of anchored and non-anchored patterns
746        let mut settings = FilterSettings::new();
747        settings.add_include("*.rs").unwrap();
748        settings.add_include("/bar").unwrap();
749        settings.add_exclude("test_*.rs").unwrap();
750        // root .rs file is included (non-anchored include)
751        assert!(matches!(
752            settings.should_include_root_item(Path::new("main.rs"), false),
753            FilterResult::Included
754        ));
755        // root test_*.rs file is excluded (non-anchored exclude)
756        match settings.should_include_root_item(Path::new("test_foo.rs"), false) {
757            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
758            other => panic!("expected ExcludedByPattern, got {:?}", other),
759        }
760        // root directory "foo" is included (to traverse for /bar)
761        assert!(matches!(
762            settings.should_include_root_item(Path::new("foo"), true),
763            FilterResult::Included
764        ));
765    }
766    #[test]
767    fn test_could_contain_matches_anchored_double_star() {
768        // /src/** should only match directories under src, not unrelated directories
769        let mut settings = FilterSettings::new();
770        settings.add_include("/src/**").unwrap();
771        let pattern = &settings.includes[0];
772        // should return true for ancestors of "src" (we need to traverse to get there)
773        assert!(settings.could_contain_matches(Path::new(""), pattern));
774        // should return true for "src" itself (it's the prefix)
775        assert!(settings.could_contain_matches(Path::new("src"), pattern));
776        // should return true for descendants of "src" (matches can be inside)
777        assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
778        assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
779        // should return FALSE for unrelated directories
780        assert!(!settings.could_contain_matches(Path::new("build"), pattern));
781        assert!(!settings.could_contain_matches(Path::new("target"), pattern));
782        assert!(!settings.could_contain_matches(Path::new("build/src"), pattern));
783    }
784    #[test]
785    fn test_could_contain_matches_non_anchored_double_star() {
786        // **/*.rs should match anywhere (no prefix)
787        let mut settings = FilterSettings::new();
788        settings.add_include("**/*.rs").unwrap();
789        let pattern = &settings.includes[0];
790        // should return true for any directory since ** can match anywhere
791        assert!(settings.could_contain_matches(Path::new("src"), pattern));
792        assert!(settings.could_contain_matches(Path::new("build"), pattern));
793        assert!(settings.could_contain_matches(Path::new("any/path"), pattern));
794    }
795    #[test]
796    fn test_could_contain_matches_nested_prefix() {
797        // /src/foo/** should have prefix "src/foo"
798        let mut settings = FilterSettings::new();
799        settings.add_include("/src/foo/**").unwrap();
800        let pattern = &settings.includes[0];
801        // ancestors of prefix
802        assert!(settings.could_contain_matches(Path::new(""), pattern));
803        assert!(settings.could_contain_matches(Path::new("src"), pattern));
804        // the prefix itself
805        assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
806        // descendants of prefix
807        assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
808        // unrelated directories
809        assert!(!settings.could_contain_matches(Path::new("build"), pattern));
810        assert!(!settings.could_contain_matches(Path::new("src/bar"), pattern));
811    }
812    #[test]
813    fn test_extract_literal_prefix() {
814        // test the helper function
815        assert_eq!(FilterSettings::extract_literal_prefix("src/**"), "src");
816        assert_eq!(
817            FilterSettings::extract_literal_prefix("src/foo/**"),
818            "src/foo"
819        );
820        assert_eq!(FilterSettings::extract_literal_prefix("**/*.rs"), "");
821        assert_eq!(FilterSettings::extract_literal_prefix("*.rs"), "");
822        assert_eq!(FilterSettings::extract_literal_prefix("src/*.rs"), "src");
823        // no wildcards = entire pattern is literal
824        assert_eq!(
825            FilterSettings::extract_literal_prefix("src/foo/bar"),
826            "src/foo/bar"
827        );
828        assert_eq!(FilterSettings::extract_literal_prefix("bar"), "bar");
829        assert_eq!(FilterSettings::extract_literal_prefix("src[0-9]/*.rs"), "");
830    }
831    #[test]
832    fn test_directly_matches_include_simple_pattern() {
833        // simple pattern like *.txt matches file
834        let mut settings = FilterSettings::new();
835        settings.add_include("*.txt").unwrap();
836        // should match files with .txt extension
837        assert!(settings.directly_matches_include(Path::new("foo.txt"), false));
838        assert!(settings.directly_matches_include(Path::new("bar/foo.txt"), false));
839        // should not match other files
840        assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
841        // directories don't match file patterns
842        assert!(!settings.directly_matches_include(Path::new("txt"), true));
843    }
844    #[test]
845    fn test_directly_matches_include_anchored_pattern() {
846        // anchored /foo matches at root only
847        let mut settings = FilterSettings::new();
848        settings.add_include("/foo").unwrap();
849        // should match at root
850        assert!(settings.directly_matches_include(Path::new("foo"), true));
851        assert!(settings.directly_matches_include(Path::new("foo"), false));
852        // should not match nested
853        assert!(!settings.directly_matches_include(Path::new("bar/foo"), true));
854    }
855    #[test]
856    fn test_directly_matches_include_empty_includes() {
857        // returns true when no includes
858        let settings = FilterSettings::new();
859        assert!(settings.directly_matches_include(Path::new("anything"), true));
860        assert!(settings.directly_matches_include(Path::new("foo/bar"), false));
861    }
862    #[test]
863    fn test_directly_matches_include_path_pattern() {
864        // path pattern like src/*.rs
865        let mut settings = FilterSettings::new();
866        settings.add_include("src/*.rs").unwrap();
867        // should match paths in src/
868        assert!(settings.directly_matches_include(Path::new("src/foo.rs"), false));
869        // should not match at root or other paths
870        assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
871        assert!(!settings.directly_matches_include(Path::new("other/foo.rs"), false));
872    }
873    #[test]
874    fn test_directly_matches_include_dir_only_pattern() {
875        // directory-only pattern target/
876        let mut settings = FilterSettings::new();
877        settings.add_include("target/").unwrap();
878        // should match directories named target
879        assert!(settings.directly_matches_include(Path::new("target"), true));
880        assert!(settings.directly_matches_include(Path::new("foo/target"), true));
881        // should not match files
882        assert!(!settings.directly_matches_include(Path::new("target"), false));
883    }
884}