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    /// Decide whether a walk entry is filtered out.
128    ///
129    /// `relative` is the walk-relative path and `name` the basename (patterns
130    /// like `*_test.rs` are written against filenames); the first
131    /// representation that matches any rule decides. When include rules
132    /// exist, a *file* matching none of them is excluded — but a directory
133    /// never is, so traversal can still reach included files below it.
134    /// Exclude rules apply to directories too (pruning the subtree).
135    pub fn excludes_entry(&self, relative: &Path, name: Option<&Path>, is_dir: bool) -> bool {
136        for path in [Some(relative), name].into_iter().flatten() {
137            match self.check(path) {
138                FilterResult::Exclude => return true,
139                FilterResult::Include => return false,
140                FilterResult::NoMatch => {}
141            }
142        }
143        !is_dir && self.has_includes()
144    }
145
146    /// Whether any include rules are present.
147    pub fn has_includes(&self) -> bool {
148        self.rules
149            .iter()
150            .any(|(action, _)| *action == FilterAction::Include)
151    }
152
153    /// Check if any rules are defined.
154    pub fn is_empty(&self) -> bool {
155        self.rules.is_empty()
156    }
157
158    /// Get the number of rules.
159    pub fn len(&self) -> usize {
160        self.rules.len()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_empty_filter() {
170        let filter = IncludeExclude::new();
171        assert_eq!(filter.check(Path::new("any.txt")), FilterResult::NoMatch);
172        assert!(!filter.excludes_entry(Path::new("any.txt"), None, false));
173    }
174
175    #[test]
176    fn test_include() {
177        let mut filter = IncludeExclude::new();
178        filter.include("*.rs");
179
180        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
181        assert_eq!(filter.check(Path::new("main.txt")), FilterResult::NoMatch);
182    }
183
184    #[test]
185    fn test_exclude() {
186        let mut filter = IncludeExclude::new();
187        filter.exclude("*.log");
188
189        assert_eq!(filter.check(Path::new("app.log")), FilterResult::Exclude);
190        assert!(filter.excludes_entry(Path::new("app.log"), None, false));
191        assert!(!filter.excludes_entry(Path::new("app.txt"), None, false));
192    }
193
194    #[test]
195    fn test_order_matters() {
196        let mut filter = IncludeExclude::new();
197        filter.include("*.rs");
198        filter.exclude("*_test.rs");
199
200        assert_eq!(
201            filter.check(Path::new("parser_test.rs")),
202            FilterResult::Include
203        );
204
205        let mut filter = IncludeExclude::new();
206        filter.exclude("*_test.rs");
207        filter.include("*.rs");
208
209        assert_eq!(
210            filter.check(Path::new("parser_test.rs")),
211            FilterResult::Exclude
212        );
213    }
214
215    #[test]
216    fn test_globstar_patterns() {
217        let mut filter = IncludeExclude::new();
218        filter.include("**/*.rs");
219        filter.exclude("**/test/**");
220
221        assert_eq!(filter.check(Path::new("src/main.rs")), FilterResult::Include);
222        assert_eq!(
223            filter.check(Path::new("src/lib/utils.rs")),
224            FilterResult::Include
225        );
226    }
227
228    #[test]
229    fn test_path_patterns() {
230        let mut filter = IncludeExclude::new();
231        filter.exclude("logs/*");
232
233        assert!(filter.excludes_entry(Path::new("logs/app.log"), None, false));
234        assert!(!filter.excludes_entry(Path::new("other/app.log"), None, false));
235    }
236
237    #[test]
238    fn test_multiple_patterns() {
239        let mut filter = IncludeExclude::new();
240        filter.include("*.rs");
241        filter.include("*.go");
242        filter.include("*.py");
243        filter.exclude("*_test.*");
244
245        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
246        assert_eq!(filter.check(Path::new("server.go")), FilterResult::Include);
247        assert_eq!(filter.check(Path::new("main_test.rs")), FilterResult::Include); // *.rs matches first!
248    }
249
250    #[test]
251    fn test_brace_expansion() {
252        let mut filter = IncludeExclude::new();
253        filter.include("*.{rs,go,py}");
254
255        assert_eq!(filter.check(Path::new("main.rs")), FilterResult::Include);
256        assert_eq!(filter.check(Path::new("main.go")), FilterResult::Include);
257        assert_eq!(filter.check(Path::new("main.py")), FilterResult::Include);
258        assert_eq!(filter.check(Path::new("main.js")), FilterResult::NoMatch);
259    }
260
261    #[test]
262    fn include_list_excludes_nonmatching_files_but_not_dirs() {
263        let mut filter = IncludeExclude::new();
264        filter.include("*.rs");
265
266        // A file matching no include rule is out...
267        assert!(filter.excludes_entry(Path::new("notes.txt"), None, false));
268        // ...a matching file is in (by relative path or by basename)...
269        assert!(!filter.excludes_entry(Path::new("main.rs"), None, false));
270        assert!(!filter.excludes_entry(
271            Path::new("src/lib.rs"),
272            Some(Path::new("lib.rs")),
273            false
274        ));
275        // ...and a directory is never excluded by include-miss.
276        assert!(!filter.excludes_entry(Path::new("src"), None, true));
277    }
278
279    #[test]
280    fn exclude_rules_still_prune_directories() {
281        let mut filter = IncludeExclude::new();
282        filter.include("*.rs");
283        filter.exclude("target");
284
285        assert!(filter.excludes_entry(Path::new("target"), None, true));
286    }
287}