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}
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 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 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 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 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 for s in modified_strs {
174 let time = parse_relative_time(s)?;
175 let now = SystemTime::now();
176 if time < now {
177 after = Some(after.map_or(time, |current| if time > current { time } else { current }));
179 } else {
180 before = Some(before.map_or(time, |current| if time < current { time } else { current }));
182 }
183 }
184
185 Ok((after, before))
186 }
187}
188
189#[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 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 #[must_use]
239 pub fn filter(&self, entries: &[FileEntry], query: &FindQuery) -> Vec<FileEntry> {
240 filter_entries(entries, query)
241 }
242}
243
244#[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 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 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 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 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 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 if query.file_type.is_some_and(|file_type| entry.file_type != file_type) {
300 return false;
301 }
302
303 if !query.hidden
305 && let Some(name) = entry.name()
306 && name.starts_with('.')
307 {
308 return false;
309 }
310
311 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 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#[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 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 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 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 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}