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_inner(root, config, MAX_INDEXED, formats, emit)
70}
71
72pub fn walk_up_to<F>(root: &Path, config: &SearchConfig, cap: usize, emit: F) -> Outcome
74where
75 F: FnMut(Vec<Entry>, Outcome) -> bool,
76{
77 walk_inner(
78 root,
79 config,
80 cap,
81 &crate::formats::Registry::default(),
82 emit,
83 )
84}
85
86fn walk_inner<F>(
87 root: &Path,
88 config: &SearchConfig,
89 cap: usize,
90 formats: &crate::formats::Registry,
91 mut emit: F,
92) -> Outcome
93where
94 F: FnMut(Vec<Entry>, Outcome) -> bool,
95{
96 let mut outcome = Outcome::default();
97 if !config.enabled {
98 return outcome;
99 }
100
101 let deadline = Instant::now() + config.time_budget.duration();
102 let skip = config.skipped_dirs();
103 let extensions: Vec<String> = config
104 .extensions
105 .iter()
106 .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
107 .collect();
108
109 let mut builder = ignore::WalkBuilder::new(root);
110 builder
111 .hidden(true)
114 .git_ignore(config.follow_gitignore)
116 .git_global(config.follow_gitignore)
117 .git_exclude(config.follow_gitignore)
118 .ignore(config.follow_gitignore)
119 .parents(config.follow_gitignore)
120 .require_git(false)
123 .follow_links(false)
126 .same_file_system(!config.cross_filesystems)
129 .max_depth(Some(config.max_depth))
130 .threads(1);
134
135 if !skip.is_empty() {
136 let mut over = ignore::overrides::OverrideBuilder::new(root);
137 for name in &skip {
138 let _ = over.add(&format!("!**/{name}"));
141 let _ = over.add(&format!("!{name}"));
142 }
143 if let Ok(over) = over.build() {
144 builder.overrides(over);
145 }
146 }
147
148 let mut batch: Vec<Entry> = Vec::with_capacity(BATCH);
149 let mut found = 0usize;
150 let mut last_emit = Instant::now();
151 let mounts = crate::home::locality::Mounts::cached();
154 let root_source = mounts.describe(root).fstype;
155 let specs = extensions.is_empty() && !formats.is_empty();
157 let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
159
160 for result in builder.build() {
161 outcome.scanned += 1;
162
163 if Instant::now() >= deadline {
166 outcome.hit_time_limit = true;
167 break;
168 }
169
170 let Ok(dir_entry) = result else {
171 continue;
174 };
175
176 if dir_entry.depth() >= config.max_depth {
177 if dir_entry.file_type().is_some_and(|t| t.is_dir()) {
179 outcome.hit_depth_limit = true;
180 }
181 continue;
182 }
183
184 let Some(file_type) = dir_entry.file_type() else {
185 continue;
186 };
187 if !file_type.is_file() {
191 continue;
192 }
193
194 let path = dir_entry.path();
195 let mut spec = None;
196 if !matches_extension(path, &extensions) {
197 if !specs {
198 continue;
199 }
200 spec = spec_of(path, formats, &mut sniffed_in);
201 if spec.is_none() {
202 continue;
203 }
204 }
205
206 let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
207 if let Some(spec) = spec {
208 crate::home::discover::name_spec_file(&mut entry, &spec);
209 }
210 if let Ok(meta) = dir_entry.metadata() {
211 entry = entry.with_fs_metadata(&meta);
212 }
213 entry.cost.source = Some(if config.cross_filesystems {
214 mounts.describe(path).fstype
215 } else {
216 root_source.clone()
217 });
218 entry.name = relative_label(root, path);
221 batch.push(entry);
222 found += 1;
223
224 if found >= cap {
225 outcome.hit_result_limit = true;
226 break;
227 }
228
229 if batch.len() >= BATCH || last_emit.elapsed() >= BATCH_INTERVAL {
232 last_emit = Instant::now();
233 if !emit(std::mem::take(&mut batch), outcome) {
234 return outcome;
235 }
236 batch.reserve(BATCH);
237 }
238 }
239
240 emit(batch, outcome);
241 outcome
242}
243
244fn spec_of(
248 path: &Path,
249 formats: &crate::formats::Registry,
250 sniffed_in: &mut (PathBuf, usize),
251) -> Option<Arc<crate::formats::Spec>> {
252 if let Some(spec) = formats.by_glob(path, false).into_iter().next() {
253 return Some(spec);
254 }
255 if !crate::home::discover::worth_sniffing(path) {
256 return None;
257 }
258 let dir = path.parent().unwrap_or(path);
259 if sniffed_in.0 != dir {
260 *sniffed_in = (dir.to_path_buf(), 0);
261 }
262 if sniffed_in.1 >= crate::home::discover::MAX_SNIFFS_PER_DIR {
263 return None;
264 }
265 sniffed_in.1 += 1;
266 match crate::home::discover::sniff_listed(path, formats)? {
267 crate::home::discover::Sniffed::Spec(spec) => Some(spec),
268 crate::home::discover::Sniffed::Format => None,
269 }
270}
271
272fn matches_extension(path: &Path, extensions: &[String]) -> bool {
274 if extensions.is_empty() {
275 return is_data_file(path);
276 }
277 path.extension()
278 .and_then(|e| e.to_str())
279 .map(|e| e.to_ascii_lowercase())
280 .is_some_and(|e| extensions.contains(&e))
281}
282
283fn relative_label(root: &Path, path: &Path) -> String {
286 let relative = path.strip_prefix(root).unwrap_or(path);
287 relative
288 .components()
289 .map(|c| c.as_os_str().to_string_lossy())
290 .collect::<Vec<_>>()
291 .join("/")
292}
293
294pub fn search_root(
297 browsing: Option<&PathBuf>,
298 network_check: fn(&Path) -> bool,
299) -> Option<PathBuf> {
300 let root = match browsing {
301 Some(dir) => dir.clone(),
302 None => std::env::current_dir().ok()?,
303 };
304 if network_check(&root) {
308 return None;
309 }
310 Some(root)
311}
312
313#[derive(Debug, Clone, Default)]
315pub struct Matches {
316 pub query: String,
318 pub upto: usize,
320 pub ids: Vec<u32>,
323 pub top: Vec<Entry>,
325 pub scores: Vec<i32>,
327}
328
329impl Matches {
330 pub fn narrows_to(&self, query: &str) -> bool {
334 !self.query.is_empty() && query.to_lowercase().starts_with(&self.query.to_lowercase())
335 }
336}
337
338impl Matches {
339 pub fn extend(&mut self, files: &[Entry], start: usize, limit: usize) -> bool {
342 let mut changed = false;
343 for (i, entry) in files.iter().enumerate() {
344 let Some(score) = crate::home::match_score(&self.query, entry) else {
345 continue;
346 };
347 self.ids.push((start + i) as u32);
348 let at = self
351 .scores
352 .iter()
353 .zip(&self.top)
354 .position(|(&s, e)| s < score || (s == score && e.name.len() > entry.name.len()))
355 .unwrap_or(self.top.len());
356 if at < limit {
357 self.top.insert(at, entry.clone());
358 self.scores.insert(at, score);
359 self.top.truncate(limit);
360 self.scores.truncate(limit);
361 changed = true;
362 }
363 }
364 self.upto = start + files.len();
365 changed
366 }
367}
368
369pub fn score(index: &[Arc<[Entry]>], query: &str, base: Option<&Matches>, limit: usize) -> Matches {
373 let all: Vec<&Entry> = index.iter().flat_map(|batch| batch.iter()).collect();
374 let base = base.filter(|b| b.narrows_to(query) && b.upto <= all.len());
375 let candidates: Box<dyn Iterator<Item = usize>> = match base {
376 Some(b) => Box::new(b.ids.iter().map(|&id| id as usize).chain(b.upto..all.len())),
377 None => Box::new(0..all.len()),
378 };
379 let mut hits: Vec<(i32, usize)> = candidates
380 .filter_map(|id| crate::home::match_score(query, all[id]).map(|s| (s, id)))
381 .collect();
382 let ids: Vec<u32> = hits.iter().map(|&(_, id)| id as u32).collect();
383 let order = |a: &(i32, usize), b: &(i32, usize)| {
385 b.0.cmp(&a.0)
386 .then_with(|| all[a.1].name.len().cmp(&all[b.1].name.len()))
387 .then_with(|| a.1.cmp(&b.1))
388 };
389 if hits.len() > limit && limit > 0 {
390 hits.select_nth_unstable_by(limit - 1, order);
391 hits.truncate(limit);
392 } else if limit == 0 {
393 hits.clear();
394 }
395 hits.sort_unstable_by(order);
396 Matches {
397 query: query.to_string(),
398 upto: all.len(),
399 ids,
400 scores: hits.iter().map(|&(score, _)| score).collect(),
401 top: hits.into_iter().map(|(_, id)| all[id].clone()).collect(),
402 }
403}