Skip to main content

reference_query/index/
mod.rs

1//! Indexing — walk a checkout, extract symbols, persist incrementally.
2//!
3//! Decoupled from search: it only writes. Unchanged files (same content hash)
4//! are skipped, and coverage is recorded so search can judge its own confidence.
5
6use std::collections::{HashMap, HashSet};
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9use std::process::Command;
10use std::time::{Duration, Instant, UNIX_EPOCH};
11
12use ignore::WalkBuilder;
13
14use crate::core::RepoIdentity;
15use crate::lang;
16use crate::store::Store;
17
18/// Outcome of an indexing run.
19#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
20pub struct Stats {
21    /// Files matching a known language that were walked.
22    pub files_seen: usize,
23    /// Files (re)parsed this run (unchanged files are skipped).
24    pub files_indexed: usize,
25    /// Symbols written this run.
26    pub symbols: usize,
27}
28
29/// Index the whole repository rooted at `root`.
30pub fn index_path(store: &mut Store, root: &Path) -> Result<Stats, Box<dyn std::error::Error>> {
31    index_under(store, root, &[])
32}
33
34/// Index `root`, or — when `subdirs` is non-empty — only those repo-relative
35/// subtrees of it (a partial index of the repo). Unbounded: an explicit index
36/// is thorough. A whole-repo index also reconciles deletions; a deliberate
37/// subset is marked `partial` so a later search won't auto-warm over it.
38pub fn index_under(
39    store: &mut Store,
40    root: &Path,
41    subdirs: &[String],
42) -> Result<Stats, Box<dyn std::error::Error>> {
43    run_index(store, root, &[], subdirs, None, None)
44}
45
46/// Lowercase the alphanumeric chars of `s` — the normal form for loose,
47/// separator-insensitive path matching.
48fn alnum_lower(s: &str) -> String {
49    s.chars()
50        .filter(|c| c.is_alphanumeric())
51        .map(|c| c.to_ascii_lowercase())
52        .collect()
53}
54
55/// Move the candidate paths whose *filename* looks relevant to the query to the
56/// front (preserving order within each group), so a warming pass parses likely
57/// files first. Deliberately generous: a stem qualifies if it shares any ~4-char
58/// run with the query — parsing is cheap, so over-including a near-match beats
59/// missing the target. `employeescontroller` flags employee / employers /
60/// EmpController, tosses companies. Matched on the filename stem (not the whole
61/// path), so a common directory like `controllers/` doesn't flag the whole tree.
62/// String-only over the in-memory list — no file reads. No-op for an empty query.
63fn prioritize_by_path(
64    paths: Vec<std::path::PathBuf>,
65    _root: &Path,
66    query: Option<&str>,
67) -> Vec<std::path::PathBuf> {
68    let needle = alnum_lower(query.unwrap_or(""));
69    let k = needle.len().min(4);
70    if k == 0 {
71        return paths;
72    }
73    let kgrams: std::collections::HashSet<&[u8]> = needle.as_bytes().windows(k).collect();
74    // one pass, reusing a scratch buffer for the normalized stem and an O(1)
75    // k-gram lookup — string-only, no per-file allocation
76    let mut prio = Vec::new();
77    let mut rest = Vec::new();
78    let mut stem = String::new();
79    for p in paths {
80        stem.clear();
81        if let Some(s) = p.file_stem() {
82            stem.extend(
83                s.to_string_lossy()
84                    .chars()
85                    .filter(|c| c.is_alphanumeric())
86                    .map(|c| c.to_ascii_lowercase()),
87            );
88        }
89        // shares a k-char run with the query (a common substring of length ≥ k)
90        if stem.as_bytes().windows(k).any(|w| kgrams.contains(w)) {
91            prio.push(p);
92        } else {
93            rest.push(p);
94        }
95    }
96    prio.extend(rest);
97    prio
98}
99
100/// Opportunistic, time-bounded indexing — warm the index a little per call so no
101/// single query blocks on a full walk of a large repo. `active` (branch) files
102/// are parsed first and ignore the budget (the working set stays fresh); then the
103/// walk streams the rest, honoring `budget`. When `query` is set, files whose
104/// *path* matches it are parsed first (a cheap, in-memory reorder of the
105/// candidate list — no file reads), so a relevant symbol indexes fast. A sweep
106/// that finishes within budget marks coverage `complete`, else `warming`.
107pub fn index_budgeted(
108    store: &mut Store,
109    root: &Path,
110    active: &[String],
111    budget: Duration,
112    query: Option<&str>,
113) -> Result<Stats, Box<dyn std::error::Error>> {
114    run_index(store, root, active, &[], Some(budget), query)
115}
116
117/// Max files a single *bounded* (warming) pass walks before it stops. The walk
118/// is cheap (stat-only), but on a huge repo it must not run the whole tree
119/// (memory + latency); the deadline cuts it short sooner. An explicit `--index`
120/// (unbounded) ignores this and walks everything. Overridable via
121/// `RQ_COLLECT_CAP` (tuning / deterministic tests).
122const COLLECT_CAP: usize = 50_000;
123
124fn collect_cap() -> usize {
125    std::env::var("RQ_COLLECT_CAP")
126        .ok()
127        .and_then(|v| v.parse().ok())
128        .unwrap_or(COLLECT_CAP)
129}
130
131/// Parse workers the background warmer uses (`--jobs`); 0 = auto.
132static PARSE_JOBS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
133
134/// Set the parse-worker count (from `--jobs`/`RQ_JOBS`); 0 restores auto.
135pub fn set_parse_jobs(n: usize) {
136    PARSE_JOBS.store(n, std::sync::atomic::Ordering::Relaxed);
137}
138
139/// Parse workers for one indexer pass — the configured value, else `RQ_JOBS`,
140/// else an auto default. Parsing is CPU-bound but writes serialize through one
141/// SQLite writer, so flooding every core rarely pays; the default caps at 8.
142pub fn parse_jobs() -> usize {
143    let configured = PARSE_JOBS.load(std::sync::atomic::Ordering::Relaxed);
144    if configured > 0 {
145        return configured;
146    }
147    if let Some(n) = std::env::var("RQ_JOBS").ok().and_then(|v| v.parse().ok())
148        && n > 0
149    {
150        return n;
151    }
152    let cores = std::thread::available_parallelism()
153        .map(|n| n.get())
154        .unwrap_or(1);
155    cores.clamp(1, 8)
156}
157
158/// Files buffered before a streaming write commits them — bounds per-transaction
159/// size and how much parsed-but-unwritten work a cut-short pass can lose.
160const WRITE_BATCH: usize = 512;
161
162/// Accumulates parsed files and commits them to the store in `WRITE_BATCH`
163/// chunks, so a long or cut-short index persists incrementally rather than in one
164/// final write. The `stream_walk` sink for `run_index`.
165struct BatchWriter<'a> {
166    store: &'a mut Store,
167    repo_id: i64,
168    buf: Vec<crate::store::FileSymbols>,
169    files: usize,
170    symbols: usize,
171    /// Cumulative time spent in `replace_files` (the single-writer store path) —
172    /// surfaced under `-v` so we can see write vs. walk/parse contention.
173    write_time: Duration,
174}
175
176impl<'a> BatchWriter<'a> {
177    fn new(store: &'a mut Store, repo_id: i64) -> Self {
178        Self {
179            store,
180            repo_id,
181            buf: Vec::new(),
182            files: 0,
183            symbols: 0,
184            write_time: Duration::ZERO,
185        }
186    }
187
188    fn push(&mut self, fs: crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>> {
189        self.buf.push(fs);
190        if self.buf.len() >= WRITE_BATCH {
191            self.flush()?;
192        }
193        Ok(())
194    }
195
196    fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
197        if !self.buf.is_empty() {
198            let t = Instant::now();
199            let (f, sy) = self.store.replace_files(self.repo_id, &self.buf)?;
200            self.write_time += t.elapsed();
201            self.files += f;
202            self.symbols += sy;
203            self.buf.clear();
204        }
205        Ok(())
206    }
207}
208
209/// Source-file candidates from `git ls-files` — read out of git's index, not by
210/// walking the filesystem. On a huge repo this is the difference between
211/// answering and timing out: enumeration is O(index read), and source-extension
212/// pathspecs make git hand back only files we can parse, so warming never burns
213/// its budget re-traversing non-source trees. Tracked files only (untracked are
214/// caught by an explicit `rq --index`'s filesystem walk). `None` outside a git
215/// work tree, so the caller falls back to walking the filesystem.
216fn git_source_candidates(root: &Path) -> Option<Vec<std::path::PathBuf>> {
217    if !is_git_repo(root) {
218        return None;
219    }
220    let globs: Vec<String> = lang::registry()
221        .iter()
222        .flat_map(|p| p.extensions().iter().map(|e| format!("*.{e}")))
223        .collect();
224    let mut cmd = Command::new("git");
225    cmd.arg("-C")
226        .arg(root)
227        .args(["ls-files", "-z", "--cached", "--"])
228        .args(&globs);
229    let out = cmd.output().ok()?;
230    if !out.status.success() {
231        return None;
232    }
233    Some(
234        out.stdout
235            .split(|&b| b == 0)
236            .filter(|s| !s.is_empty())
237            .map(|s| root.join(String::from_utf8_lossy(s).as_ref()))
238            .collect(),
239    )
240}
241
242/// A lazy, streaming filesystem walk of `roots` yielding file paths — the
243/// fallback when git can't enumerate (an explicit unbounded index, or a non-git
244/// dir). Honors `.gitignore`/hidden rules via the `ignore` crate.
245fn fs_walk_candidates(roots: Vec<std::path::PathBuf>) -> impl Iterator<Item = std::path::PathBuf> {
246    roots.into_iter().flat_map(|root| {
247        WalkBuilder::new(&root)
248            .build()
249            .filter_map(Result::ok)
250            .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
251            .map(ignore::DirEntry::into_path)
252    })
253}
254
255/// The one fused walk→parse→consume engine. A walk thread streams the source
256/// paths that `keep` selects (in walk order, the instant each is found) through a
257/// bounded channel to a pool of parse workers; the workers parse in parallel
258/// (skipping files that lack `needle`, when set) and stream each result to `sink`
259/// on the calling thread. Bounded channels back-pressure the walk and workers so
260/// neither runs ahead into unbounded memory; `deadline`/`cap` bound the pass.
261/// `seen` is seeded by the caller and returned holding every source file walked
262/// (for deletion reconcile). The bool is whether walk *and* parse finished within
263/// budget. Streaming — never collect-then-parse — is what keeps a pass too big to
264/// finish from making zero progress.
265///
266/// `run_index` sinks to the store (writing in batches via [`BatchWriter`]); the
267/// live [`scan`] sinks into a `Vec` it returns — same engine, different consumer.
268#[allow(clippy::too_many_arguments)]
269fn stream_walk(
270    root: &Path,
271    candidates: impl Iterator<Item = std::path::PathBuf> + Send,
272    deadline: Option<Instant>,
273    cap: Option<usize>,
274    needle: Option<&[u8]>,
275    seen: HashSet<String>,
276    keep: impl Fn(&str, &Path) -> bool + Send,
277    mut sink: impl FnMut(crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>>,
278) -> Result<(HashSet<String>, bool), Box<dyn std::error::Error>> {
279    use std::sync::atomic::{AtomicBool, Ordering};
280    use std::sync::{Arc, Mutex};
281
282    let workers = parse_jobs();
283    let parse_incomplete = AtomicBool::new(false);
284    let (path_tx, path_rx) = std::sync::mpsc::sync_channel::<std::path::PathBuf>(1024);
285    let (res_tx, res_rx) = std::sync::mpsc::sync_channel::<crate::store::FileSymbols>(1024);
286    let path_rx = Arc::new(Mutex::new(path_rx));
287
288    let (seen, walk_finished) = std::thread::scope(|s| -> Result<_, Box<dyn std::error::Error>> {
289        // walk thread: stream every kept source path to the workers, in order, the
290        // instant it's found. No buffering or deferral — on a repo too big to
291        // finish in budget, anything held back would never be sent.
292        let walk = s.spawn(move || {
293            let mut seen = seen;
294            let mut finished = true;
295            let mut processed = 0usize;
296            for path in candidates {
297                if past(deadline) {
298                    finished = false;
299                    break;
300                }
301                let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
302                    continue;
303                };
304                if lang::plugin_for_extension(ext).is_none() {
305                    continue;
306                }
307                let rel = path
308                    .strip_prefix(root)
309                    .unwrap_or(&path)
310                    .to_string_lossy()
311                    .into_owned();
312                if !seen.insert(rel.clone()) {
313                    continue; // already handled (active file), or a duplicate
314                }
315                if !keep(&rel, &path) {
316                    continue; // caller skipped it (unchanged / already indexed)
317                }
318                if path_tx.send(path).is_err() {
319                    finished = false; // workers gone (deadline) — walk didn't complete
320                    break;
321                }
322                processed += 1;
323                if cap.is_some_and(|c| processed >= c) {
324                    finished = false;
325                    break;
326                }
327            }
328            drop(path_tx); // close → workers drain and exit
329            (seen, finished)
330        });
331
332        // parse workers: pull paths, parse (with the content pre-filter) in
333        // parallel, stream results out
334        let parse_incomplete = &parse_incomplete;
335        for _ in 0..workers {
336            let path_rx = Arc::clone(&path_rx);
337            let res_tx = res_tx.clone();
338            s.spawn(move || {
339                loop {
340                    let got = { path_rx.lock().unwrap().recv() };
341                    let Ok(path) = got else { break }; // channel closed
342                    if past(deadline) {
343                        parse_incomplete.store(true, Ordering::Relaxed); // backlog abandoned
344                        break;
345                    }
346                    if let Some(fs) = parse_file(root, &path, needle)
347                        && res_tx.send(fs).is_err()
348                    {
349                        break;
350                    }
351                }
352            });
353        }
354        drop(res_tx); // the workers hold the live clones
355
356        // consumer (this thread): hand each parsed file to the sink as it arrives
357        while let Ok(fs) = res_rx.recv() {
358            sink(fs)?;
359        }
360        Ok(walk.join().unwrap())
361    })?;
362
363    Ok((
364        seen,
365        walk_finished && !parse_incomplete.load(Ordering::Relaxed),
366    ))
367}
368
369/// Decide an index sweep's outcome: whether to *finalize* (reconcile deletions +
370/// record the indexed HEAD) and the coverage `status` to store.
371///
372/// The guard (budgeted/warm passes only): a completed whole-repo warm that saw
373/// **zero** source files while the index already held some is almost certainly a
374/// failed enumeration (a `git ls-files` hiccup, a wrong root), not "every file
375/// was deleted". Finalizing it would forget the entire index and mark it
376/// `complete` — which warm-skip then strands at zero forever (a clean, "complete"
377/// repo isn't re-warmed). So it isn't finalized and stays `warming` for the next
378/// query to retry. An explicit `rq --index` (unbounded, `budgeted = false`) walks
379/// the filesystem and is user-initiated, so it's trusted: an empty tree really
380/// does reconcile the index away. A genuinely empty repo (nothing stored before)
381/// also completes.
382fn sweep_outcome(
383    completed: bool,
384    whole_repo: bool,
385    seen_empty: bool,
386    had_stored: bool,
387    budgeted: bool,
388) -> (bool, &'static str) {
389    if !whole_repo {
390        return (false, "partial");
391    }
392    if budgeted && completed && seen_empty && had_stored {
393        return (false, "warming"); // suspicious empty warm — don't wipe the index
394    }
395    if completed {
396        (true, "complete")
397    } else {
398        (false, "warming")
399    }
400}
401
402/// The shared indexing core behind both the explicit (`index_under`) and
403/// opportunistic (`index_budgeted`) paths, run as a single fused pipeline: one
404/// walk thread streams candidate paths (cheap, stat-only, mtime-skipping
405/// unchanged files), a pool of parse workers turns them into symbols in parallel,
406/// and this thread writes the results in batches **as they arrive** — so a pass
407/// cut short by its budget still persists everything parsed up to that point, and
408/// indexing starts the instant the first file is found (walk and parse overlap).
409///
410/// `active` files are parsed first and ignore `budget` (the working set stays
411/// fresh); then the walk streams the rest in walk order. `subdirs` (empty = whole
412/// repo) scope the walk; `budget` bounds it (`None` = unbounded). A whole-repo
413/// sweep that finishes within budget reconciles deletions and is `complete`; a
414/// sweep cut short is `warming`; a deliberate subtree is `partial`.
415fn run_index(
416    store: &mut Store,
417    root: &Path,
418    active: &[String],
419    subdirs: &[String],
420    budget: Option<Duration>,
421    query: Option<&str>,
422) -> Result<Stats, Box<dyn std::error::Error>> {
423    let identity = detect_identity(root);
424    let branch = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]);
425    let repo_id = store.upsert_repository(&identity, branch.as_deref())?;
426    let root_display = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
427    store.upsert_checkout(repo_id, &root_display.to_string_lossy(), branch.as_deref())?;
428
429    let stored = store.file_mtimes(repo_id)?;
430    let mut seen: HashSet<String> = HashSet::new();
431
432    // A cold, unbounded index (no prior coverage, the explicit `rq --index`)
433    // suspends per-row FTS maintenance and rebuilds the trigram index in one bulk
434    // pass at the end — the per-row trigger is ~70% of the write cost. Scoped to
435    // the cold full path so incremental re-index and budgeted warming (which may
436    // run concurrently and only touch a few files) keep the per-row trigger.
437    let bulk_fts = budget.is_none() && stored.is_empty();
438    if bulk_fts {
439        store.defer_fts_insert()?;
440    }
441
442    // Active (branch) files first: always parsed and written, so the working set
443    // stays fresh even when a tight budget cuts the walk short.
444    let mut active_to_parse: Vec<std::path::PathBuf> = Vec::new();
445    for rel in active {
446        note_candidate(
447            root,
448            &root.join(rel),
449            &stored,
450            &mut seen,
451            &mut active_to_parse,
452        );
453    }
454    let (active_parsed, _) = parse_files(root, &active_to_parse, None, None);
455    let (mut files_indexed, mut symbols) = store.replace_files(repo_id, &active_parsed)?;
456
457    // walk the whole repo, or just the requested subtrees — paths stay relative
458    // to `root` so they're repo-relative either way
459    let walk_roots: Vec<std::path::PathBuf> = if subdirs.is_empty() {
460        vec![root.to_path_buf()]
461    } else {
462        subdirs.iter().map(|s| root.join(s)).collect()
463    };
464
465    // Enumerate candidates. A budgeted (warming) pass on a git repo reads git's
466    // index — O(index read), no filesystem traversal — so a huge repo isn't stuck
467    // re-walking non-source trees every pass and never reaching source. An
468    // explicit unbounded index, or a non-git dir, walks the filesystem (thorough;
469    // catches untracked files). `git ls-files` runs *before* the deadline so its
470    // (cheap) work never eats the parse budget.
471    // An empty result means nothing is tracked yet (a fresh/uncommitted repo), so
472    // fall back to the filesystem walk, which sees untracked files.
473    let git_candidates = budget
474        .and_then(|_| git_source_candidates(root))
475        .filter(|paths| !paths.is_empty());
476    let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> = match git_candidates {
477        // parse query-relevant files (by path) first — a cheap in-memory reorder
478        Some(paths) => Box::new(prioritize_by_path(paths, root, query).into_iter()),
479        None => Box::new(fs_walk_candidates(walk_roots)),
480    };
481
482    let deadline = budget.map(|b| Instant::now() + b);
483    let cap = budget.map(|_| collect_cap());
484
485    // Fused walk → parse → write: stream candidates through the shared pipeline,
486    // committing parsed files in batches as they arrive (so a budget-cut or killed
487    // pass keeps what it parsed). Only new or changed files are parsed; every
488    // source file seen lands in `seen` for deletion reconcile.
489    let stored_ref = &stored;
490    let keep = |rel: &str, path: &Path| match stored_ref.get(rel) {
491        Some(&Some(m)) => Some(m) != file_mtime(path),
492        _ => true, // new file, or one stored without an mtime
493    };
494    let stream_start = Instant::now();
495    let (seen, completed, walk_files, walk_symbols, write_time) = {
496        let mut writer = BatchWriter::new(&mut *store, repo_id);
497        let (seen, completed) =
498            stream_walk(root, candidates, deadline, cap, None, seen, keep, |fs| {
499                writer.push(fs)
500            })?;
501        writer.flush()?;
502        (
503            seen,
504            completed,
505            writer.files,
506            writer.symbols,
507            writer.write_time,
508        )
509    };
510    if crate::trace::enabled() {
511        let elapsed = stream_start.elapsed();
512        crate::trace!(
513            "walk+parse+write {} file(s)/{} symbol(s) in {} ms ({} ms in store writes, {} parse jobs)",
514            walk_files,
515            walk_symbols,
516            elapsed.as_millis(),
517            write_time.as_millis(),
518            parse_jobs(),
519        );
520    }
521    if bulk_fts {
522        let t = crate::trace::Timer::start("fts bulk rebuild");
523        store.rebuild_fts()?;
524        drop(t);
525    }
526    files_indexed += walk_files;
527    symbols += walk_symbols;
528    let stats = Stats {
529        files_seen: seen.len(),
530        files_indexed,
531        symbols,
532    };
533
534    let whole_repo = subdirs.is_empty();
535    let (finalize, status) = sweep_outcome(
536        completed,
537        whole_repo,
538        seen.is_empty(),
539        !stored.is_empty(),
540        budget.is_some(),
541    );
542    // a finalized whole-repo sweep saw every live file → anything still indexed
543    // (but not seen) was deleted on disk. A sweep that saw *zero* files while the
544    // index held some is treated as a failed enumeration (see `sweep_outcome`),
545    // not finalized — so a transient empty walk can't wipe a populated index.
546    if finalize {
547        let mut forgotten = 0;
548        for path in stored.keys() {
549            if !seen.contains(path) {
550                store.forget_file(repo_id, path)?;
551                forgotten += 1;
552            }
553        }
554        if forgotten > 0 {
555            crate::trace!(
556                "reconcile {}: forgot {forgotten} file(s) not seen on disk",
557                crate::trace::abbrev(&root_display)
558            );
559        }
560        // record the commit the index now reflects, so a later search can detect
561        // an unchanged committed tree and skip re-walking a large repo
562        if let Some(head) = git_head(root) {
563            let _ = store.set_indexed_head(repo_id, &head);
564        }
565    }
566    // commit times feed the recency signal, but `git log -n1000 --name-only` is
567    // pricey on a big repo. Run it only when this run indexed something AND
568    // `root` is the work-tree root: a subdir index's `git log` walks the whole
569    // repo's history yet emits repo-relative paths that wouldn't match our
570    // subdir-relative ones — pure waste. (A subdir index leans on mtime recency.)
571    if stats.files_indexed > 0 && repo_root(root).is_some_and(|r| r == root_display) {
572        let times = git_commit_times(root, 1000);
573        if !times.is_empty() {
574            let _ = store.set_file_git_ts(repo_id, &times);
575        }
576    }
577
578    // Never persist "complete" for an empty index: a zero-file complete is almost
579    // by definition wrong (a failed enumeration), and warm-skip would then strand
580    // the repo at zero. Keep it "warming" so the next query keeps polling for
581    // files to index. Counts the repo's *total* indexed files, not this run's —
582    // a warm of an already-indexed repo re-parses nothing yet isn't empty.
583    let total_files = store.repo_totals(repo_id).map(|(f, _)| f).unwrap_or(0);
584    let status = if status == "complete" && total_files == 0 {
585        "warming"
586    } else {
587        status
588    };
589    store.set_coverage(
590        repo_id,
591        stats.files_seen as i64,
592        stats.files_indexed as i64,
593        status,
594    )?;
595    crate::trace!(
596        "index {} (budget {budget:?}): {} seen, {} indexed, {} symbols → {status}",
597        crate::trace::abbrev(&root_display),
598        stats.files_seen,
599        stats.files_indexed,
600        stats.symbols,
601    );
602    Ok(stats)
603}
604
605/// Note a walked file: record every source file in `seen` (for deletion
606/// reconcile), and queue it for parsing only when it's new or its mtime moved —
607/// a cheap `stat` skips unchanged files before any read. Non-source files are
608/// ignored entirely.
609fn note_candidate(
610    root: &Path,
611    file: &Path,
612    stored: &HashMap<String, Option<i64>>,
613    seen: &mut HashSet<String>,
614    to_parse: &mut Vec<std::path::PathBuf>,
615) {
616    let Some(ext) = file.extension().and_then(|e| e.to_str()) else {
617        return;
618    };
619    if lang::plugin_for_extension(ext).is_none() {
620        return;
621    }
622    let rel = file
623        .strip_prefix(root)
624        .unwrap_or(file)
625        .to_string_lossy()
626        .into_owned();
627    if !seen.insert(rel.clone()) {
628        return; // already noted (e.g. an active file re-seen by the walk)
629    }
630    // unchanged by mtime → already indexed, no need to re-parse
631    if let Some(&Some(m)) = stored.get(&rel)
632        && Some(m) == file_mtime(file)
633    {
634        return;
635    }
636    to_parse.push(file.to_path_buf());
637}
638
639/// Read + parse one source file into a [`FileSymbols`], or `None` if it isn't a
640/// known language, can't be read, or (when `needle` is set) doesn't contain the
641/// query — the ripgrep-style content pre-filter, applied here so it runs on the
642/// worker thread. Touches no store — safe to run in parallel (each call builds
643/// its own Tree-sitter parser).
644fn parse_file(
645    root: &Path,
646    file: &Path,
647    needle: Option<&[u8]>,
648) -> Option<crate::store::FileSymbols> {
649    let ext = file.extension().and_then(|e| e.to_str())?;
650    let plugin = lang::plugin_for_extension(ext)?;
651    let rel = file
652        .strip_prefix(root)
653        .unwrap_or(file)
654        .to_string_lossy()
655        .into_owned();
656    let source = std::fs::read_to_string(file).ok()?;
657    // pre-filter: skip the expensive parse on files that can't hold the match
658    if let Some(n) = needle
659        && !contains_ascii_ci(source.as_bytes(), n)
660    {
661        return None;
662    }
663    let content_hash = content_hash(&source);
664    let symbols = plugin.extract(&rel, &source);
665    let language = symbols
666        .first()
667        .map(|s| s.language.clone())
668        .unwrap_or_else(|| "unknown".to_string());
669    Some(crate::store::FileSymbols {
670        path: rel,
671        language,
672        mtime: file_mtime(file),
673        content_hash,
674        symbols,
675    })
676}
677
678/// Whether an optional deadline has passed (always false when unbounded).
679fn past(deadline: Option<Instant>) -> bool {
680    deadline.is_some_and(|d| Instant::now() >= d)
681}
682
683/// Parse many files across the available CPUs, stopping early once `deadline`
684/// passes; when `needle` is set, each worker skips files that don't contain it
685/// (the content pre-filter). Returns the parsed files and whether *all* of them
686/// were parsed (false if the deadline cut it short). Parsing is the expensive,
687/// CPU-bound step; writing stays serialized in one batched transaction by the
688/// caller.
689fn parse_files(
690    root: &Path,
691    paths: &[std::path::PathBuf],
692    deadline: Option<Instant>,
693    needle: Option<&[u8]>,
694) -> (Vec<crate::store::FileSymbols>, bool) {
695    use std::sync::atomic::{AtomicBool, Ordering};
696
697    let workers = parse_jobs().min(paths.len());
698
699    if workers <= 1 {
700        let mut out = Vec::new();
701        for p in paths {
702            if past(deadline) {
703                return (out, false);
704            }
705            if let Some(parsed) = parse_file(root, p, needle) {
706                out.push(parsed);
707            }
708        }
709        return (out, true);
710    }
711
712    let bailed = AtomicBool::new(false);
713    let chunk_size = paths.len().div_ceil(workers);
714    let mut out = Vec::new();
715    std::thread::scope(|s| {
716        let handles: Vec<_> = paths
717            .chunks(chunk_size)
718            .map(|chunk| {
719                let bailed = &bailed;
720                s.spawn(move || {
721                    let mut local = Vec::new();
722                    for p in chunk {
723                        if past(deadline) {
724                            bailed.store(true, Ordering::Relaxed);
725                            break;
726                        }
727                        if let Some(parsed) = parse_file(root, p, needle) {
728                            local.push(parsed);
729                        }
730                    }
731                    local
732                })
733            })
734            .collect();
735        for h in handles {
736            out.extend(h.join().unwrap_or_default());
737        }
738    });
739    (out, !bailed.load(Ordering::Relaxed))
740}
741
742/// Map of repo-relative path → most-recent commit time (unix seconds), from the
743/// last `limit` commits. Paths are repo-root-relative, matching the indexed
744/// paths when `root` is the repository root.
745fn git_commit_times(root: &Path, limit: usize) -> HashMap<String, i64> {
746    match git_output(
747        root,
748        &[
749            "log",
750            &format!("-n{limit}"),
751            "--name-only",
752            "--pretty=format:%ct",
753        ],
754    ) {
755        Some(text) => parse_git_log(&text),
756        None => HashMap::new(),
757    }
758}
759
760/// Parse `git log --name-only --pretty=format:%ct` output into path → latest
761/// commit time. Newest-first, so the first time a path appears is its most
762/// recent commit.
763fn parse_git_log(text: &str) -> HashMap<String, i64> {
764    let mut map = HashMap::new();
765    let mut current_ts = 0i64;
766    for line in text.lines() {
767        if line.is_empty() {
768            continue;
769        }
770        if let Ok(ts) = line.parse::<i64>() {
771            // a commit-timestamp header (filenames that are pure integers don't
772            // occur in practice)
773            current_ts = ts;
774        } else {
775            map.entry(line.to_string()).or_insert(current_ts);
776        }
777    }
778    map
779}
780
781/// Live, budgeted scan (search Layer 4): stream-walk `root` on the same fused
782/// [`stream_walk`] engine as the indexer, parsing source files and returning the
783/// parsed `FileSymbols` *without* touching the store — so `rq` answers at zero
784/// coverage. Bounded and filtered:
785/// - stop once `deadline` passes;
786/// - skip any file whose repo-relative path is in `skip` (already indexed);
787/// - when `needle` is set, parse only files containing it (case-insensitive
788///   substring) — the ripgrep-style pre-filter that skips the tree-sitter parse
789///   on files that can't hold an exact/prefix/substring match. `needle` is `None`
790///   for the *fuzzy* fallback: an abbreviation (`usr` → `user`) isn't a substring
791///   of its match, so it can't be content-filtered; callers retry unfiltered when
792///   a filtered scan comes up empty.
793///
794/// The caller decides the fate of the result, which is exactly where the
795/// persist-or-not policy lives: a warming git repo **persists** them via
796/// `replace_files` (folds the scan into the index — demand-first coverage); a
797/// non-git dir ranks them in-memory and discards them (there's no index to fold
798/// into). Streaming — never collect-then-parse — keeps a scan too big to finish
799/// from coming up empty.
800pub fn scan(
801    root: &Path,
802    skip: &HashSet<String>,
803    deadline: Option<Instant>,
804    needle: Option<&[u8]>,
805) -> Vec<crate::store::FileSymbols> {
806    let needle = needle.filter(|n| !n.is_empty());
807    // git's index for a git repo (content-scan a huge repo without traversing it),
808    // else a filesystem walk (the live scan of a non-git dir)
809    let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> =
810        match git_source_candidates(root).filter(|paths| !paths.is_empty()) {
811            Some(paths) => Box::new(paths.into_iter()),
812            None => Box::new(fs_walk_candidates(vec![root.to_path_buf()])),
813        };
814    let mut out: Vec<crate::store::FileSymbols> = Vec::new();
815    let keep = |rel: &str, _: &Path| !skip.contains(rel); // skip already-indexed
816    let _ = stream_walk(
817        root,
818        candidates,
819        deadline,
820        None,
821        needle,
822        HashSet::new(),
823        keep,
824        |fs| {
825            out.push(fs);
826            Ok(())
827        },
828    );
829    out
830}
831
832/// Case-insensitive (ASCII) substring test — `haystack` contains `needle`.
833/// Allocation-free; used to pre-filter live-scan files before parsing.
834fn contains_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
835    if needle.len() > haystack.len() {
836        return false;
837    }
838    haystack
839        .windows(needle.len())
840        .any(|w| w.eq_ignore_ascii_case(needle))
841}
842
843/// Result of revalidating a single file against what's on disk.
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub enum Refresh {
846    /// Nothing to do — content hash still matches, or the file couldn't be read
847    /// right now (left in place rather than forgotten — see [`refresh_file`]).
848    Unchanged,
849    /// File changed; its symbols were re-extracted.
850    Updated,
851}
852
853/// Whether `root` is inside a git work tree. Implicit (opportunistic) indexing
854/// is gated on this so a stray query never walks a non-repo directory. Native
855/// (no `git` fork) — it runs on every search.
856pub fn is_git_repo(root: &Path) -> bool {
857    repo_root(root).is_some()
858}
859
860/// The git work-tree root at or above `path` — the nearest ancestor holding a
861/// `.git` entry — found without shelling out. `.git` may be a directory or a
862/// file (worktrees, submodules), so we test existence either way. `None` when
863/// `path` is not inside a work tree.
864pub fn repo_root(path: &Path) -> Option<std::path::PathBuf> {
865    let start = path.canonicalize().ok()?;
866    start
867        .ancestors()
868        .find(|a| a.join(".git").exists())
869        .map(Path::to_path_buf)
870}
871
872/// The current HEAD commit sha, or `None` outside a git work tree.
873pub fn git_head(root: &Path) -> Option<String> {
874    git_output(root, &["rev-parse", "HEAD"])
875}
876
877/// Whether the work tree has uncommitted changes to *tracked* files (staged or
878/// unstaged). `--untracked-files=no` skips the work-tree-wide untracked-file
879/// scan — the expensive, cold-cache-sensitive part of `git status` on a large
880/// repo (it walks to classify every path against `.gitignore`). This runs on
881/// every search to gate warming, so the scan dominated query-time variance.
882///
883/// The tradeoff: a brand-new *untracked* file isn't seen as a change here, so it
884/// won't be picked up by the opportunistic warm until it's committed (HEAD moves
885/// → warm) or `rq --index`ed. Tracked edits, the common case, are still caught,
886/// and `git status` still refreshes the index so a touched-but-unchanged file
887/// doesn't read as dirty. Empty stdout (clean) reports as `None` via
888/// `git_output`.
889pub fn is_dirty(root: &Path) -> bool {
890    git_output(root, &["status", "--porcelain", "--untracked-files=no"]).is_some()
891}
892
893/// Repo-relative files you're working on this branch: committed changes since
894/// the branch diverged from the trunk, plus uncommitted edits. Empty on the
895/// trunk itself (where it isn't a useful signal) or outside git. Feeds the
896/// branch ranking boost — necessarily a few git calls, but gated to feature
897/// branches.
898pub fn branch_changed_files(root: &Path) -> Vec<String> {
899    let Some(branch) = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]) else {
900        return Vec::new();
901    };
902    if is_trunk(&branch) {
903        return Vec::new();
904    }
905    let Some(trunk) = trunk_ref(root) else {
906        return Vec::new();
907    };
908
909    let mut files: HashMap<String, ()> = HashMap::new();
910    // committed branch changes since divergence from the trunk (three-dot)
911    if let Some(out) = git_output(root, &["diff", "--name-only", &format!("{trunk}...HEAD")]) {
912        files.extend(
913            out.lines()
914                .filter(|l| !l.is_empty())
915                .map(|l| (l.to_string(), ())),
916        );
917    }
918    // uncommitted edits to tracked files
919    if let Some(out) = git_output(root, &["diff", "--name-only", "HEAD"]) {
920        files.extend(
921            out.lines()
922                .filter(|l| !l.is_empty())
923                .map(|l| (l.to_string(), ())),
924        );
925    }
926    files.into_keys().collect()
927}
928
929/// Branch names treated as the trunk — the "active files" signal doesn't apply
930/// there (you're not on a feature branch).
931fn is_trunk(branch: &str) -> bool {
932    matches!(branch, "main" | "master" | "trunk")
933}
934
935/// The trunk ref to diff against: `main` if it exists, else `master`.
936fn trunk_ref(root: &Path) -> Option<String> {
937    ["main", "master"]
938        .into_iter()
939        .find(|name| git_output(root, &["rev-parse", "--verify", "--quiet", name]).is_some())
940        .map(str::to_string)
941}
942
943/// Lazily revalidate one indexed file against disk: re-extract it if its content
944/// changed. This is the staleness check search runs over its top results.
945///
946/// It deliberately **never forgets** a file: a failed read isn't proof of
947/// deletion (a wrong checkout root, a transient FS error, or a race all look the
948/// same), and a search must never destroy index data over it — that bug dropped
949/// whole indexes when a stale checkout root made every read fail. Genuine
950/// deletions are reconciled by an indexing pass ([`run_index`]), which sees the
951/// whole tree at once and can tell "gone" from "couldn't read one file".
952pub fn refresh_file(
953    store: &mut Store,
954    repository_id: i64,
955    root: &Path,
956    rel: &str,
957) -> Result<Refresh, Box<dyn std::error::Error>> {
958    let path = root.join(rel);
959    let source = match std::fs::read_to_string(&path) {
960        Ok(s) => s,
961        Err(_) => return Ok(Refresh::Unchanged), // unreadable now — leave it, don't forget
962    };
963    let hash = content_hash(&source);
964    if store.file_unchanged(repository_id, rel, &hash)? {
965        return Ok(Refresh::Unchanged);
966    }
967    let ext = path
968        .extension()
969        .and_then(|e| e.to_str())
970        .unwrap_or_default();
971    let symbols = match lang::plugin_for_extension(ext) {
972        Some(plugin) => plugin.extract(rel, &source),
973        None => Vec::new(),
974    };
975    let language = symbols
976        .first()
977        .map(|s| s.language.clone())
978        .unwrap_or_else(|| "unknown".to_string());
979    let mtime = file_mtime(&path);
980    store.replace_file_symbols(repository_id, rel, &language, mtime, &hash, &symbols)?;
981    Ok(Refresh::Updated)
982}
983
984/// Best-effort repository identity: upstream git remote, else the local path.
985pub fn detect_identity(root: &Path) -> RepoIdentity {
986    for remote in ["origin", "upstream"] {
987        if let Some(url) = git_output(root, &["remote", "get-url", remote])
988            && let Some(id) = RepoIdentity::from_remote_url(&url)
989        {
990            return id;
991        }
992    }
993    let abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
994    RepoIdentity::local(&abs.to_string_lossy())
995}
996
997/// Run a git command in `root`, returning trimmed stdout on success.
998fn git_output(root: &Path, args: &[&str]) -> Option<String> {
999    let out = Command::new("git")
1000        .arg("-C")
1001        .arg(root)
1002        .args(args)
1003        .output()
1004        .ok()?;
1005    if !out.status.success() {
1006        return None;
1007    }
1008    let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
1009    if s.is_empty() { None } else { Some(s) }
1010}
1011
1012fn content_hash(source: &str) -> String {
1013    // DefaultHasher uses fixed keys, so this is stable across runs — enough for
1014    // change detection (not cryptographic).
1015    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1016    source.hash(&mut hasher);
1017    format!("{:016x}", hasher.finish())
1018}
1019
1020fn file_mtime(path: &Path) -> Option<i64> {
1021    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
1022    let secs = modified.duration_since(UNIX_EPOCH).ok()?.as_secs();
1023    Some(secs as i64)
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029
1030    #[test]
1031    fn sweep_outcome_guards_against_a_failed_empty_walk() {
1032        // normal warm: completed whole-repo sweep finalizes and completes
1033        assert_eq!(
1034            sweep_outcome(true, true, false, true, true),
1035            (true, "complete")
1036        );
1037        // a genuinely empty repo (nothing stored before) still completes
1038        assert_eq!(
1039            sweep_outcome(true, true, true, false, true),
1040            (true, "complete")
1041        );
1042        // THE GUARD (warm only): completed but saw zero files while the index
1043        // held some → don't finalize (don't wipe), stay warming to retry
1044        assert_eq!(
1045            sweep_outcome(true, true, true, true, true),
1046            (false, "warming")
1047        );
1048        // an explicit `--index` (unbounded) is trusted: an empty tree reconciles
1049        assert_eq!(
1050            sweep_outcome(true, true, true, true, false),
1051            (true, "complete")
1052        );
1053        // a budget-cut sweep stays warming and doesn't reconcile
1054        assert_eq!(
1055            sweep_outcome(false, true, false, true, true),
1056            (false, "warming")
1057        );
1058        // a subtree index is always partial, never reconciles
1059        assert_eq!(
1060            sweep_outcome(true, false, false, true, true),
1061            (false, "partial")
1062        );
1063    }
1064
1065    #[test]
1066    fn content_hash_is_stable_and_distinguishes() {
1067        assert_eq!(
1068            content_hash("class Foo\nend"),
1069            content_hash("class Foo\nend")
1070        );
1071        assert_ne!(
1072            content_hash("class Foo\nend"),
1073            content_hash("class Bar\nend")
1074        );
1075    }
1076
1077    #[test]
1078    fn trunk_names_are_recognized() {
1079        assert!(is_trunk("main"));
1080        assert!(is_trunk("master"));
1081        assert!(!is_trunk("feature/x"));
1082        assert!(!is_trunk("dpep/fix"));
1083    }
1084
1085    #[test]
1086    fn prioritize_by_path_is_loose_but_targeted() {
1087        let root = Path::new("/repo");
1088        let paths: Vec<std::path::PathBuf> = [
1089            "companies.rb",         // unrelated → tail
1090            "app/employee.rb",      // near-match → front
1091            "lib/EmpController.rb", // near-match (shares "cont…") → front
1092            "employers.rb",         // near-match (shares "employe") → front
1093            "app/controllers/x.rb", // dir matches but stem doesn't → tail
1094        ]
1095        .iter()
1096        .map(|p| root.join(p))
1097        .collect();
1098        let out = prioritize_by_path(paths.clone(), root, Some("employeescontroller"));
1099        let name = |p: &std::path::PathBuf| p.file_name().unwrap().to_str().unwrap().to_string();
1100        let front: Vec<String> = out[..3].iter().map(name).collect();
1101        assert!(front.contains(&"employee.rb".to_string()), "{front:?}");
1102        assert!(front.contains(&"EmpController.rb".to_string()), "{front:?}");
1103        assert!(front.contains(&"employers.rb".to_string()), "{front:?}");
1104        let tail: Vec<String> = out[3..].iter().map(name).collect();
1105        assert!(tail.contains(&"companies.rb".to_string()), "{tail:?}");
1106        assert!(tail.contains(&"x.rb".to_string()), "{tail:?}"); // dir match isn't enough
1107        // no query → unchanged
1108        assert_eq!(prioritize_by_path(paths.clone(), root, None), paths);
1109    }
1110
1111    #[test]
1112    fn detects_git_work_tree_natively() {
1113        let dir = std::env::temp_dir().join(format!("rq-reporoot-{}", std::process::id()));
1114        let _ = std::fs::remove_dir_all(&dir);
1115        std::fs::create_dir_all(dir.join("sub")).unwrap();
1116
1117        assert!(!is_git_repo(&dir), "no .git yet");
1118        std::fs::create_dir_all(dir.join(".git")).unwrap();
1119        assert!(is_git_repo(&dir), "a .git entry marks a work tree");
1120        // from a subdirectory, repo_root walks up to the work-tree root
1121        assert_eq!(
1122            repo_root(&dir.join("sub")).unwrap(),
1123            dir.canonicalize().unwrap()
1124        );
1125
1126        let _ = std::fs::remove_dir_all(&dir);
1127    }
1128
1129    #[test]
1130    fn parses_git_log_keeping_most_recent_commit_per_file() {
1131        // newest-first: a.rb appears in both commits; the newer ts wins
1132        let log = "1700000000\n\na.rb\nb.rb\n1699990000\n\na.rb\nc.rb\n";
1133        let map = parse_git_log(log);
1134        assert_eq!(map.get("a.rb"), Some(&1700000000));
1135        assert_eq!(map.get("b.rb"), Some(&1700000000));
1136        assert_eq!(map.get("c.rb"), Some(&1699990000));
1137        assert_eq!(map.len(), 3);
1138    }
1139}