1use crate::config::SearchConfig;
8use crate::home::discover::{Entry, EntryKind, is_data_file};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13pub const MAX_INDEXED: usize = 100_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub struct Outcome {
21 pub scanned: usize,
23 pub hit_result_limit: bool,
25 pub hit_time_limit: bool,
27 pub hit_depth_limit: bool,
29}
30
31impl Outcome {
32 pub fn complete(&self) -> bool {
33 !self.hit_result_limit && !self.hit_time_limit && !self.hit_depth_limit
34 }
35
36 pub fn note(&self) -> Option<&'static str> {
38 if self.hit_time_limit {
39 Some("partial · out of time")
40 } else if self.hit_result_limit {
41 Some("partial · too many files")
42 } else if self.hit_depth_limit {
43 Some("partial · too deep")
44 } else {
45 None
46 }
47 }
48}
49
50const BATCH: usize = 64;
53const BATCH_INTERVAL: Duration = Duration::from_millis(120);
54
55pub fn walk_with_specs<F>(
61 root: &Path,
62 config: &SearchConfig,
63 formats: &crate::formats::Registry,
64 emit: F,
65) -> Outcome
66where
67 F: FnMut(Vec<Entry>, Outcome) -> bool,
68{
69 walk_recalling(root, config, formats, &Default::default(), emit)
70}
71
72pub fn walk_recalling<F>(
76 root: &Path,
77 config: &SearchConfig,
78 formats: &crate::formats::Registry,
79 known: &std::collections::HashMap<std::path::PathBuf, crate::cache::DatasetFacts>,
80 emit: F,
81) -> Outcome
82where
83 F: FnMut(Vec<Entry>, Outcome) -> bool,
84{
85 walk_inner(root, config, MAX_INDEXED, formats, known, emit)
86}
87
88pub fn walk_up_to<F>(root: &Path, config: &SearchConfig, cap: usize, emit: F) -> Outcome
90where
91 F: FnMut(Vec<Entry>, Outcome) -> bool,
92{
93 walk_inner(
94 root,
95 config,
96 cap,
97 &crate::formats::Registry::default(),
98 &Default::default(),
99 emit,
100 )
101}
102
103fn walk_inner<F>(
104 root: &Path,
105 config: &SearchConfig,
106 cap: usize,
107 formats: &crate::formats::Registry,
108 known: &std::collections::HashMap<std::path::PathBuf, crate::cache::DatasetFacts>,
109 mut emit: F,
110) -> Outcome
111where
112 F: FnMut(Vec<Entry>, Outcome) -> bool,
113{
114 let mut outcome = Outcome::default();
115 if !config.enabled {
116 return outcome;
117 }
118
119 let deadline = Instant::now() + config.time_budget.duration();
120 let skip = config.skipped_dirs();
121 let extensions: Vec<String> = config
122 .extensions
123 .iter()
124 .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
125 .collect();
126
127 let mut builder = ignore::WalkBuilder::new(root);
128 builder
129 .hidden(true)
132 .git_ignore(config.follow_gitignore)
134 .git_global(config.follow_gitignore)
135 .git_exclude(config.follow_gitignore)
136 .ignore(config.follow_gitignore)
137 .parents(config.follow_gitignore)
138 .require_git(false)
141 .follow_links(false)
144 .same_file_system(!config.cross_filesystems)
147 .max_depth(Some(config.max_depth))
148 .threads(1);
152
153 if !skip.is_empty() {
154 let mut over = ignore::overrides::OverrideBuilder::new(root);
155 for name in &skip {
156 let _ = over.add(&format!("!**/{name}"));
159 let _ = over.add(&format!("!{name}"));
160 }
161 if let Ok(over) = over.build() {
162 builder.overrides(over);
163 }
164 }
165
166 let mut batch: Vec<Entry> = Vec::with_capacity(BATCH);
167 let mut found = 0usize;
168 let mut last_emit = Instant::now();
169 let mounts = crate::home::locality::Mounts::cached();
172 let root_source = mounts.describe(root).fstype;
173 let specs = extensions.is_empty() && !formats.is_empty();
175 let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
177
178 for result in builder.build() {
179 outcome.scanned += 1;
180
181 if Instant::now() >= deadline {
184 outcome.hit_time_limit = true;
185 break;
186 }
187
188 let Ok(dir_entry) = result else {
189 continue;
192 };
193
194 if dir_entry.depth() >= config.max_depth {
195 if dir_entry.file_type().is_some_and(|t| t.is_dir()) {
197 outcome.hit_depth_limit = true;
198 }
199 continue;
200 }
201
202 let Some(file_type) = dir_entry.file_type() else {
203 continue;
204 };
205 if !file_type.is_file() {
209 continue;
210 }
211
212 let path = dir_entry.path();
213 let mut spec = None;
214 if !matches_extension(path, &extensions) {
215 if !specs {
216 continue;
217 }
218 spec = spec_of(path, formats, &mut sniffed_in);
219 if spec.is_none() {
220 continue;
221 }
222 }
223
224 let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
225 if let Some(spec) = spec {
226 crate::home::discover::name_spec_file(&mut entry, &spec);
227 }
228 let recorded =
232 || known.contains_key(path) || known.contains_key(&crate::home::index_key(path));
233 if !known.is_empty()
234 && recorded()
235 && let Ok(meta) = dir_entry.metadata()
236 {
237 entry = entry.with_fs_metadata(&meta);
238 }
239 entry.cost.source = Some(if config.cross_filesystems {
240 mounts.describe(path).fstype
241 } else {
242 root_source.clone()
243 });
244 entry.name = relative_label(root, path);
247 batch.push(entry);
248 found += 1;
249
250 if found >= cap {
251 outcome.hit_result_limit = true;
252 break;
253 }
254
255 if batch.len() >= BATCH || last_emit.elapsed() >= BATCH_INTERVAL {
258 last_emit = Instant::now();
259 if !emit(std::mem::take(&mut batch), outcome) {
260 return outcome;
261 }
262 batch.reserve(BATCH);
263 }
264 }
265
266 emit(batch, outcome);
267 outcome
268}
269
270fn spec_of(
274 path: &Path,
275 formats: &crate::formats::Registry,
276 sniffed_in: &mut (PathBuf, usize),
277) -> Option<Arc<crate::formats::Spec>> {
278 if let Some(spec) = formats.by_glob(path, false).into_iter().next() {
279 return Some(spec);
280 }
281 if !crate::home::discover::worth_sniffing(path) {
282 return None;
283 }
284 let dir = path.parent().unwrap_or(path);
285 if sniffed_in.0 != dir {
286 *sniffed_in = (dir.to_path_buf(), 0);
287 }
288 if sniffed_in.1 >= crate::home::discover::MAX_SNIFFS_PER_DIR {
289 return None;
290 }
291 sniffed_in.1 += 1;
292 match crate::home::discover::sniff_listed(path, formats)? {
293 crate::home::discover::Sniffed::Spec(spec) => Some(spec),
294 crate::home::discover::Sniffed::Format => None,
295 }
296}
297
298fn matches_extension(path: &Path, extensions: &[String]) -> bool {
300 if extensions.is_empty() {
301 return is_data_file(path);
302 }
303 path.extension()
304 .and_then(|e| e.to_str())
305 .map(|e| e.to_ascii_lowercase())
306 .is_some_and(|e| extensions.contains(&e))
307}
308
309fn relative_label(root: &Path, path: &Path) -> String {
312 let relative = path.strip_prefix(root).unwrap_or(path);
313 relative
314 .components()
315 .map(|c| c.as_os_str().to_string_lossy())
316 .collect::<Vec<_>>()
317 .join("/")
318}
319
320pub fn search_root(
323 browsing: Option<&PathBuf>,
324 network_check: fn(&Path) -> bool,
325) -> Option<PathBuf> {
326 let root = match browsing {
327 Some(dir) => dir.clone(),
328 None => std::env::current_dir().ok()?,
329 };
330 if network_check(&root) {
334 return None;
335 }
336 Some(root)
337}
338
339#[derive(Debug, Clone, Default)]
341pub struct Matches {
342 pub query: String,
344 pub upto: usize,
346 pub ids: Vec<u32>,
349 pub top: Vec<Entry>,
351 pub scores: Vec<i32>,
353}
354
355impl Matches {
356 pub fn narrows_to(&self, query: &str) -> bool {
360 !self.query.is_empty() && query.to_lowercase().starts_with(&self.query.to_lowercase())
361 }
362}
363
364impl Matches {
365 pub fn extend(&mut self, files: &[Entry], start: usize, limit: usize) -> bool {
368 let mut changed = false;
369 for (i, entry) in files.iter().enumerate() {
370 let Some(score) = crate::home::match_score(&self.query, entry) else {
371 continue;
372 };
373 self.ids.push((start + i) as u32);
374 let at = self
377 .scores
378 .iter()
379 .zip(&self.top)
380 .position(|(&s, e)| s < score || (s == score && e.name.len() > entry.name.len()))
381 .unwrap_or(self.top.len());
382 if at < limit {
383 self.top.insert(at, entry.clone());
384 self.scores.insert(at, score);
385 self.top.truncate(limit);
386 self.scores.truncate(limit);
387 changed = true;
388 }
389 }
390 self.upto = start + files.len();
391 changed
392 }
393}
394
395pub fn score(index: &[Arc<[Entry]>], query: &str, base: Option<&Matches>, limit: usize) -> Matches {
399 let all: Vec<&Entry> = index.iter().flat_map(|batch| batch.iter()).collect();
400 let base = base.filter(|b| b.narrows_to(query) && b.upto <= all.len());
401 let candidates: Box<dyn Iterator<Item = usize>> = match base {
402 Some(b) => Box::new(b.ids.iter().map(|&id| id as usize).chain(b.upto..all.len())),
403 None => Box::new(0..all.len()),
404 };
405 let mut hits: Vec<(i32, usize)> = candidates
406 .filter_map(|id| crate::home::match_score(query, all[id]).map(|s| (s, id)))
407 .collect();
408 let ids: Vec<u32> = hits.iter().map(|&(_, id)| id as u32).collect();
409 let order = |a: &(i32, usize), b: &(i32, usize)| {
411 b.0.cmp(&a.0)
412 .then_with(|| all[a.1].name.len().cmp(&all[b.1].name.len()))
413 .then_with(|| a.1.cmp(&b.1))
414 };
415 if hits.len() > limit && limit > 0 {
416 hits.select_nth_unstable_by(limit - 1, order);
417 hits.truncate(limit);
418 } else if limit == 0 {
419 hits.clear();
420 }
421 hits.sort_unstable_by(order);
422 Matches {
423 query: query.to_string(),
424 upto: all.len(),
425 ids,
426 scores: hits.iter().map(|&(score, _)| score).collect(),
427 top: hits.into_iter().map(|(_, id)| all[id].clone()).collect(),
428 }
429}