rust_filesearch/fs/
filters.rs

1use crate::errors::{FsError, Result};
2use crate::models::{Entry, EntryKind, FileCategory};
3use crate::util::{parse_date, parse_size};
4use chrono::{DateTime, Utc};
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use regex::Regex;
7
8/// A predicate that can be applied to entries
9pub trait Predicate: Send + Sync {
10    fn test(&self, entry: &Entry) -> bool;
11}
12
13/// Combines multiple predicates with AND logic
14pub struct AndPredicate {
15    predicates: Vec<Box<dyn Predicate>>,
16}
17
18impl AndPredicate {
19    pub fn new(predicates: Vec<Box<dyn Predicate>>) -> Self {
20        Self { predicates }
21    }
22}
23
24impl Predicate for AndPredicate {
25    fn test(&self, entry: &Entry) -> bool {
26        self.predicates.iter().all(|p| p.test(entry))
27    }
28}
29
30/// Glob pattern filter
31pub struct GlobFilter {
32    globset: GlobSet,
33}
34
35impl GlobFilter {
36    pub fn new(patterns: &[String]) -> Result<Self> {
37        let mut builder = GlobSetBuilder::new();
38        for pattern in patterns {
39            let glob = Glob::new(pattern).map_err(|e| FsError::InvalidGlob {
40                pattern: pattern.clone(),
41                source: e,
42            })?;
43            builder.add(glob);
44        }
45        let globset = builder.build().map_err(|e| FsError::InvalidGlob {
46            pattern: "combined".to_string(),
47            source: e,
48        })?;
49        Ok(Self { globset })
50    }
51}
52
53impl Predicate for GlobFilter {
54    fn test(&self, entry: &Entry) -> bool {
55        self.globset.is_match(&entry.name)
56    }
57}
58
59/// Regex pattern filter
60pub struct RegexFilter {
61    regex: Regex,
62}
63
64impl RegexFilter {
65    pub fn new(pattern: &str) -> Result<Self> {
66        let regex = Regex::new(pattern).map_err(|e| FsError::InvalidRegex {
67            pattern: pattern.to_string(),
68            source: e,
69        })?;
70        Ok(Self { regex })
71    }
72}
73
74impl Predicate for RegexFilter {
75    fn test(&self, entry: &Entry) -> bool {
76        self.regex.is_match(&entry.name)
77    }
78}
79
80/// Extension filter
81pub struct ExtensionFilter {
82    extensions: Vec<String>,
83}
84
85impl ExtensionFilter {
86    pub fn new(extensions: &[String]) -> Self {
87        Self {
88            extensions: extensions.iter().map(|e| e.to_lowercase()).collect(),
89        }
90    }
91}
92
93impl Predicate for ExtensionFilter {
94    fn test(&self, entry: &Entry) -> bool {
95        if let Some(ext) = entry.path.extension().and_then(|e| e.to_str()) {
96            self.extensions.contains(&ext.to_lowercase())
97        } else {
98            false
99        }
100    }
101}
102
103/// Size range filter
104pub struct SizeFilter {
105    min: Option<u64>,
106    max: Option<u64>,
107}
108
109impl SizeFilter {
110    pub fn new(min: Option<&str>, max: Option<&str>) -> Result<Self> {
111        let min = min.map(parse_size).transpose()?;
112        let max = max.map(parse_size).transpose()?;
113        Ok(Self { min, max })
114    }
115}
116
117impl Predicate for SizeFilter {
118    fn test(&self, entry: &Entry) -> bool {
119        if entry.kind == EntryKind::Dir {
120            return true; // Skip dirs for size filtering
121        }
122
123        if let Some(min) = self.min {
124            if entry.size < min {
125                return false;
126            }
127        }
128
129        if let Some(max) = self.max {
130            if entry.size > max {
131                return false;
132            }
133        }
134
135        true
136    }
137}
138
139/// Date range filter
140pub struct DateFilter {
141    after: Option<DateTime<Utc>>,
142    before: Option<DateTime<Utc>>,
143}
144
145impl DateFilter {
146    pub fn new(after: Option<&str>, before: Option<&str>) -> Result<Self> {
147        let after = after.map(parse_date).transpose()?;
148        let before = before.map(parse_date).transpose()?;
149        Ok(Self { after, before })
150    }
151}
152
153impl Predicate for DateFilter {
154    fn test(&self, entry: &Entry) -> bool {
155        if let Some(after) = &self.after {
156            if entry.mtime < *after {
157                return false;
158            }
159        }
160
161        if let Some(before) = &self.before {
162            if entry.mtime > *before {
163                return false;
164            }
165        }
166
167        true
168    }
169}
170
171/// Kind filter
172pub struct KindFilter {
173    kinds: Vec<EntryKind>,
174}
175
176impl KindFilter {
177    pub fn new(kinds: &[EntryKind]) -> Self {
178        Self {
179            kinds: kinds.to_vec(),
180        }
181    }
182}
183
184impl Predicate for KindFilter {
185    fn test(&self, entry: &Entry) -> bool {
186        self.kinds.contains(&entry.kind)
187    }
188}
189
190/// Category filter - matches files by smart categorization
191pub struct CategoryFilter {
192    category: String,
193}
194
195impl CategoryFilter {
196    pub fn new(category: &str) -> Self {
197        Self {
198            category: category.to_lowercase(),
199        }
200    }
201
202    /// Check if a FileCategory matches the filter
203    fn matches_category(&self, file_category: &FileCategory) -> bool {
204        match self.category.as_str() {
205            "source" => matches!(file_category, FileCategory::Source { .. }),
206            "build" => matches!(file_category, FileCategory::Build),
207            "config" => matches!(file_category, FileCategory::Config { .. }),
208            "docs" | "documentation" => matches!(file_category, FileCategory::Documentation),
209            "media" => matches!(file_category, FileCategory::Media { .. }),
210            "image" => matches!(
211                file_category,
212                FileCategory::Media {
213                    media_type: crate::models::MediaType::Image
214                }
215            ),
216            "video" => matches!(
217                file_category,
218                FileCategory::Media {
219                    media_type: crate::models::MediaType::Video
220                }
221            ),
222            "audio" => matches!(
223                file_category,
224                FileCategory::Media {
225                    media_type: crate::models::MediaType::Audio
226                }
227            ),
228            "data" => matches!(file_category, FileCategory::Data { .. }),
229            "archive" => matches!(file_category, FileCategory::Archive),
230            "executable" | "exec" => matches!(file_category, FileCategory::Executable),
231            _ => false,
232        }
233    }
234}
235
236impl Predicate for CategoryFilter {
237    fn test(&self, entry: &Entry) -> bool {
238        // Only categorize files, not directories
239        if entry.kind != EntryKind::File {
240            return false;
241        }
242
243        // Get file extension
244        if let Some(ext) = entry.path.extension().and_then(|e| e.to_str()) {
245            let category = FileCategory::from_extension(ext);
246            self.matches_category(&category)
247        } else {
248            false
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use std::path::PathBuf;
257
258    fn make_test_entry(name: &str, size: u64, kind: EntryKind) -> Entry {
259        use chrono::Utc;
260
261        Entry {
262            path: PathBuf::from(name),
263            name: name.to_string(),
264            size,
265            kind,
266            mtime: Utc::now(),
267            perms: None,
268            owner: None,
269            depth: 0,
270        }
271    }
272
273    #[test]
274    fn test_glob_filter() {
275        let filter = GlobFilter::new(&["*.rs".to_string()]).unwrap();
276        assert!(filter.test(&make_test_entry("main.rs", 100, EntryKind::File)));
277        assert!(!filter.test(&make_test_entry("main.txt", 100, EntryKind::File)));
278    }
279
280    #[test]
281    fn test_regex_filter() {
282        let filter = RegexFilter::new(r"^test_.*\.rs$").unwrap();
283        assert!(filter.test(&make_test_entry("test_foo.rs", 100, EntryKind::File)));
284        assert!(!filter.test(&make_test_entry("main.rs", 100, EntryKind::File)));
285    }
286
287    #[test]
288    fn test_extension_filter() {
289        let filter = ExtensionFilter::new(&["rs".to_string(), "toml".to_string()]);
290        assert!(filter.test(&make_test_entry("main.rs", 100, EntryKind::File)));
291        assert!(filter.test(&make_test_entry("Cargo.toml", 100, EntryKind::File)));
292        assert!(!filter.test(&make_test_entry("readme.md", 100, EntryKind::File)));
293    }
294
295    #[test]
296    fn test_size_filter() {
297        let filter = SizeFilter::new(Some("1KB"), Some("10KB")).unwrap();
298        assert!(!filter.test(&make_test_entry("small.txt", 500, EntryKind::File)));
299        assert!(filter.test(&make_test_entry("medium.txt", 5000, EntryKind::File)));
300        assert!(!filter.test(&make_test_entry("large.txt", 20000, EntryKind::File)));
301    }
302
303    #[test]
304    fn test_kind_filter() {
305        let filter = KindFilter::new(&[EntryKind::File]);
306        assert!(filter.test(&make_test_entry("file.txt", 100, EntryKind::File)));
307        assert!(!filter.test(&make_test_entry("dir", 0, EntryKind::Dir)));
308    }
309
310    #[test]
311    fn test_category_filter_source() {
312        let filter = CategoryFilter::new("source");
313        assert!(filter.test(&make_test_entry("main.rs", 100, EntryKind::File)));
314        assert!(filter.test(&make_test_entry("app.py", 100, EntryKind::File)));
315        assert!(!filter.test(&make_test_entry("image.png", 100, EntryKind::File)));
316    }
317
318    #[test]
319    fn test_category_filter_media() {
320        let filter = CategoryFilter::new("image");
321        assert!(filter.test(&make_test_entry("photo.jpg", 100, EntryKind::File)));
322        assert!(filter.test(&make_test_entry("icon.png", 100, EntryKind::File)));
323        assert!(!filter.test(&make_test_entry("video.mp4", 100, EntryKind::File)));
324    }
325
326    #[test]
327    fn test_category_filter_config() {
328        let filter = CategoryFilter::new("config");
329        assert!(filter.test(&make_test_entry("Cargo.toml", 100, EntryKind::File)));
330        assert!(filter.test(&make_test_entry("config.yaml", 100, EntryKind::File)));
331        assert!(!filter.test(&make_test_entry("main.rs", 100, EntryKind::File)));
332    }
333}