use crate::config::SearchConfig;
use crate::discover::{is_data_file, Entry, EntryKind};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Outcome {
pub scanned: usize,
pub hit_result_limit: bool,
pub hit_time_limit: bool,
pub hit_depth_limit: bool,
}
impl Outcome {
pub fn complete(&self) -> bool {
!self.hit_result_limit && !self.hit_time_limit && !self.hit_depth_limit
}
pub fn note(&self) -> Option<&'static str> {
if self.hit_time_limit {
Some("partial · out of time")
} else if self.hit_result_limit {
Some("partial · too many")
} else if self.hit_depth_limit {
Some("partial · too deep")
} else {
None
}
}
}
const BATCH: usize = 64;
const BATCH_INTERVAL: Duration = Duration::from_millis(120);
pub fn walk<F>(root: &Path, config: &SearchConfig, mut emit: F) -> Outcome
where
F: FnMut(Vec<Entry>, Outcome) -> bool,
{
let mut outcome = Outcome::default();
if !config.enabled {
return outcome;
}
let deadline = Instant::now() + Duration::from_millis(config.time_budget_ms);
let skip = config.skipped_dirs();
let extensions: Vec<String> = config
.extensions
.iter()
.map(|e| e.trim_start_matches('.').to_ascii_lowercase())
.collect();
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(true)
.git_ignore(config.follow_gitignore)
.git_global(config.follow_gitignore)
.git_exclude(config.follow_gitignore)
.ignore(config.follow_gitignore)
.parents(config.follow_gitignore)
.require_git(false)
.follow_links(false)
.same_file_system(!config.cross_filesystems)
.max_depth(Some(config.max_depth))
.threads(1);
if !skip.is_empty() {
let mut over = ignore::overrides::OverrideBuilder::new(root);
for name in &skip {
let _ = over.add(&format!("!**/{name}"));
let _ = over.add(&format!("!{name}"));
}
if let Ok(over) = over.build() {
builder.overrides(over);
}
}
let mut batch: Vec<Entry> = Vec::with_capacity(BATCH);
let mut found = 0usize;
let mut last_emit = Instant::now();
for result in builder.build() {
outcome.scanned += 1;
if Instant::now() >= deadline {
outcome.hit_time_limit = true;
break;
}
let Ok(dir_entry) = result else {
continue;
};
if dir_entry.depth() >= config.max_depth {
if dir_entry.file_type().is_some_and(|t| t.is_dir()) {
outcome.hit_depth_limit = true;
}
continue;
}
let Some(file_type) = dir_entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let path = dir_entry.path();
if !matches_extension(path, &extensions) {
continue;
}
let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
if let Ok(meta) = dir_entry.metadata() {
entry = entry.with_fs_metadata(&meta);
}
entry.name = relative_label(root, path);
batch.push(entry);
found += 1;
if found >= config.max_results {
outcome.hit_result_limit = true;
break;
}
if batch.len() >= BATCH || last_emit.elapsed() >= BATCH_INTERVAL {
last_emit = Instant::now();
if !emit(std::mem::take(&mut batch), outcome) {
return outcome;
}
batch.reserve(BATCH);
}
}
emit(batch, outcome);
outcome
}
fn matches_extension(path: &Path, extensions: &[String]) -> bool {
if extensions.is_empty() {
return is_data_file(path);
}
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| extensions.contains(&e))
}
fn relative_label(root: &Path, path: &Path) -> String {
let relative = path.strip_prefix(root).unwrap_or(path);
relative
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
pub fn search_root(
browsing: Option<&PathBuf>,
network_check: fn(&Path) -> bool,
) -> Option<PathBuf> {
let root = match browsing {
Some(dir) => dir.clone(),
None => std::env::current_dir().ok()?,
};
if network_check(&root) {
return None;
}
Some(root)
}