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