use crate::config::SearchConfig;
use crate::home::discover::{Entry, EntryKind, is_data_file};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_INDEXED: usize = 100_000;
#[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 files")
} 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_with_specs<F>(
root: &Path,
config: &SearchConfig,
formats: &crate::formats::Registry,
emit: F,
) -> Outcome
where
F: FnMut(Vec<Entry>, Outcome) -> bool,
{
walk_recalling(root, config, formats, &Default::default(), emit)
}
pub fn walk_recalling<F>(
root: &Path,
config: &SearchConfig,
formats: &crate::formats::Registry,
known: &std::collections::HashMap<std::path::PathBuf, crate::cache::DatasetFacts>,
emit: F,
) -> Outcome
where
F: FnMut(Vec<Entry>, Outcome) -> bool,
{
walk_inner(root, config, MAX_INDEXED, formats, known, emit)
}
pub fn walk_up_to<F>(root: &Path, config: &SearchConfig, cap: usize, emit: F) -> Outcome
where
F: FnMut(Vec<Entry>, Outcome) -> bool,
{
walk_inner(
root,
config,
cap,
&crate::formats::Registry::default(),
&Default::default(),
emit,
)
}
fn walk_inner<F>(
root: &Path,
config: &SearchConfig,
cap: usize,
formats: &crate::formats::Registry,
known: &std::collections::HashMap<std::path::PathBuf, crate::cache::DatasetFacts>,
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() + config.time_budget.duration();
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();
let mounts = crate::home::locality::Mounts::cached();
let root_source = mounts.describe(root).fstype;
let specs = extensions.is_empty() && !formats.is_empty();
let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
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();
let mut spec = None;
if !matches_extension(path, &extensions) {
if !specs {
continue;
}
spec = spec_of(path, formats, &mut sniffed_in);
if spec.is_none() {
continue;
}
}
let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
if let Some(spec) = spec {
crate::home::discover::name_spec_file(&mut entry, &spec);
}
let recorded =
|| known.contains_key(path) || known.contains_key(&crate::home::index_key(path));
if !known.is_empty()
&& recorded()
&& let Ok(meta) = dir_entry.metadata()
{
entry = entry.with_fs_metadata(&meta);
}
entry.cost.source = Some(if config.cross_filesystems {
mounts.describe(path).fstype
} else {
root_source.clone()
});
entry.name = relative_label(root, path);
batch.push(entry);
found += 1;
if found >= cap {
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 spec_of(
path: &Path,
formats: &crate::formats::Registry,
sniffed_in: &mut (PathBuf, usize),
) -> Option<Arc<crate::formats::Spec>> {
if let Some(spec) = formats.by_glob(path, false).into_iter().next() {
return Some(spec);
}
if !crate::home::discover::worth_sniffing(path) {
return None;
}
let dir = path.parent().unwrap_or(path);
if sniffed_in.0 != dir {
*sniffed_in = (dir.to_path_buf(), 0);
}
if sniffed_in.1 >= crate::home::discover::MAX_SNIFFS_PER_DIR {
return None;
}
sniffed_in.1 += 1;
match crate::home::discover::sniff_listed(path, formats)? {
crate::home::discover::Sniffed::Spec(spec) => Some(spec),
crate::home::discover::Sniffed::Format => None,
}
}
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)
}
#[derive(Debug, Clone, Default)]
pub struct Matches {
pub query: String,
pub upto: usize,
pub ids: Vec<u32>,
pub top: Vec<Entry>,
pub scores: Vec<i32>,
}
impl Matches {
pub fn narrows_to(&self, query: &str) -> bool {
!self.query.is_empty() && query.to_lowercase().starts_with(&self.query.to_lowercase())
}
}
impl Matches {
pub fn extend(&mut self, files: &[Entry], start: usize, limit: usize) -> bool {
let mut changed = false;
for (i, entry) in files.iter().enumerate() {
let Some(score) = crate::home::match_score(&self.query, entry) else {
continue;
};
self.ids.push((start + i) as u32);
let at = self
.scores
.iter()
.zip(&self.top)
.position(|(&s, e)| s < score || (s == score && e.name.len() > entry.name.len()))
.unwrap_or(self.top.len());
if at < limit {
self.top.insert(at, entry.clone());
self.scores.insert(at, score);
self.top.truncate(limit);
self.scores.truncate(limit);
changed = true;
}
}
self.upto = start + files.len();
changed
}
}
pub fn score(index: &[Arc<[Entry]>], query: &str, base: Option<&Matches>, limit: usize) -> Matches {
let all: Vec<&Entry> = index.iter().flat_map(|batch| batch.iter()).collect();
let base = base.filter(|b| b.narrows_to(query) && b.upto <= all.len());
let candidates: Box<dyn Iterator<Item = usize>> = match base {
Some(b) => Box::new(b.ids.iter().map(|&id| id as usize).chain(b.upto..all.len())),
None => Box::new(0..all.len()),
};
let mut hits: Vec<(i32, usize)> = candidates
.filter_map(|id| crate::home::match_score(query, all[id]).map(|s| (s, id)))
.collect();
let ids: Vec<u32> = hits.iter().map(|&(_, id)| id as u32).collect();
let order = |a: &(i32, usize), b: &(i32, usize)| {
b.0.cmp(&a.0)
.then_with(|| all[a.1].name.len().cmp(&all[b.1].name.len()))
.then_with(|| a.1.cmp(&b.1))
};
if hits.len() > limit && limit > 0 {
hits.select_nth_unstable_by(limit - 1, order);
hits.truncate(limit);
} else if limit == 0 {
hits.clear();
}
hits.sort_unstable_by(order);
Matches {
query: query.to_string(),
upto: all.len(),
ids,
scores: hits.iter().map(|&(score, _)| score).collect(),
top: hits.into_iter().map(|(_, id)| all[id].clone()).collect(),
}
}