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