1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
18#[non_exhaustive]
19pub enum MatchKind {
20 Exact,
21 #[default]
22 Glob,
23 Regex,
24}
25
26#[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 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 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 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 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 for s in modified_strs {
175 let time = parse_relative_time(s)?;
176 let now = SystemTime::now();
177 if time < now {
178 after = Some(after.map_or(time, |current| if time > current { time } else { current }));
180 } else {
181 before = Some(before.map_or(time, |current| if time < current { time } else { current }));
183 }
184 }
185
186 Ok((after, before))
187 }
188}
189
190#[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 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 #[must_use]
240 pub fn filter(&self, entries: &[FileEntry], query: &FindQuery) -> Vec<FileEntry> {
241 filter_entries(entries, query)
242 }
243}
244
245#[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 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 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 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 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 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 if query.file_type.is_some_and(|file_type| entry.file_type != file_type) {
301 return false;
302 }
303
304 if !query.hidden
306 && let Some(name) = entry.name()
307 && name.starts_with('.')
308 {
309 return false;
310 }
311
312 if query.downloadable
314 && let Some(ref mode) = query.sync_mode
315 && (mode == "publicreadonly" || mode == "sendonly")
316 {
317 return false;
318 }
319
320 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 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#[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 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 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 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 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}