Skip to main content

kaish_glob/
filter.rs

1//! rsync-style include/exclude filters.
2//!
3//! Filters are processed in order. The first matching rule wins.
4//! If no rule matches, the path is included by default.
5
6use std::path::Path;
7
8use crate::glob::glob_match;
9use crate::glob_path::GlobPath;
10
11/// Result of checking a path against filters.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FilterResult {
14    /// Path is explicitly included.
15    Include,
16    /// Path is explicitly excluded.
17    Exclude,
18    /// No filter matched this path.
19    NoMatch,
20}
21
22/// An rsync-style include/exclude filter.
23///
24/// Rules are processed in order. The first matching rule determines
25/// whether the path is included or excluded. If no rule matches,
26/// the path is considered to have no explicit ruling (NoMatch).
27///
28/// # Examples
29/// ```
30/// use kaish_glob::{IncludeExclude, FilterResult};
31/// use std::path::Path;
32///
33/// let mut filter = IncludeExclude::new();
34/// filter.include("*.rs");
35/// filter.exclude("*_test.rs");
36///
37/// // Note: order matters! First match wins.
38/// // In this case, *.rs matches first, so test files ARE included.
39/// ```
40#[derive(Debug, Clone, Default)]
41pub struct IncludeExclude {
42    rules: Vec<(FilterAction, CompiledRule)>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46enum FilterAction {
47    Include,
48    Exclude,
49}
50
51#[derive(Debug, Clone)]
52struct CompiledRule {
53    glob: Option<GlobPath>,
54    raw: String,
55}
56
57impl CompiledRule {
58    fn new(pattern: &str) -> Self {
59        let glob = GlobPath::new(pattern).ok();
60
61        Self {
62            glob,
63            raw: pattern.to_string(),
64        }
65    }
66
67    fn matches(&self, path: &Path) -> bool {
68        if let Some(ref glob) = self.glob {
69            return glob.matches(path);
70        }
71
72        // Fallback: simple string matching
73        let path_str = path.to_string_lossy();
74
75        // Also try matching just the filename
76        if let Some(name) = path.file_name() {
77            let name_str = name.to_string_lossy();
78            if glob_match(&self.raw, &name_str) {
79                return true;
80            }
81        }
82
83        glob_match(&self.raw, &path_str)
84    }
85}
86
87impl IncludeExclude {
88    /// Create an empty filter set.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Add an include pattern.
94    ///
95    /// Paths matching this pattern will be included (if checked before
96    /// any exclude pattern matches).
97    pub fn include(&mut self, pattern: &str) {
98        self.rules
99            .push((FilterAction::Include, CompiledRule::new(pattern)));
100    }
101
102    /// Add an exclude pattern.
103    ///
104    /// Paths matching this pattern will be excluded (if checked before
105    /// any include pattern matches).
106    pub fn exclude(&mut self, pattern: &str) {
107        self.rules
108            .push((FilterAction::Exclude, CompiledRule::new(pattern)));
109    }
110
111    /// Check a path against the filter rules.
112    ///
113    /// Returns the first matching rule's action, or `NoMatch` if no rules match.
114    pub fn check(&self, path: &Path) -> FilterResult {
115        for (action, rule) in &self.rules {
116            if rule.matches(path) {
117                return match action {
118                    FilterAction::Include => FilterResult::Include,
119                    FilterAction::Exclude => FilterResult::Exclude,
120                };
121            }
122        }
123
124        FilterResult::NoMatch
125    }
126
127    /// Check if a path should be filtered out.
128    ///
129    /// Returns true if the path should be excluded (either explicitly excluded
130    /// or not matching any include patterns when in strict mode).
131    pub fn should_exclude(&self, path: &Path) -> bool {
132        matches!(self.check(path), FilterResult::Exclude)
133    }
134
135    /// Check if any rules are defined.
136    pub fn is_empty(&self) -> bool {
137        self.rules.is_empty()
138    }
139
140    /// Get the number of rules.
141    pub fn len(&self) -> usize {
142        self.rules.len()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_empty_filter() {
152        let filter = IncludeExclude::new();
153        assert_eq!(filter.check(Path::new("any.txt")), FilterResult::NoMatch);
154        assert!(!filter.should_exclude(Path::new("any.txt")));
155    }
156
157    #[test]
158    fn test_include() {
159        let mut filter = IncludeExclude::new();
160        filter.include("*.rs");
161
162        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
163        assert_eq!(filter.check(Path::new("main.txt")), FilterResult::NoMatch);
164    }
165
166    #[test]
167    fn test_exclude() {
168        let mut filter = IncludeExclude::new();
169        filter.exclude("*.log");
170
171        assert_eq!(filter.check(Path::new("app.log")), FilterResult::Exclude);
172        assert!(filter.should_exclude(Path::new("app.log")));
173        assert!(!filter.should_exclude(Path::new("app.txt")));
174    }
175
176    #[test]
177    fn test_order_matters() {
178        let mut filter = IncludeExclude::new();
179        filter.include("*.rs");
180        filter.exclude("*_test.rs");
181
182        assert_eq!(
183            filter.check(Path::new("parser_test.rs")),
184            FilterResult::Include
185        );
186
187        let mut filter = IncludeExclude::new();
188        filter.exclude("*_test.rs");
189        filter.include("*.rs");
190
191        assert_eq!(
192            filter.check(Path::new("parser_test.rs")),
193            FilterResult::Exclude
194        );
195    }
196
197    #[test]
198    fn test_globstar_patterns() {
199        let mut filter = IncludeExclude::new();
200        filter.include("**/*.rs");
201        filter.exclude("**/test/**");
202
203        assert_eq!(filter.check(Path::new("src/main.rs")), FilterResult::Include);
204        assert_eq!(
205            filter.check(Path::new("src/lib/utils.rs")),
206            FilterResult::Include
207        );
208    }
209
210    #[test]
211    fn test_path_patterns() {
212        let mut filter = IncludeExclude::new();
213        filter.exclude("logs/*");
214
215        assert!(filter.should_exclude(Path::new("logs/app.log")));
216        assert!(!filter.should_exclude(Path::new("other/app.log")));
217    }
218
219    #[test]
220    fn test_multiple_patterns() {
221        let mut filter = IncludeExclude::new();
222        filter.include("*.rs");
223        filter.include("*.go");
224        filter.include("*.py");
225        filter.exclude("*_test.*");
226
227        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
228        assert_eq!(filter.check(Path::new("server.go")), FilterResult::Include);
229        assert_eq!(filter.check(Path::new("main_test.rs")), FilterResult::Include); // *.rs matches first!
230    }
231
232    #[test]
233    fn test_brace_expansion() {
234        let mut filter = IncludeExclude::new();
235        filter.include("*.{rs,go,py}");
236
237        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
238        assert_eq!(filter.check(Path::new("main.go")), FilterResult::Include);
239        assert_eq!(filter.check(Path::new("main.py")), FilterResult::Include);
240        assert_eq!(filter.check(Path::new("main.js")), FilterResult::NoMatch);
241    }
242}