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
8pub trait Predicate: Send + Sync {
10 fn test(&self, entry: &Entry) -> bool;
11}
12
13pub 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
30pub 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
59pub 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
80pub 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
103pub 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; }
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
139pub 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
171pub 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
190pub 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 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 if entry.kind != EntryKind::File {
240 return false;
241 }
242
243 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}