Skip to main content

datui_lib/
search.rs

1//! Recursive search for datasets below a directory.
2//!
3//! The home screen's filter is a fuzzy match over rows that are already listed. This
4//! module is what puts more rows in front of it: one bounded walk of the working
5//! directory, run once in the background, whose result is then filtered in memory
6//! like everything else. Nothing here is repeated per keystroke — a walk per
7//! character is how a file finder becomes slow on exactly the trees where it matters.
8//!
9//! Every limit exists because some real directory violates it. See [`Limits`].
10//!
11//! The walk keeps every data file it finds, and the filter is scored against that
12//! index off the UI thread ([`score`]). The cap on what is listed counts matches, not
13//! files: a file that matches is never lost behind thousands that do not.
14
15use crate::config::SearchConfig;
16use crate::discover::{Entry, EntryKind, is_data_file};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21/// The most files one walk keeps. The time budget bounds a walk first in practice; this
22/// bounds its memory on a tree fast enough to list a million names inside it.
23pub const MAX_INDEXED: usize = 100_000;
24
25/// How far a walk got, and why it stopped.
26///
27/// A search that quietly returned less than the truth would be worse than no search:
28/// "not found here" is a thing people act on.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct Outcome {
31    /// Directory entries examined, whether or not they were data.
32    pub scanned: usize,
33    /// Stopped at [`MAX_INDEXED`] files.
34    pub hit_result_limit: bool,
35    /// Stopped at `time_budget_ms`.
36    pub hit_time_limit: bool,
37    /// Stopped at `max_depth` somewhere; deeper datasets may exist.
38    pub hit_depth_limit: bool,
39}
40
41impl Outcome {
42    pub fn complete(&self) -> bool {
43        !self.hit_result_limit && !self.hit_time_limit && !self.hit_depth_limit
44    }
45
46    /// A short phrase for the section heading, or `None` when the walk saw everything.
47    pub fn note(&self) -> Option<&'static str> {
48        if self.hit_time_limit {
49            Some("partial · out of time")
50        } else if self.hit_result_limit {
51            Some("partial · too many files")
52        } else if self.hit_depth_limit {
53            Some("partial · too deep")
54        } else {
55            None
56        }
57    }
58}
59
60/// How often the walker hands back what it has found so far.
61///
62/// Small enough that a cold tree fills the screen while it is still working, large
63/// enough that a warm one does not spend its time sending messages.
64const BATCH: usize = 64;
65const BATCH_INTERVAL: Duration = Duration::from_millis(120);
66
67/// Walk `root` for datasets, handing batches to `emit` as they are found.
68///
69/// `emit` returns `false` to abandon the walk — the caller has moved on, and there is
70/// no reason to keep reading a filesystem for an answer nobody is waiting for.
71///
72/// This blocks and touches the filesystem, so it must never be called from the thread
73/// drawing the screen.
74pub fn walk<F>(root: &Path, config: &SearchConfig, emit: F) -> Outcome
75where
76    F: FnMut(Vec<Entry>, Outcome) -> bool,
77{
78    walk_up_to(root, config, MAX_INDEXED, emit)
79}
80
81/// [`walk`], keeping the files `formats` reads as well, as the listing names them: by a
82/// spec's glob, or by its magic in the first bytes of a file whose name says nothing,
83/// at most [`crate::discover::MAX_SNIFFS_PER_DIR`] of them a directory.
84pub fn walk_with_specs<F>(
85    root: &Path,
86    config: &SearchConfig,
87    formats: &crate::formats::Registry,
88    emit: F,
89) -> Outcome
90where
91    F: FnMut(Vec<Entry>, Outcome) -> bool,
92{
93    walk_inner(root, config, MAX_INDEXED, formats, emit)
94}
95
96/// [`walk`], keeping at most `cap` files.
97pub fn walk_up_to<F>(root: &Path, config: &SearchConfig, cap: usize, emit: F) -> Outcome
98where
99    F: FnMut(Vec<Entry>, Outcome) -> bool,
100{
101    walk_inner(
102        root,
103        config,
104        cap,
105        &crate::formats::Registry::default(),
106        emit,
107    )
108}
109
110fn walk_inner<F>(
111    root: &Path,
112    config: &SearchConfig,
113    cap: usize,
114    formats: &crate::formats::Registry,
115    mut emit: F,
116) -> Outcome
117where
118    F: FnMut(Vec<Entry>, Outcome) -> bool,
119{
120    let mut outcome = Outcome::default();
121    if !config.enabled {
122        return outcome;
123    }
124
125    let deadline = Instant::now() + config.time_budget.duration();
126    let skip = config.skipped_dirs();
127    let extensions: Vec<String> = config
128        .extensions
129        .iter()
130        .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
131        .collect();
132
133    let mut builder = ignore::WalkBuilder::new(root);
134    builder
135        // Hidden directories are skipped for the same reason `scan_dir` skips them,
136        // and it does most of this module's work: `.git`, `.venv`, `.tox`, the caches.
137        .hidden(true)
138        // Off on purpose. See `SearchConfig::follow_gitignore`.
139        .git_ignore(config.follow_gitignore)
140        .git_global(config.follow_gitignore)
141        .git_exclude(config.follow_gitignore)
142        .ignore(config.follow_gitignore)
143        .parents(config.follow_gitignore)
144        // Without this, `.gitignore` is consulted only inside a git repository.
145        // Someone who turned the option on meant the file, not the repository.
146        .require_git(false)
147        // A symlink can point at its own parent, or at a mount that is not answering.
148        // Neither is worth the risk for a convenience feature.
149        .follow_links(false)
150        // The limit that matters most: it is what keeps a walk from wandering onto a
151        // network share, and on autofs, from mounting one merely by looking.
152        .same_file_system(!config.cross_filesystems)
153        .max_depth(Some(config.max_depth))
154        // One thread. The walk is bounded and usually finishes in milliseconds warm;
155        // a thread pool competing with the load that opens a dataset is a worse trade
156        // than the milliseconds it would save.
157        .threads(1);
158
159    if !skip.is_empty() {
160        let mut over = ignore::overrides::OverrideBuilder::new(root);
161        for name in &skip {
162            // A leading `!` makes this an exclusion; matching both the bare name and
163            // any depth catches `node_modules` wherever it appears.
164            let _ = over.add(&format!("!**/{name}"));
165            let _ = over.add(&format!("!{name}"));
166        }
167        if let Ok(over) = over.build() {
168            builder.overrides(over);
169        }
170    }
171
172    let mut batch: Vec<Entry> = Vec::with_capacity(BATCH);
173    let mut found = 0usize;
174    let mut last_emit = Instant::now();
175    // Where the files live, for the row's storage glyph (#547 D10). One filesystem unless the walk
176    // may cross into others, and then asked per file.
177    let mounts = crate::locality::Mounts::cached();
178    let root_source = mounts.describe(root).fstype;
179    // Specs name files only when no extension filter narrows the search.
180    let specs = extensions.is_empty() && !formats.is_empty();
181    // The directory being walked and how many of its files have been looked inside.
182    let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
183
184    for result in builder.build() {
185        outcome.scanned += 1;
186
187        // Checked per entry rather than per batch: one enormous directory can burn
188        // the whole budget without ever completing a batch.
189        if Instant::now() >= deadline {
190            outcome.hit_time_limit = true;
191            break;
192        }
193
194        let Ok(dir_entry) = result else {
195            // A directory that cannot be read is not an error worth reporting here —
196            // permissions on someone else's tree are normal.
197            continue;
198        };
199
200        if dir_entry.depth() >= config.max_depth {
201            // Reaching the limit is only worth reporting if there was more below it.
202            if dir_entry.file_type().is_some_and(|t| t.is_dir()) {
203                outcome.hit_depth_limit = true;
204            }
205            continue;
206        }
207
208        let Some(file_type) = dir_entry.file_type() else {
209            continue;
210        };
211        // Directories are traversed, not offered: a search result is something you
212        // can open. Anything that is not a regular file — a FIFO, a socket, a device
213        // — is never opened, which is the rule the rest of datui already follows.
214        if !file_type.is_file() {
215            continue;
216        }
217
218        let path = dir_entry.path();
219        let mut spec = None;
220        if !matches_extension(path, &extensions) {
221            if !specs {
222                continue;
223            }
224            spec = spec_of(path, formats, &mut sniffed_in);
225            if spec.is_none() {
226                continue;
227            }
228        }
229
230        let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
231        if let Some(spec) = spec {
232            crate::discover::name_spec_file(&mut entry, &spec);
233        }
234        if let Ok(meta) = dir_entry.metadata() {
235            entry = entry.with_fs_metadata(&meta);
236        }
237        entry.cost.source = Some(if config.cross_filesystems {
238            mounts.describe(path).fstype
239        } else {
240            root_source.clone()
241        });
242        // The name carries the path relative to where the search started, because
243        // "sales.parquet" three times over says nothing about which one you want.
244        entry.name = relative_label(root, path);
245        batch.push(entry);
246        found += 1;
247
248        if found >= cap {
249            outcome.hit_result_limit = true;
250            break;
251        }
252
253        // Either condition, not both. A full batch bounds the work done between
254        // checkpoints; the interval covers the opposite case, a walk crossing
255        // thousands of entries that match nothing, where the caller still wants a
256        // progress count and still needs somewhere to say "stop".
257        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
270/// The spec that reads `path`, a file no extension names, as the listing finds it: by
271/// glob, else by its first bytes when its name says nothing, within the per-directory
272/// cap `sniffed_in` counts.
273fn 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::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::discover::MAX_SNIFFS_PER_DIR {
289        return None;
290    }
291    sniffed_in.1 += 1;
292    match crate::discover::sniff_listed(path, formats)? {
293        crate::discover::Sniffed::Spec(spec) => Some(spec),
294        crate::discover::Sniffed::Format(_) => None,
295    }
296}
297
298/// Whether `path` is a format the search is looking for.
299fn 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
309/// A label naming the dataset by where it sits under the search root.
310///
311/// Always with forward slashes. The separator here is a display choice, not a path:
312/// the row carries its real `path` for opening, and a list mixing `a/b/c.parquet`
313/// with `a\b\c.parquet` depending on the platform is worse to read and worse to
314/// write tests against.
315fn relative_label(root: &Path, path: &Path) -> String {
316    let relative = path.strip_prefix(root).unwrap_or(path);
317    relative
318        .components()
319        .map(|c| c.as_os_str().to_string_lossy())
320        .collect::<Vec<_>>()
321        .join("/")
322}
323
324/// Where a search should start, given where the user is.
325///
326/// `None` when there is nothing sensible to search: no working directory, or one on a
327/// filesystem that must not be walked.
328pub fn search_root(
329    browsing: Option<&PathBuf>,
330    network_check: fn(&Path) -> bool,
331) -> Option<PathBuf> {
332    let root = match browsing {
333        Some(dir) => dir.clone(),
334        None => std::env::current_dir().ok()?,
335    };
336    // A remote root is listed by its probe, one directory at a time, precisely so that
337    // a share which stops answering cannot take the interface with it. Recursively
338    // walking one would undo that.
339    if network_check(&root) {
340        return None;
341    }
342    Some(root)
343}
344
345/// What the filter matched among the files a walk kept.
346#[derive(Debug, Clone, Default)]
347pub struct Matches {
348    /// The filter these are matches for.
349    pub query: String,
350    /// How many files of the index were looked at: the first `upto`.
351    pub upto: usize,
352    /// Every match, by its place in the index, in index order. Kept whole so the next,
353    /// longer query only has to look at these.
354    pub ids: Vec<u32>,
355    /// The best matches, best first, at most `max_results` of them: what is listed.
356    pub top: Vec<Entry>,
357    /// The score of each of `top`, so listing them does not score them again.
358    pub scores: Vec<i32>,
359}
360
361impl Matches {
362    /// Whether these can be narrowed to `query` rather than looked for again: every
363    /// match of a longer query is a match of its prefix, for a subsequence of the name
364    /// and for a substring of a column alike.
365    pub fn narrows_to(&self, query: &str) -> bool {
366        !self.query.is_empty() && query.to_lowercase().starts_with(&self.query.to_lowercase())
367    }
368}
369
370impl Matches {
371    /// Score files the walk found since, `start` being the first one's place in the
372    /// index, and fold them in. Whether any of them is now among the best listed.
373    pub fn extend(&mut self, files: &[Entry], start: usize, limit: usize) -> bool {
374        let mut changed = false;
375        for (i, entry) in files.iter().enumerate() {
376            let Some(score) = crate::home::match_score(&self.query, entry) else {
377                continue;
378            };
379            self.ids.push((start + i) as u32);
380            // After every listed match that ranks above or level with it: the ones found
381            // first win a tie, as in a whole scoring.
382            let at = self
383                .scores
384                .iter()
385                .zip(&self.top)
386                .position(|(&s, e)| s < score || (s == score && e.name.len() > entry.name.len()))
387                .unwrap_or(self.top.len());
388            if at < limit {
389                self.top.insert(at, entry.clone());
390                self.scores.insert(at, score);
391                self.top.truncate(limit);
392                self.scores.truncate(limit);
393                changed = true;
394            }
395        }
396        self.upto = start + files.len();
397        changed
398    }
399}
400
401/// Score `query` against the files in `index`, keeping the best `limit` to list.
402///
403/// With `base` from a prefix of `query`, only its matches and the files indexed since
404/// are looked at. Called off the UI thread: over a large tree this is the work that made
405/// every keystroke wait.
406pub fn score(index: &[Arc<[Entry]>], query: &str, base: Option<&Matches>, limit: usize) -> Matches {
407    let all: Vec<&Entry> = index.iter().flat_map(|batch| batch.iter()).collect();
408    let base = base.filter(|b| b.narrows_to(query) && b.upto <= all.len());
409    let candidates: Box<dyn Iterator<Item = usize>> = match base {
410        Some(b) => Box::new(b.ids.iter().map(|&id| id as usize).chain(b.upto..all.len())),
411        None => Box::new(0..all.len()),
412    };
413    let mut hits: Vec<(i32, usize)> = candidates
414        .filter_map(|id| crate::home::match_score(query, all[id]).map(|s| (s, id)))
415        .collect();
416    let ids: Vec<u32> = hits.iter().map(|&(_, id)| id as u32).collect();
417    // Best first; ties to the shorter name, as the listing ranks them.
418    let order = |a: &(i32, usize), b: &(i32, usize)| {
419        b.0.cmp(&a.0)
420            .then_with(|| all[a.1].name.len().cmp(&all[b.1].name.len()))
421            .then_with(|| a.1.cmp(&b.1))
422    };
423    if hits.len() > limit && limit > 0 {
424        hits.select_nth_unstable_by(limit - 1, order);
425        hits.truncate(limit);
426    } else if limit == 0 {
427        hits.clear();
428    }
429    hits.sort_unstable_by(order);
430    Matches {
431        query: query.to_string(),
432        upto: all.len(),
433        ids,
434        scores: hits.iter().map(|&(score, _)| score).collect(),
435        top: hits.into_iter().map(|(_, id)| all[id].clone()).collect(),
436    }
437}