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_inner(root, config, MAX_INDEXED, formats, emit)
70}
71
72/// [`walk_with_specs`] with no specs, keeping at most `cap` files.
73pub 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 directories are skipped for the same reason the listing skips them,
112        // and it does most of this module's work: `.git`, `.venv`, `.tox`, the caches.
113        .hidden(true)
114        // Off on purpose. See `SearchConfig::follow_gitignore`.
115        .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        // Without this, `.gitignore` is consulted only inside a git repository.
121        // Someone who turned the option on meant the file, not the repository.
122        .require_git(false)
123        // A symlink can point at its own parent, or at a mount that is not answering.
124        // Neither is worth the risk for a convenience feature.
125        .follow_links(false)
126        // The limit that matters most: it is what keeps a walk from wandering onto a
127        // network share, and on autofs, from mounting one merely by looking.
128        .same_file_system(!config.cross_filesystems)
129        .max_depth(Some(config.max_depth))
130        // One thread. The walk is bounded and usually finishes in milliseconds warm;
131        // a thread pool competing with the load that opens a dataset is a worse trade
132        // than the milliseconds it would save.
133        .threads(1);
134
135    if !skip.is_empty() {
136        let mut over = ignore::overrides::OverrideBuilder::new(root);
137        for name in &skip {
138            // A leading `!` makes this an exclusion; matching both the bare name and
139            // any depth catches `node_modules` wherever it appears.
140            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    // Where the files live, for the row's storage glyph (#547 D10). One filesystem unless the walk
152    // may cross into others, and then asked per file.
153    let mounts = crate::home::locality::Mounts::cached();
154    let root_source = mounts.describe(root).fstype;
155    // Specs name files only when no extension filter narrows the search.
156    let specs = extensions.is_empty() && !formats.is_empty();
157    // The directory being walked and how many of its files have been looked inside.
158    let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
159
160    for result in builder.build() {
161        outcome.scanned += 1;
162
163        // Checked per entry rather than per batch: one enormous directory can burn
164        // the whole budget without ever completing a batch.
165        if Instant::now() >= deadline {
166            outcome.hit_time_limit = true;
167            break;
168        }
169
170        let Ok(dir_entry) = result else {
171            // A directory that cannot be read is not an error worth reporting here —
172            // permissions on someone else's tree are normal.
173            continue;
174        };
175
176        if dir_entry.depth() >= config.max_depth {
177            // Reaching the limit is only worth reporting if there was more below it.
178            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        // Directories are traversed, not offered: a search result is something you
188        // can open. Anything that is not a regular file — a FIFO, a socket, a device
189        // — is never opened, which is the rule the rest of datui already follows.
190        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        // The name carries the path relative to where the search started, because
219        // "sales.parquet" three times over says nothing about which one you want.
220        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        // Either condition: a full batch bounds work between checkpoints; the interval covers
230        // long stretches of non-matching entries, which still need progress and a way to stop.
231        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
244/// The spec that reads `path`, a file no extension names, as the listing finds it: by
245/// glob, else by its first bytes when its name says nothing, within the per-directory
246/// cap `sniffed_in` counts.
247fn 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
272/// Whether `path` is a format the search is looking for.
273fn 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
283/// A label naming the dataset by its place under the search root, always with forward
284/// slashes (a display choice; the row keeps its real `path`).
285fn 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
294/// Where a search should start from where the user is; `None` without a working
295/// directory or on a filesystem that must not be walked.
296pub 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    // A remote root is listed by its probe, one directory at a time, precisely so that
305    // a share which stops answering cannot take the interface with it. Recursively
306    // walking one would undo that.
307    if network_check(&root) {
308        return None;
309    }
310    Some(root)
311}
312
313/// What the filter matched among the files a walk kept.
314#[derive(Debug, Clone, Default)]
315pub struct Matches {
316    /// The filter these are matches for.
317    pub query: String,
318    /// How many files of the index were looked at: the first `upto`.
319    pub upto: usize,
320    /// Every match, by its place in the index, in index order. Kept whole so the next,
321    /// longer query only has to look at these.
322    pub ids: Vec<u32>,
323    /// The best matches, best first, at most `max_results` of them: what is listed.
324    pub top: Vec<Entry>,
325    /// The score of each of `top`, so listing them does not score them again.
326    pub scores: Vec<i32>,
327}
328
329impl Matches {
330    /// Whether these can be narrowed to `query` rather than looked for again: every
331    /// match of a longer query is a match of its prefix, for a subsequence of the name
332    /// and for a substring of a column alike.
333    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    /// Score files the walk found since, `start` being the first one's place in the
340    /// index, and fold them in. Whether any of them is now among the best listed.
341    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            // After every listed match that ranks above or level with it: the ones found
349            // first win a tie, as in a whole scoring.
350            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
369/// Score `query` against `index`, keeping the best `limit`. With `base` from a prefix
370/// of `query`, only its matches and newer files are scored. Off the UI thread: on a large
371/// tree this made every keystroke wait.
372pub 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    // Best first; ties to the shorter name, as the listing ranks them.
384    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}