Skip to main content

syncweb_core/
search.rs

1//! Search and ranking APIs for local and synchronized entries.
2
3use std::{
4    path::{Path, PathBuf},
5    time::SystemTime,
6};
7
8use regex::Regex;
9
10use crate::{
11    error::{Result, SyncwebError},
12    fs::{FileEntry, FileType, ParallelScanner},
13    parsing::{parse_relative_time, parse_size_constraint},
14};
15
16/// How a find expression is interpreted.
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
18#[non_exhaustive]
19pub enum MatchKind {
20    Exact,
21    #[default]
22    Glob,
23    Regex,
24}
25
26/// Filters supported by [`FindEngine`].
27#[derive(Clone, Debug, Default)]
28#[non_exhaustive]
29pub struct FindQuery {
30    pub pattern: String,
31    pub kind: MatchKind,
32    pub max_depth: Option<usize>,
33    pub min_depth: Option<usize>,
34    pub min_size: Option<u64>,
35    pub max_size: Option<u64>,
36    pub modified_after: Option<SystemTime>,
37    pub modified_before: Option<SystemTime>,
38    pub extension: Option<String>,
39    pub extensions: Vec<String>,
40    pub file_type: Option<FileType>,
41    pub case_sensitive: Option<bool>,
42    pub fixed_strings: bool,
43    pub full_path: bool,
44    pub hidden: bool,
45    pub follow_links: bool,
46    pub absolute_path: bool,
47    pub downloadable: bool,
48}
49
50impl FindQuery {
51    #[must_use]
52    pub fn exact(pattern: impl Into<String>) -> Self {
53        Self {
54            pattern: pattern.into(),
55            kind: MatchKind::Exact,
56            ..Self::default()
57        }
58    }
59
60    #[must_use]
61    pub fn glob(pattern: impl Into<String>) -> Self {
62        Self {
63            pattern: pattern.into(),
64            kind: MatchKind::Glob,
65            ..Self::default()
66        }
67    }
68
69    #[must_use]
70    pub fn regex(pattern: impl Into<String>) -> Self {
71        Self {
72            pattern: pattern.into(),
73            kind: MatchKind::Regex,
74            ..Self::default()
75        }
76    }
77
78    #[must_use]
79    pub const fn depth(mut self, max_depth: usize) -> Self {
80        self.max_depth = Some(max_depth);
81        self
82    }
83
84    #[must_use]
85    pub const fn size(mut self, min: Option<u64>, max: Option<u64>) -> Self {
86        self.min_size = min;
87        self.max_size = max;
88        self
89    }
90
91    #[must_use]
92    pub fn extension(mut self, extension: impl Into<String>) -> Self {
93        self.extension = Some(extension.into().trim_start_matches('.').to_owned());
94        self
95    }
96
97    #[must_use]
98    pub fn extensions(mut self, exts: Vec<String>) -> Self {
99        self.extensions = exts
100            .into_iter()
101            .map(|e| e.trim_start_matches('.').to_lowercase())
102            .collect();
103        self
104    }
105
106    #[must_use]
107    pub const fn file_type(mut self, file_type: FileType) -> Self {
108        self.file_type = Some(file_type);
109        self
110    }
111
112    #[must_use]
113    pub const fn modified_after(mut self, time: SystemTime) -> Self {
114        self.modified_after = Some(time);
115        self
116    }
117
118    #[must_use]
119    pub const fn modified_before(mut self, time: SystemTime) -> Self {
120        self.modified_before = Some(time);
121        self
122    }
123
124    /// Parse size constraints from fd-find style strings.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the string cannot be parsed.
129    pub fn parse_size_constraints(size_strs: &[String]) -> Result<(Option<u64>, Option<u64>)> {
130        let mut min_size: Option<u64> = None;
131        let mut max_size: Option<u64> = None;
132
133        for s in size_strs {
134            let (min, max) = parse_size_constraint(s)?;
135            if let Some(new_min) = min {
136                min_size = Some(min_size.map_or(new_min, |current| current.max(new_min)));
137            }
138            if let Some(new_max) = max {
139                max_size = Some(max_size.map_or(new_max, |current| current.min(new_max)));
140            }
141        }
142
143        Ok((min_size, max_size))
144    }
145
146    /// Parse time constraints from human-readable strings.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the string cannot be parsed.
151    pub fn parse_time_constraints(
152        within_strs: &[String],
153        before_strs: &[String],
154        modified_strs: &[String],
155    ) -> Result<(Option<SystemTime>, Option<SystemTime>)> {
156        let mut after: Option<SystemTime> = None;
157        let mut before: Option<SystemTime> = None;
158
159        // "3 days" means after = now - 3 days
160        for s in within_strs {
161            let time = parse_relative_time(&format!("-{s}"))?;
162            after = Some(after.map_or(time, |current| if time > current { time } else { current }));
163        }
164
165        // "3 years" means before = now - 3 years (older than)
166        for s in before_strs {
167            let time = parse_relative_time(&format!("-{s}"))?;
168            before = Some(before.map_or(time, |current| if time < current { time } else { current }));
169        }
170
171        // "-3 days" means after = now - 3 days (newer than)
172        // "+3 days" means before = now - 3 days (older than)
173        for s in modified_strs {
174            let time = parse_relative_time(s)?;
175            let now = SystemTime::now();
176            if time < now {
177                // Past time
178                after = Some(after.map_or(time, |current| if time > current { time } else { current }));
179            } else {
180                // Future time (shouldn't happen, but handle gracefully)
181                before = Some(before.map_or(time, |current| if time < current { time } else { current }));
182            }
183        }
184
185        Ok((after, before))
186    }
187}
188
189/// Recursive filesystem search engine.
190#[derive(Clone)]
191pub struct FindEngine {
192    root: PathBuf,
193    ignore_patterns: Vec<String>,
194    threads: usize,
195}
196
197impl FindEngine {
198    #[must_use]
199    pub fn new(root: impl Into<PathBuf>) -> Self {
200        Self {
201            root: root.into(),
202            ignore_patterns: Vec::new(),
203            threads: 0,
204        }
205    }
206
207    #[must_use]
208    pub fn with_ignore_patterns(mut self, patterns: Vec<String>) -> Self {
209        self.ignore_patterns = patterns;
210        self
211    }
212
213    #[must_use]
214    pub const fn with_threads(mut self, threads: usize) -> Self {
215        self.threads = threads;
216        self
217    }
218
219    /// Scan the root and apply a query.
220    /// # Errors
221    ///
222    /// Returns an error if the filesystem cannot be accessed.
223    pub fn find(&self, query: &FindQuery) -> Result<Vec<FileEntry>> {
224        let scanner = ParallelScanner::new(&self.root, self.ignore_patterns.clone(), self.threads);
225        let entries = if query.file_type.is_some_and(|kind| kind != FileType::File) {
226            scanner.scan_all()?
227        } else {
228            scanner.scan()?
229        };
230        if query.kind == MatchKind::Regex {
231            Regex::new(&query.pattern)
232                .map_err(|error| SyncwebError::operation("invalid find regular expression", error))?;
233        }
234        Ok(filter_entries(&entries, query))
235    }
236
237    /// Apply a query to already indexed metadata without reading file content.
238    #[must_use]
239    pub fn filter(&self, entries: &[FileEntry], query: &FindQuery) -> Vec<FileEntry> {
240        filter_entries(entries, query)
241    }
242}
243
244/// Filter an existing metadata index.
245#[must_use]
246pub fn filter_entries(entries: &[FileEntry], query: &FindQuery) -> Vec<FileEntry> {
247    let regex = (query.kind == MatchKind::Regex)
248        .then(|| Regex::new(&query.pattern).ok())
249        .flatten();
250    entries
251        .iter()
252        .filter(|entry| {
253            // Depth filtering
254            if let Some(max_depth) = query.max_depth
255                && entry.relative_path.components().count() > max_depth
256            {
257                return false;
258            }
259            if let Some(min_depth) = query.min_depth
260                && entry.relative_path.components().count() < min_depth
261            {
262                return false;
263            }
264
265            // Size filtering
266            if query.min_size.is_some_and(|size| entry.size < size)
267                || query.max_size.is_some_and(|size| entry.size > size)
268            {
269                return false;
270            }
271
272            // Time filtering
273            if query.modified_after.is_some_and(|time| entry.modified <= time)
274                || query.modified_before.is_some_and(|time| entry.modified >= time)
275            {
276                return false;
277            }
278
279            // Single extension filtering
280            if query.extension.as_ref().is_some_and(|extension| {
281                entry.path.extension().and_then(|value| value.to_str()) != Some(extension.as_str())
282            }) {
283                return false;
284            }
285
286            // Multiple extensions filtering (OR logic)
287            if !query.extensions.is_empty() {
288                let has_valid_extension = entry
289                    .path
290                    .extension()
291                    .and_then(|value| value.to_str())
292                    .is_some_and(|ext| query.extensions.iter().any(|e| e == ext));
293                if !has_valid_extension {
294                    return false;
295                }
296            }
297
298            // File type filtering
299            if query.file_type.is_some_and(|file_type| entry.file_type != file_type) {
300                return false;
301            }
302
303            // Hidden files filtering
304            if !query.hidden
305                && let Some(name) = entry.name()
306                && name.starts_with('.')
307            {
308                return false;
309            }
310
311            // Pattern matching
312            if query.pattern.is_empty() {
313                return true;
314            }
315
316            let relative = entry.relative_path.to_string_lossy();
317            let name = entry.name().unwrap_or_default();
318            let search_target = if query.full_path { relative.as_ref() } else { name };
319
320            match query.kind {
321                MatchKind::Exact => {
322                    if query.fixed_strings {
323                        search_target.contains(&query.pattern)
324                    } else {
325                        // Case-insensitive exact match
326                        let pattern = if query.case_sensitive.unwrap_or(false) {
327                            query.pattern.clone()
328                        } else {
329                            query.pattern.to_lowercase()
330                        };
331                        let target = if query.case_sensitive.unwrap_or(false) {
332                            search_target.to_string()
333                        } else {
334                            search_target.to_lowercase()
335                        };
336                        target.contains(&pattern)
337                    }
338                }
339                MatchKind::Glob => {
340                    let ignore_case = query
341                        .case_sensitive
342                        .map_or_else(|| query.pattern.to_lowercase() == query.pattern, |cs| !cs);
343                    glob_match_case(&query.pattern, search_target, ignore_case)
344                }
345                MatchKind::Regex => {
346                    let ignore_case = query
347                        .case_sensitive
348                        .map_or_else(|| query.pattern.to_lowercase() == query.pattern, |cs| !cs);
349                    regex.as_ref().is_some_and(|re| {
350                        if ignore_case {
351                            re.is_match(search_target)
352                        } else {
353                            re.captures(search_target).is_some()
354                        }
355                    })
356                }
357            }
358        })
359        .cloned()
360        .collect()
361}
362
363fn glob_match(pattern: &str, value: &str) -> bool {
364    globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(value))
365}
366
367fn glob_match_case(pattern: &str, value: &str, ignore_case: bool) -> bool {
368    if ignore_case {
369        let pattern_lower = pattern.to_lowercase();
370        let value_lower = value.to_lowercase();
371        glob_match(&pattern_lower, &value_lower)
372    } else {
373        glob_match(pattern, value)
374    }
375}
376
377/// Kept as a small helper for callers that need to calculate depth.
378#[must_use]
379pub fn path_depth(path: &Path) -> usize {
380    path.components().count()
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::time::UNIX_EPOCH;
387
388    #[test]
389    fn test_parse_size_constraints() {
390        let empty: Vec<String> = vec![];
391        let (min1, max1) = FindQuery::parse_size_constraints(&empty).unwrap();
392        assert_eq!(min1, None);
393        assert_eq!(max1, None);
394
395        let (min2, max2) = FindQuery::parse_size_constraints(&["5GB".to_string()]).unwrap();
396        assert_eq!(min2, Some(5_000_000_000));
397        assert_eq!(max2, Some(5_000_000_000));
398
399        let (min3, max3) = FindQuery::parse_size_constraints(&["-5GB".to_string()]).unwrap();
400        assert_eq!(min3, None);
401        assert_eq!(max3, Some(4_999_999_999));
402    }
403
404    #[test]
405    fn test_filter_entries() {
406        let entries = vec![
407            FileEntry {
408                path: PathBuf::from("/test/file1.txt"),
409                relative_path: PathBuf::from("file1.txt"),
410                size: 100,
411                modified: UNIX_EPOCH,
412                hash: blake3::hash(b""),
413                file_type: FileType::File,
414            },
415            FileEntry {
416                path: PathBuf::from("/test/.hidden"),
417                relative_path: PathBuf::from(".hidden"),
418                size: 50,
419                modified: UNIX_EPOCH,
420                hash: blake3::hash(b""),
421                file_type: FileType::File,
422            },
423            FileEntry {
424                path: PathBuf::from("/test/subdir/file2.txt"),
425                relative_path: PathBuf::from("subdir/file2.txt"),
426                size: 200,
427                modified: UNIX_EPOCH,
428                hash: blake3::hash(b""),
429                file_type: FileType::File,
430            },
431        ];
432
433        // Test hidden files filtering
434        let query1 = FindQuery {
435            hidden: false,
436            ..FindQuery::default()
437        };
438        let filtered1 = filter_entries(&entries, &query1);
439        assert_eq!(filtered1.len(), 2);
440
441        // Test hidden files shown
442        let query2 = FindQuery {
443            hidden: true,
444            ..FindQuery::default()
445        };
446        let filtered2 = filter_entries(&entries, &query2);
447        assert_eq!(filtered2.len(), 3);
448
449        // Test depth filtering
450        let query3 = FindQuery {
451            max_depth: Some(1),
452            hidden: true,
453            ..FindQuery::default()
454        };
455        let filtered3 = filter_entries(&entries, &query3);
456        assert_eq!(filtered3.len(), 2);
457
458        // Test size filtering
459        let query4 = FindQuery {
460            min_size: Some(150),
461            hidden: true,
462            ..FindQuery::default()
463        };
464        let filtered4 = filter_entries(&entries, &query4);
465        assert_eq!(filtered4.len(), 1);
466        assert_eq!(filtered4.first().unwrap().path, PathBuf::from("/test/subdir/file2.txt"));
467    }
468}