1use crate::cache::{CacheKey, CachePolicy, CacheValue, DiscoveryCache};
6use crate::error::PathError;
7use crate::internal::validation::{MAX_PATTERN_LENGTH, reject_nul_path};
8use crate::listing::{ListOptions, list};
9use crate::metadata::{EntryKind, FileEntry};
10use globset::{Glob, GlobSet, GlobSetBuilder};
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14#[derive(Debug, Clone)]
16pub struct SearchRequest {
17 pub root: PathBuf,
19 pub patterns: Vec<String>,
21 pub exclude_patterns: Vec<String>,
23 pub options: ListOptions,
25 pub cache: CachePolicy,
30}
31
32impl Default for SearchRequest {
33 fn default() -> Self {
34 Self {
35 root: PathBuf::from("."),
36 patterns: Vec::new(),
37 exclude_patterns: Vec::new(),
38 options: ListOptions {
39 recursive: true,
40 ..ListOptions::default()
41 },
42 cache: CachePolicy::Bypass,
43 }
44 }
45}
46
47impl SearchRequest {
48 pub fn new(
50 root: impl Into<PathBuf>,
51 patterns: impl IntoIterator<Item = impl Into<String>>,
52 ) -> Self {
53 Self {
54 root: root.into(),
55 patterns: patterns.into_iter().map(Into::into).collect(),
56 ..Self::default()
57 }
58 }
59
60 pub fn exclude(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
62 self.exclude_patterns
63 .extend(patterns.into_iter().map(Into::into));
64 self
65 }
66
67 pub fn options(mut self, options: ListOptions) -> Self {
69 self.options = options;
70 self
71 }
72
73 pub fn cache_policy(mut self, policy: CachePolicy) -> Self {
75 self.cache = policy;
76 self
77 }
78}
79
80pub fn search(request: &SearchRequest) -> Result<Vec<FileEntry>, PathError> {
91 search_uncached(request)
92}
93
94pub fn search_with_cache(
115 request: &SearchRequest,
116 cache: Option<&dyn DiscoveryCache>,
117) -> Result<Vec<FileEntry>, PathError> {
118 reject_nul_path(&request.root)?;
119 validate_patterns(request)?;
120
121 let key = CacheKey::from_search(request);
122
123 let Some(cache) = cache else {
125 return search_uncached(request);
126 };
127
128 match request.cache {
129 CachePolicy::Bypass => search_uncached(request),
130 CachePolicy::ReadThrough => {
131 if let Some(value) = cache.get(&key)? {
132 if !value.is_expired() {
133 return Ok(value.entries);
134 }
135 }
136 let entries = search_uncached(request)?;
137 cache.put(
138 key,
139 CacheValue {
140 entries: entries.clone(),
141 stored_at: SystemTime::now(),
142 ttl: None,
143 },
144 )?;
145 Ok(entries)
146 }
147 CachePolicy::Refresh => {
148 let entries = search_uncached(request)?;
149 cache.put(
150 key,
151 CacheValue {
152 entries: entries.clone(),
153 stored_at: SystemTime::now(),
154 ttl: None,
155 },
156 )?;
157 Ok(entries)
158 }
159 }
160}
161
162fn validate_patterns(request: &SearchRequest) -> Result<(), PathError> {
163 if request.patterns.is_empty() {
164 return Err(PathError::invalid("search requires at least one pattern"));
165 }
166 for p in request
167 .patterns
168 .iter()
169 .chain(request.exclude_patterns.iter())
170 {
171 if p.len() > MAX_PATTERN_LENGTH {
172 return Err(PathError::InvalidGlob {
173 message: format!("pattern exceeds maximum length ({MAX_PATTERN_LENGTH})"),
174 });
175 }
176 }
177 Ok(())
178}
179
180fn search_uncached(request: &SearchRequest) -> Result<Vec<FileEntry>, PathError> {
181 reject_nul_path(&request.root)?;
182 validate_patterns(request)?;
183
184 let include = build_globset(&request.patterns)?;
185 let exclude = if request.exclude_patterns.is_empty() {
186 None
187 } else {
188 Some(build_globset(&request.exclude_patterns)?)
189 };
190
191 let mut list_opts = request.options.clone();
192 if !list_opts.include_files && !list_opts.include_directories && !list_opts.include_symlinks {
193 list_opts.include_files = true;
194 }
195
196 let listed = list(&request.root, &list_opts)?;
197 let mut out = Vec::new();
198
199 for entry in listed {
200 let rel = entry
201 .relative_path
202 .as_ref()
203 .map(|p| p.to_string_lossy().replace('\\', "/"))
204 .unwrap_or_default();
205
206 let name = entry
207 .path
208 .file_name()
209 .map(|n| n.to_string_lossy().into_owned())
210 .unwrap_or_default();
211
212 let matched = include.is_match(&rel) || include.is_match(&name);
213 if !matched {
214 continue;
215 }
216 if let Some(ex) = &exclude {
217 if ex.is_match(&rel) || ex.is_match(&name) {
218 continue;
219 }
220 }
221 out.push(entry);
222 if let Some(max) = request.options.max_entries {
223 if out.len() >= max {
224 break;
225 }
226 }
227 }
228
229 Ok(out)
230}
231
232fn build_globset(patterns: &[String]) -> Result<GlobSet, PathError> {
233 let mut builder = GlobSetBuilder::new();
234 for pattern in patterns {
235 let glob = Glob::new(pattern).map_err(|e| PathError::InvalidGlob {
236 message: e.to_string(),
237 })?;
238 builder.add(glob);
239 }
240 builder.build().map_err(|e| PathError::InvalidGlob {
241 message: e.to_string(),
242 })
243}
244
245pub fn search_with(
251 root: impl AsRef<Path>,
252 options: &ListOptions,
253 predicate: impl Fn(&FileEntry) -> bool,
254) -> Result<Vec<FileEntry>, PathError> {
255 let listed = list(root, options)?;
256 let mut out: Vec<FileEntry> = listed.into_iter().filter(|e| predicate(e)).collect();
257 if let Some(max) = options.max_entries {
258 out.truncate(max);
259 }
260 Ok(out)
261}
262
263pub mod predicates {
265 use super::*;
266 use std::ffi::OsStr;
267 use std::time::SystemTime;
268
269 pub fn extension(ext: impl AsRef<OsStr>) -> impl Fn(&FileEntry) -> bool {
271 let ext = ext.as_ref().to_os_string();
272 move |entry: &FileEntry| entry.path.extension() == Some(ext.as_os_str())
273 }
274
275 pub fn filename(name: impl AsRef<OsStr>) -> impl Fn(&FileEntry) -> bool {
277 let name = name.as_ref().to_os_string();
278 move |entry: &FileEntry| entry.path.file_name() == Some(name.as_os_str())
279 }
280
281 pub fn kind(expected: EntryKind) -> impl Fn(&FileEntry) -> bool {
283 move |entry: &FileEntry| entry.kind == expected
284 }
285
286 pub fn min_size(min: u64) -> impl Fn(&FileEntry) -> bool {
288 move |entry: &FileEntry| entry.size.is_some_and(|s| s >= min)
289 }
290
291 pub fn max_size(max: u64) -> impl Fn(&FileEntry) -> bool {
293 move |entry: &FileEntry| entry.size.is_some_and(|s| s <= max)
294 }
295
296 pub fn modified_after(t: SystemTime) -> impl Fn(&FileEntry) -> bool {
298 move |entry: &FileEntry| entry.modified.is_some_and(|m| m > t)
299 }
300
301 pub fn modified_before(t: SystemTime) -> impl Fn(&FileEntry) -> bool {
303 move |entry: &FileEntry| entry.modified.is_some_and(|m| m < t)
304 }
305
306 pub fn is_hidden() -> impl Fn(&FileEntry) -> bool {
308 move |entry: &FileEntry| {
309 entry
310 .path
311 .file_name()
312 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
313 }
314 }
315}
316
317#[cfg(feature = "async")]
318pub async fn search_async(request: SearchRequest) -> Result<Vec<FileEntry>, PathError> {
320 tokio::task::spawn_blocking(move || search(&request))
321 .await
322 .map_err(|e| PathError::traversal(format!("async search join failed: {e}")))?
323}