use crate::cache::{CacheKey, CachePolicy, CacheValue, DiscoveryCache};
use crate::error::PathError;
use crate::internal::validation::{MAX_PATTERN_LENGTH, reject_nul_path};
use crate::listing::{ListOptions, list};
use crate::metadata::{EntryKind, FileEntry};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct SearchRequest {
pub root: PathBuf,
pub patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub options: ListOptions,
pub cache: CachePolicy,
}
impl Default for SearchRequest {
fn default() -> Self {
Self {
root: PathBuf::from("."),
patterns: Vec::new(),
exclude_patterns: Vec::new(),
options: ListOptions {
recursive: true,
..ListOptions::default()
},
cache: CachePolicy::Bypass,
}
}
}
impl SearchRequest {
pub fn new(
root: impl Into<PathBuf>,
patterns: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
root: root.into(),
patterns: patterns.into_iter().map(Into::into).collect(),
..Self::default()
}
}
pub fn exclude(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.exclude_patterns
.extend(patterns.into_iter().map(Into::into));
self
}
pub fn options(mut self, options: ListOptions) -> Self {
self.options = options;
self
}
pub fn cache_policy(mut self, policy: CachePolicy) -> Self {
self.cache = policy;
self
}
}
pub fn search(request: &SearchRequest) -> Result<Vec<FileEntry>, PathError> {
search_uncached(request)
}
pub fn search_with_cache(
request: &SearchRequest,
cache: Option<&dyn DiscoveryCache>,
) -> Result<Vec<FileEntry>, PathError> {
reject_nul_path(&request.root)?;
validate_patterns(request)?;
let key = CacheKey::from_search(request);
let Some(cache) = cache else {
return search_uncached(request);
};
match request.cache {
CachePolicy::Bypass => search_uncached(request),
CachePolicy::ReadThrough => {
if let Some(value) = cache.get(&key)? {
if !value.is_expired() {
return Ok(value.entries);
}
}
let entries = search_uncached(request)?;
cache.put(
key,
CacheValue {
entries: entries.clone(),
stored_at: SystemTime::now(),
ttl: None,
},
)?;
Ok(entries)
}
CachePolicy::Refresh => {
let entries = search_uncached(request)?;
cache.put(
key,
CacheValue {
entries: entries.clone(),
stored_at: SystemTime::now(),
ttl: None,
},
)?;
Ok(entries)
}
}
}
fn validate_patterns(request: &SearchRequest) -> Result<(), PathError> {
if request.patterns.is_empty() {
return Err(PathError::invalid("search requires at least one pattern"));
}
for p in request
.patterns
.iter()
.chain(request.exclude_patterns.iter())
{
if p.len() > MAX_PATTERN_LENGTH {
return Err(PathError::InvalidGlob {
message: format!("pattern exceeds maximum length ({MAX_PATTERN_LENGTH})"),
});
}
}
Ok(())
}
fn search_uncached(request: &SearchRequest) -> Result<Vec<FileEntry>, PathError> {
reject_nul_path(&request.root)?;
validate_patterns(request)?;
let include = build_globset(&request.patterns)?;
let exclude = if request.exclude_patterns.is_empty() {
None
} else {
Some(build_globset(&request.exclude_patterns)?)
};
let mut list_opts = request.options.clone();
if !list_opts.include_files && !list_opts.include_directories && !list_opts.include_symlinks {
list_opts.include_files = true;
}
let listed = list(&request.root, &list_opts)?;
let mut out = Vec::new();
for entry in listed {
let rel = entry
.relative_path
.as_ref()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let name = entry
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let matched = include.is_match(&rel) || include.is_match(&name);
if !matched {
continue;
}
if let Some(ex) = &exclude {
if ex.is_match(&rel) || ex.is_match(&name) {
continue;
}
}
out.push(entry);
if let Some(max) = request.options.max_entries {
if out.len() >= max {
break;
}
}
}
Ok(out)
}
fn build_globset(patterns: &[String]) -> Result<GlobSet, PathError> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|e| PathError::InvalidGlob {
message: e.to_string(),
})?;
builder.add(glob);
}
builder.build().map_err(|e| PathError::InvalidGlob {
message: e.to_string(),
})
}
pub fn search_with(
root: impl AsRef<Path>,
options: &ListOptions,
predicate: impl Fn(&FileEntry) -> bool,
) -> Result<Vec<FileEntry>, PathError> {
let listed = list(root, options)?;
let mut out: Vec<FileEntry> = listed.into_iter().filter(|e| predicate(e)).collect();
if let Some(max) = options.max_entries {
out.truncate(max);
}
Ok(out)
}
pub mod predicates {
use super::*;
use std::ffi::OsStr;
use std::time::SystemTime;
pub fn extension(ext: impl AsRef<OsStr>) -> impl Fn(&FileEntry) -> bool {
let ext = ext.as_ref().to_os_string();
move |entry: &FileEntry| entry.path.extension() == Some(ext.as_os_str())
}
pub fn filename(name: impl AsRef<OsStr>) -> impl Fn(&FileEntry) -> bool {
let name = name.as_ref().to_os_string();
move |entry: &FileEntry| entry.path.file_name() == Some(name.as_os_str())
}
pub fn kind(expected: EntryKind) -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| entry.kind == expected
}
pub fn min_size(min: u64) -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| entry.size.is_some_and(|s| s >= min)
}
pub fn max_size(max: u64) -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| entry.size.is_some_and(|s| s <= max)
}
pub fn modified_after(t: SystemTime) -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| entry.modified.is_some_and(|m| m > t)
}
pub fn modified_before(t: SystemTime) -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| entry.modified.is_some_and(|m| m < t)
}
pub fn is_hidden() -> impl Fn(&FileEntry) -> bool {
move |entry: &FileEntry| {
entry
.path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
}
}
#[cfg(feature = "async")]
pub async fn search_async(request: SearchRequest) -> Result<Vec<FileEntry>, PathError> {
tokio::task::spawn_blocking(move || search(&request))
.await
.map_err(|e| PathError::traversal(format!("async search join failed: {e}")))?
}