Skip to main content

path_rs/
search.rs

1//! Glob and predicate-based path search.
2//!
3//! Enabled by the `search` feature (default). Depends on `listing`.
4
5use 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/// Request for a glob-based search.
15#[derive(Debug, Clone)]
16pub struct SearchRequest {
17    /// Root directory to search.
18    pub root: PathBuf,
19    /// Include glob patterns (matched against paths relative to `root`).
20    pub patterns: Vec<String>,
21    /// Optional exclude patterns.
22    pub exclude_patterns: Vec<String>,
23    /// Listing / traversal options.
24    pub options: ListOptions,
25    /// Cache policy for this request.
26    ///
27    /// Caching only occurs when an explicit [`DiscoveryCache`] is passed to
28    /// [`search_with_cache`]. [`search`] never reads or writes a cache.
29    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    /// Create a search request with include patterns and default options.
49    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    /// Add exclude patterns.
61    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    /// Set list options.
68    pub fn options(mut self, options: ListOptions) -> Self {
69        self.options = options;
70        self
71    }
72
73    /// Set cache policy (only effective with an explicit cache in [`search_with_cache`]).
74    pub fn cache_policy(mut self, policy: CachePolicy) -> Self {
75        self.cache = policy;
76        self
77    }
78}
79
80/// Search for entries under `request.root` matching include patterns.
81///
82/// Patterns are matched against the path relative to the search root using `globset`.
83///
84/// This function **never** uses a cache (equivalent to [`CachePolicy::Bypass`]).
85/// For caching, call [`search_with_cache`] with an explicit [`DiscoveryCache`].
86///
87/// # Filesystem access
88///
89/// **Yes.** Requires the root to exist.
90pub fn search(request: &SearchRequest) -> Result<Vec<FileEntry>, PathError> {
91    search_uncached(request)
92}
93
94/// Search using an explicit discovery cache.
95///
96/// # Cache rules
97///
98/// | Policy | Without `cache` | With `cache` |
99/// | --- | --- | --- |
100/// | [`CachePolicy::Bypass`] | Scan only | Scan only (no read/write) |
101/// | [`CachePolicy::ReadThrough`] | Scan only | Read then maybe write |
102/// | [`CachePolicy::Refresh`] | Scan only | Scan and write |
103///
104/// There is **no** process-global default cache. Callers that want caching must
105/// supply their own [`MemoryCache`] or [`crate::cache::PersistentCache`].
106///
107/// # Filesystem access
108///
109/// **Yes.** Requires the root to exist.
110///
111/// # Security
112///
113/// Cache hits are performance artifacts only. Re-validate paths before security-sensitive use.
114pub 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    // Without an explicit cache, never use global state — always scan.
124    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
245/// Search with a predicate closure.
246///
247/// # Filesystem access
248///
249/// **Yes.**
250pub 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
263/// Built-in entry predicates for common filters.
264pub mod predicates {
265    use super::*;
266    use std::ffi::OsStr;
267    use std::time::SystemTime;
268
269    /// Match entries with the given extension (without the dot), case-sensitive.
270    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    /// Match entries whose file name equals `name`.
276    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    /// Match a specific entry kind.
282    pub fn kind(expected: EntryKind) -> impl Fn(&FileEntry) -> bool {
283        move |entry: &FileEntry| entry.kind == expected
284    }
285
286    /// Match files with size >= `min`.
287    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    /// Match files with size <= `max`.
292    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    /// Match entries modified after `t`.
297    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    /// Match entries modified before `t`.
302    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    /// Match hidden names (leading `.`).
307    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")]
318/// Async wrapper around [`search`].
319pub 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}