Skip to main content

datui_lib/home/
search.rs

1//! Recursive search for datasets below a directory: one bounded background walk of the
2//! working directory, its results filtered in memory like listed rows; nothing repeats
3//! per keystroke. Every limit exists because some real directory needs it (see
4//! `Limits`). The walk keeps every data file, scored off the UI thread ([`score`]); the
5//! listing cap counts matches, so a match is never lost behind non-matches.
6
7use 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
13/// The most files one walk keeps. The time budget bounds a walk first in practice; this
14/// bounds its memory on a tree fast enough to list a million names inside it.
15pub const MAX_INDEXED: usize = 100_000;
16
17/// How far a walk got and why it stopped: "not found here" is acted on, so a short
18/// search must say it was short.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub struct Outcome {
21    /// Directory entries examined, whether or not they were data.
22    pub scanned: usize,
23    /// Stopped at [`MAX_INDEXED`] files.
24    pub hit_result_limit: bool,
25    /// Stopped at `time_budget_ms`.
26    pub hit_time_limit: bool,
27    /// Stopped at `max_depth` somewhere; deeper datasets may exist.
28    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    /// A short phrase for the section heading, or `None` when the walk saw everything.
37    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
50/// How often the walker reports: often enough that a cold tree fills the screen while
51/// working, rarely enough that a warm one is not busy messaging.
52const BATCH: usize = 64;
53const BATCH_INTERVAL: Duration = Duration::from_millis(120);
54
55/// Walk `root` for datasets, handing batches to `emit`; `emit` returns `false` to
56/// abandon the walk. Blocks on the filesystem: never call from the drawing thread.
57/// Keeps the files `formats` reads as well, as the listing names them: by a spec's
58/// glob, or by its magic in the first bytes of a file whose name says nothing, at most
59/// `crate::home::discover::MAX_SNIFFS_PER_DIR` of them a directory.
60pub 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
72/// [`walk_with_specs`], stat'ing the files `known` has a record of, so what an earlier
73/// run measured (their columns, which the filter matches) fills them in: the rest are
74/// stat'ed only when shown.
75pub 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
88/// [`walk_with_specs`] with no specs, keeping at most `cap` files.
89pub 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 directories are skipped for the same reason the listing skips them,
130        // and it does most of this module's work: `.git`, `.venv`, `.tox`, the caches.
131        .hidden(true)
132        // Off on purpose. See `SearchConfig::follow_gitignore`.
133        .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        // Without this, `.gitignore` is consulted only inside a git repository.
139        // Someone who turned the option on meant the file, not the repository.
140        .require_git(false)
141        // A symlink can point at its own parent, or at a mount that is not answering.
142        // Neither is worth the risk for a convenience feature.
143        .follow_links(false)
144        // The limit that matters most: it is what keeps a walk from wandering onto a
145        // network share, and on autofs, from mounting one merely by looking.
146        .same_file_system(!config.cross_filesystems)
147        .max_depth(Some(config.max_depth))
148        // One thread. The walk is bounded and usually finishes in milliseconds warm;
149        // a thread pool competing with the load that opens a dataset is a worse trade
150        // than the milliseconds it would save.
151        .threads(1);
152
153    if !skip.is_empty() {
154        let mut over = ignore::overrides::OverrideBuilder::new(root);
155        for name in &skip {
156            // A leading `!` makes this an exclusion; matching both the bare name and
157            // any depth catches `node_modules` wherever it appears.
158            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    // Where the files live, for the row's storage glyph (#547 D10). One filesystem unless the walk
170    // may cross into others, and then asked per file.
171    let mounts = crate::home::locality::Mounts::cached();
172    let root_source = mounts.describe(root).fstype;
173    // Specs name files only when no extension filter narrows the search.
174    let specs = extensions.is_empty() && !formats.is_empty();
175    // The directory being walked and how many of its files have been looked inside.
176    let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
177
178    for result in builder.build() {
179        outcome.scanned += 1;
180
181        // Checked per entry rather than per batch: one enormous directory can burn
182        // the whole budget without ever completing a batch.
183        if Instant::now() >= deadline {
184            outcome.hit_time_limit = true;
185            break;
186        }
187
188        let Ok(dir_entry) = result else {
189            // A directory that cannot be read is not an error worth reporting here —
190            // permissions on someone else's tree are normal.
191            continue;
192        };
193
194        if dir_entry.depth() >= config.max_depth {
195            // Reaching the limit is only worth reporting if there was more below it.
196            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        // Directories are traversed, not offered: a search result is something you
206        // can open. Anything that is not a regular file — a FIFO, a socket, a device
207        // — is never opened, which is the rule the rest of datui already follows.
208        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        // No stat: of thousands found, the few shown are stat'ed when they are
229        // (`discover::stat_row`), as a listing's rows are. A file with a record is,
230        // since its record holds only at the size and mtime it was taken at.
231        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        // The name carries the path relative to where the search started, because
245        // "sales.parquet" three times over says nothing about which one you want.
246        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        // Either condition: a full batch bounds work between checkpoints; the interval covers
256        // long stretches of non-matching entries, which still need progress and a way to 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::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
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 its place under the search root, always with forward
310/// slashes (a display choice; the row keeps its real `path`).
311fn 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
320/// Where a search should start from where the user is; `None` without a working
321/// directory or on a filesystem that must not be walked.
322pub 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    // A remote root is listed by its probe, one directory at a time, precisely so that
331    // a share which stops answering cannot take the interface with it. Recursively
332    // walking one would undo that.
333    if network_check(&root) {
334        return None;
335    }
336    Some(root)
337}
338
339/// What the filter matched among the files a walk kept.
340#[derive(Debug, Clone, Default)]
341pub struct Matches {
342    /// The filter these are matches for.
343    pub query: String,
344    /// How many files of the index were looked at: the first `upto`.
345    pub upto: usize,
346    /// Every match, by its place in the index, in index order. Kept whole so the next,
347    /// longer query only has to look at these.
348    pub ids: Vec<u32>,
349    /// The best matches, best first, at most `max_results` of them: what is listed.
350    pub top: Vec<Entry>,
351    /// The score of each of `top`, so listing them does not score them again.
352    pub scores: Vec<i32>,
353}
354
355impl Matches {
356    /// Whether these can be narrowed to `query` rather than looked for again: every
357    /// match of a longer query is a match of its prefix, for a subsequence of the name
358    /// and for a substring of a column alike.
359    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    /// Score files the walk found since, `start` being the first one's place in the
366    /// index, and fold them in. Whether any of them is now among the best listed.
367    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            // After every listed match that ranks above or level with it: the ones found
375            // first win a tie, as in a whole scoring.
376            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
395/// Score `query` against `index`, keeping the best `limit`. With `base` from a prefix
396/// of `query`, only its matches and newer files are scored. Off the UI thread: on a large
397/// tree this made every keystroke wait.
398pub 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    // Best first; ties to the shorter name, as the listing ranks them.
410    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}