Skip to main content

reference_query/cli/
mod.rs

1//! Command-line surface. Search is the default action: `rq <query>`.
2
3use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::store::Store;
13
14/// Search is the default action (`rq <query>`). Operations are flags rather
15/// than subcommands so no word is reserved — `rq index`, `rq status`, and
16/// `rq record` all search for those symbols. This also matches the rg/fd feel.
17#[derive(Parser)]
18#[command(
19    name = "rq",
20    version,
21    about = "Ranked definition lookup — the one place a symbol is defined, first.",
22    long_about = "rq finds where a symbol is defined and ranks the one you most \
23likely meant to the top — not every match.\n\n\
24Search is the default action; operations are flags, not subcommands, so every \
25word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
26your current repo and recently-active files, and learns from the results you open \
27(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
28    after_help = "EXAMPLES:\n  \
29rq thing                  search for a definition named or like \"thing\"\n  \
30rq wibble --explain       same, plus the score behind each result\n  \
31rq thing --json           machine-readable results (for editors/agents)\n  \
32rq thing --no-record      search without recording it (speculative/agent queries)\n  \
33rq thing --no-wait        answer now from the committed index; don't block on a rebuild\n  \
34rq thing --wait 2s        ...or wait up to a bounded time for the index to warm\n  \
35rq thing app/web          restrict to a directory (rg-style)\n  \
36rq perform -k method      restrict to a symbol kind (c/mod/m/f/s/e/t)\n  \
37rq class Widget           a leading kind keyword is shorthand for -k\n  \
38rq --symbols FILE         outline a file's definitions, in line order\n  \
39rq thing -x rust          restrict to a language (ruby/rust/go/python)\n  \
40rq -o thing               open the best match in your editor (and record it)\n  \
41rq --index                index the current repository\n  \
42rq --status               show indexing coverage\n  \
43rq --drop                 remove this repo's index (opposite of --index)\n\n\
44SHORT FLAGS (easy to misread):\n  \
45-j = --json (not jobs; --jobs is long-only)   -l = --limit (not lang)   -x = --lang\n\n\
46RECORDING (editor/shell hook):\n  \
47rq --record --file <path> --line <n> <query>\n  \
48Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
49to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
50The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
51automatically on the first search in a git repo. On a large, cold repo a search \
52keeps indexing until it can answer rather than reporting a premature \"no \
53matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
54= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
55)]
56struct Cli {
57    /// Search query. With --drop, the repo path/identity to drop; with --record,
58    /// the query the selection was made for.
59    //
60    // `Other` keeps shells from offering filenames here: a search query isn't a
61    // path. The path-valued operations (--index, --symbols) carry their own
62    // value with a path hint instead, so completion is scoped to them.
63    #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
64    target: Option<String>,
65
66    /// Directories to restrict results to (rg-style; same as repeated --path).
67    #[arg(value_name = "PATH")]
68    dirs: Vec<String>,
69
70    /// Show the score breakdown for each result.
71    #[arg(short = 'e', long)]
72    explain: bool,
73
74    /// Don't record this search as a behavioral signal (for agents/scripts).
75    #[arg(long)]
76    no_record: bool,
77
78    /// Answer immediately from the committed index — never block waiting on a
79    /// background (re)index. For agents/scripts: a query issued mid-rebuild
80    /// returns at once (a miss reports `warming`, exit 2, so a caller can retry)
81    /// instead of blocking up to the wait budget. Shorthand for `--wait 0`;
82    /// leftover warming still detaches to a background child.
83    #[arg(long = "no-wait")]
84    no_wait: bool,
85
86    /// How long a query may wait for the index to warm before answering with
87    /// whatever's committed: a duration like `50ms`, `2s`, `1m`, or a bare number
88    /// of seconds. `0` doesn't wait at all (same as `--no-wait`). Overrides
89    /// `RQ_WAIT_BUDGET_MS` for this call (default 1 minute).
90    #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
91    wait: Option<Duration>,
92
93    /// Open the best match in your editor and record the pick, so ranking learns.
94    /// On a terminal with several matches, prompts to choose. Launcher: `RQ_OPEN`
95    /// (a template with `{file}`/`{line}`/`{}` = path:line), else VS Code
96    /// (`code`), else `$VISUAL`/`$EDITOR`, else prints the resolved path:line.
97    #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
98    open: bool,
99
100    /// Print the definition's source, not just its location — but only when the
101    /// top match is confident; otherwise falls back to the ranked list. Pipe to a
102    /// pager (`rq --show foo | less`). JSON adds a `body` field.
103    #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
104    show: bool,
105
106    /// Emit results as a JSON array (for editors and scripts).
107    #[arg(short = 'j', long)]
108    json: bool,
109
110    /// Emit results as newline-delimited JSON, one object per line.
111    #[arg(short = 'J', long, conflicts_with = "json")]
112    ndjson: bool,
113
114    /// Restrict results to files under this repo-relative directory (repeatable).
115    #[arg(short = 'p', long, value_name = "DIR")]
116    path: Vec<String>,
117
118    /// Maximum number of results to show.
119    #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
120    limit: usize,
121
122    /// Restrict to symbol kinds: class, module, method, function, struct, enum,
123    /// trait (shortcuts: c, mod, m, f, s, e, t). Repeatable or comma-separated.
124    #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
125    kind: Vec<String>,
126
127    /// Restrict to languages: ruby, rust, go, python. Prefix-matched, so `r`
128    /// means ruby+rust and `p` means python; aliases rb, rs, golang. Repeatable
129    /// or comma-separated.
130    #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
131    lang: Vec<String>,
132
133    /// Search every indexed repository, not just the current one. By default a
134    /// search inside a repo returns only that repo's definitions.
135    #[arg(long = "all-repos")]
136    all_repos: bool,
137
138    /// Index a repository (PATH, or the current directory).
139    #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
140    index: Option<Option<String>>,
141
142    /// Show indexing coverage per known repository.
143    #[arg(long, conflicts_with_all = ["index", "record"])]
144    status: bool,
145
146    /// List the symbols defined in FILE, in line order — a structural outline,
147    /// not a ranked search. Honors -k/-x to filter by kind/language.
148    #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
149    symbols: Option<String>,
150
151    /// Drop a repository's index — the opposite of --index. Removes its symbols,
152    /// files, coverage, and learned ranking. TARGET is the repo's path (or the
153    /// current repo); a known identity string (as shown by --status) also works.
154    #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
155    drop: bool,
156
157    /// Record an interaction (editor/shell hook): the result opened for a query.
158    /// Requires --file.
159    #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
160    record: bool,
161
162    /// (--record) File that was opened/selected.
163    #[arg(long)]
164    file: Option<String>,
165
166    /// (--record) Line landed on (attributes the selection to a definition).
167    #[arg(long)]
168    line: Option<i64>,
169
170    /// (--record) Event kind (select or open).
171    #[arg(long, default_value = "select")]
172    event: String,
173
174    /// Finish warming a repository's index in the background — the target a
175    /// search re-execs after printing results, detached, so the shell never
176    /// waits on it. Single-flighted per repo; safe to run by hand.
177    #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "record", "drop", "symbols", "open", "show"])]
178    warm: Option<Option<String>>,
179
180    /// Print a shell completion script (bash, zsh, fish, elvish, powershell).
181    #[arg(long, value_name = "SHELL")]
182    completions: Option<Shell>,
183
184    /// Trace what rq decides (root, coverage, warming, reconcile) to stderr —
185    /// for debugging. `RQ_LOG=1` does the same for an installed binary.
186    #[arg(short = 'v', long)]
187    verbose: bool,
188
189    /// Report where a search spent its time, phase by phase, to stderr — as
190    /// JSON alongside --json, so a baseline can be stored and diffed.
191    /// `RQ_PROFILE=1` does the same for an installed binary.
192    #[arg(long)]
193    profile: bool,
194
195    /// Parse worker threads the background indexer uses (0 = auto). (`-j` is
196    /// taken by `--json`, so this is `--jobs` only.) `RQ_JOBS` works too.
197    #[arg(long, value_name = "N", default_value_t = 0)]
198    jobs: usize,
199}
200
201/// Parse arguments and dispatch. Returns the process exit code.
202pub fn run() -> ExitCode {
203    let cli = Cli::parse();
204    crate::trace::enable_from(cli.verbose);
205    crate::profile::enable_from(cli.profile);
206    crate::index::set_parse_jobs(cli.jobs);
207
208    if let Some(shell) = cli.completions {
209        clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
210        return ExitCode::SUCCESS;
211    }
212    if let Some(path) = &cli.index {
213        // index PATH (else cwd); with --path, seed only those subtrees
214        let out = output_format(&cli);
215        return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
216    }
217    if let Some(path) = &cli.warm {
218        return cmd_warm(path.as_deref());
219    }
220    if cli.status {
221        return cmd_status(output_format(&cli));
222    }
223    if cli.drop {
224        let out = output_format(&cli);
225        return cmd_drop(cli.target, out);
226    }
227    if cli.record {
228        // a typo'd --event would otherwise record silently and never roll up
229        if !matches!(cli.event.as_str(), "select" | "open") {
230            return fail(format_args!(
231                "rq --record: unknown --event {:?} (expected select or open)",
232                cli.event
233            ));
234        }
235        // clap guarantees --file is present via `requires`
236        let file = cli.file.expect("--record requires --file");
237        return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
238    }
239    let out = output_format(&cli);
240    let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
241    // a language token can expand to several tags (`r` → ruby + rust)
242    let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
243    if let Some(file) = &cli.symbols {
244        return cmd_symbols(file, &kinds, &langs, out);
245    }
246    // path filters: trailing positionals (rg-style) plus any --path flags
247    let mut paths = cli.path.clone();
248    match cli.target {
249        Some(target) => {
250            // A leading kind keyword (`rq class Foo`) is shorthand for `-k`; skip
251            // it when the user gave an explicit `-k`, so the two never conflict.
252            let query = if cli.kind.is_empty() {
253                let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
254                if let Some(k) = kw {
255                    kinds.push(k.to_string());
256                }
257                paths.extend(dirs);
258                query
259            } else {
260                paths.extend(cli.dirs.clone());
261                target
262            };
263            cmd_search(&SearchArgs {
264                query: &query,
265                explain: cli.explain,
266                out,
267                paths: &paths,
268                kinds: &kinds,
269                langs: &langs,
270                want: cli.limit,
271                no_record: cli.no_record,
272                no_wait: cli.no_wait,
273                wait: cli.wait,
274                open: cli.open,
275                all_repos: cli.all_repos,
276                show: cli.show,
277            })
278        }
279        // bare `rq` (or just flags like --explain with no query): show help
280        None => {
281            let _ = Cli::command().print_long_help();
282            ExitCode::SUCCESS
283        }
284    }
285}
286
287/// How results are rendered.
288#[derive(Clone, Copy, PartialEq)]
289enum Output {
290    Text,
291    Json,
292    Ndjson,
293}
294
295fn output_format(cli: &Cli) -> Output {
296    if cli.ndjson {
297        Output::Ndjson
298    } else if cli.json {
299        Output::Json
300    } else {
301        Output::Text
302    }
303}
304
305/// Minimum headroom to rank before a `--path` filter (so filtered-in results
306/// aren't lost to the cutoff).
307const PATH_HEADROOM: usize = 200;
308
309/// How often the search re-checks the index while a cold repo warms on the
310/// background thread. Each poll runs a full read query against the DB the
311/// indexer is actively writing, so polling too fast steals CPU and read-lock
312/// churn from the warm; 100 ms keeps that pressure low while staying
313/// imperceptible (an early answer or completion appears within a frame, and the
314/// progress line only redraws every `PROGRESS_REDRAW` anyway).
315const POLL_INTERVAL: Duration = Duration::from_millis(100);
316
317/// How long a cold-repo query may wait silently before we tell the user we're
318/// indexing — short enough to explain the pause, long enough that a repo which
319/// indexes quickly never flashes a message.
320const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
321
322/// Minimum gap between progress-line redraws once the heads-up is showing — keeps
323/// the line from flickering (and the count query off the hot path) while still
324/// feeling live.
325const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
326
327/// Everything `rq <query>` needs, bundled from the parsed CLI flags.
328struct SearchArgs<'a> {
329    query: &'a str,
330    explain: bool,
331    out: Output,
332    paths: &'a [String],
333    kinds: &'a [String],
334    langs: &'a [String],
335    /// Number of results to show (`--limit`).
336    want: usize,
337    no_record: bool,
338    /// Answer from the committed index without blocking on a (re)index (`--no-wait`).
339    no_wait: bool,
340    /// Cap on how long to wait for the index to warm (`--wait`); `None` = the
341    /// default/`RQ_WAIT_BUDGET_MS` budget.
342    wait: Option<Duration>,
343    open: bool,
344    all_repos: bool,
345    show: bool,
346}
347
348/// Default action: search the index and print ranked results.
349fn cmd_search(args: &SearchArgs) -> ExitCode {
350    let &SearchArgs {
351        query,
352        out,
353        want,
354        no_record,
355        no_wait,
356        wait,
357        open,
358        all_repos,
359        show,
360        ..
361    } = args;
362    // `--wait DUR` overrides the wait budget for this call; `--wait 0` (or
363    // `--no-wait`) means don't block or warm in-process at all.
364    let wait_budget = wait.unwrap_or_else(wait_budget);
365    let no_wait = no_wait || wait_budget.is_zero();
366    // post-filters (--path, --kind, --lang) need headroom before the cutoff so a
367    // filtered-in result isn't lost to the top-N truncation
368    let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
369        want
370    } else {
371        (want * 20).max(PATH_HEADROOM)
372    };
373    let _timer = crate::trace::Timer::start("search done");
374    let profile_started = std::time::Instant::now();
375    let t_setup = std::time::Instant::now();
376    let open_span = crate::profile::span("store open");
377    let mut store = match open_store() {
378        Ok(s) => s,
379        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
380    };
381    drop(open_span);
382    let setup_span = crate::profile::span("setup");
383    let git_span = crate::profile::span("setup: git root");
384    let cwd = std::env::current_dir().ok();
385    let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
386
387    // Index relative to the repo ROOT, not wherever the search happens to run.
388    // Paths and the stored checkout root must be repo-root-relative and stable, or
389    // a search from a subdirectory would re-key the same repo under subdir-relative
390    // paths — and the deletion reconcile / staleness revalidation would then forget
391    // everything indexed from the root. Outside git, the root is just the cwd.
392    let root = cwd
393        .as_deref()
394        .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
395    drop(git_span);
396
397    // Files you're changing on this feature branch (and their directory
398    // neighbors): the branch ranking boost, and the warm pass's priority set.
399    let mut branch_span = crate::profile::span("setup: branch files");
400    let (active_paths, branch_refresh) = match &root {
401        Some(c) if cwd_is_git => cached_branch_files(&store, c),
402        _ => (Vec::new(), None),
403    };
404    branch_span.note(|| {
405        let how = if branch_refresh.is_some() {
406            "cached, refreshing alongside"
407        } else {
408            "cached"
409        };
410        format!("{} changed, {how}", active_paths.len())
411    });
412    drop(branch_span);
413
414    // Resolve identity from the repo root, cache-first: looked up by checkout root
415    // (no `git remote` fork), falling back to git only the first time we see a
416    // repo. Computed even for non-git dirs so an explicitly `--index`ed one is
417    // still recognized as the current repo below.
418    let mut identity_span = crate::profile::span("setup: identity");
419    let identity = root.as_deref().map(|c| resolve_identity(&store, c));
420    let coverage = identity
421        .as_deref()
422        .and_then(|id| store.coverage_status(id).ok())
423        .flatten();
424    identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
425    drop(identity_span);
426
427    // Opportunistic indexing (Layer 5), time-bounded so the first query in a
428    // large repo never blocks on a full walk. We may warm a git work tree (safe
429    // to auto-discover) *or* any dir we already track — one earns tracking by
430    // being explicitly `--index`ed, which opts a non-git dir in. We never warm
431    // an unknown non-git dir (don't walk a random directory). A subtree index
432    // (`--index --path …`) is a seed, not a fence: coverage stays `warming`, so
433    // warming continues over the rest of the repo from here.
434    let known = coverage.is_some();
435    let warming_ok = cwd_is_git || known;
436    if crate::trace::enabled() {
437        crate::trace!(
438            "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
439            root.as_deref().map_or("?".into(), crate::trace::abbrev),
440            identity.as_deref().unwrap_or("none"),
441            coverage.as_deref().unwrap_or("none"),
442            active_paths.len(),
443        );
444    }
445    let repo_span = crate::profile::span("setup: repo state");
446    let current = identity
447        .as_deref()
448        .and_then(|id| store.repository_id(id).ok().flatten());
449    // Default: scope results to the current repo (when it's indexed) so a search
450    // never leaks another repo's definitions. `--all-repos` searches everything.
451    let only_repo = if all_repos { None } else { current };
452    let active = crate::search::ActiveFiles::new(active_paths.clone());
453
454    // A repeated search (same query, nothing opened since) means last time missed
455    // — decay this query's learned boost before ranking so a stale learned pick
456    // stops dominating. Skipped under --no-record so an agent doesn't perturb it.
457    if !no_record && let Some(repo) = current {
458        let qn = query.to_ascii_lowercase();
459        if store.is_repeat_search(repo, &qn).unwrap_or(false) {
460            let _ = store.decay_selections(repo, &qn);
461        }
462    }
463
464    drop(repo_span);
465    let warm_span = crate::profile::span("setup: warm decision");
466
467    // Warm the index on a background thread (its own connection — WAL lets it
468    // write while we read) whenever there's work: a not-yet-complete repo, or a
469    // complete one changed since it was indexed. The search below reads whatever
470    // it has committed so far. With detach on (the default), this in-process
471    // warm only serves *this* answer — leftover work goes to a detached child
472    // after results print, so the shell never waits on it.
473    let warm_budget = if warm_detach_enabled() {
474        answer_warm_budget()
475    } else {
476        answer_warm_budget() + deferred_warm_budget()
477    };
478    let was_warming = coverage.as_deref() != Some("complete");
479    let want_warm = warming_ok
480        && match &root {
481            Some(c) => {
482                was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
483            }
484            None => false,
485        };
486
487    // Block-until-answered on a cold/partial repo. A bounded warm exists so a
488    // query never hangs, but on a *huge, cold* repo it can expire before the
489    // symbol is indexed — turning a real hit into a false "no matches". Since
490    // correctness beats the first query's latency (and once warm the repo answers
491    // fast), we keep indexing until the answer appears or the repo is fully
492    // indexed — for humans *and* programs alike. Small/medium repos finish inside
493    // the normal budget and are unaffected; only a genuinely large cold repo
494    // waits, and only once.
495    // `--no-wait`: a scripted/agent caller that would rather answer from the
496    // committed index right now than block up to the wait budget while a
497    // background rebuild rewrites the index. It suppresses the block-until-answered
498    // escalation *and* the in-process warm (no lock contention, no join) — leftover
499    // warming still detaches below, so the index keeps improving for next time.
500    let block = want_warm && was_warming && !no_wait;
501    // A human at a plain-text terminal also gets a live progress heads-up and a
502    // graceful Ctrl-C; piped/`--json` callers (agents, scripts) block silently and
503    // are bounded by a wait budget instead, since there's nothing to draw to and
504    // no one to interrupt.
505    let progress_ui = block && show_progress(out, stderr_interactive());
506    let indexer_budget = if block { wait_budget } else { warm_budget };
507    if progress_ui {
508        install_interrupt_handler();
509    }
510
511    // `warm_done` lets the poll stop the instant the indexer finishes — so a miss
512    // on a small repo returns as soon as it's indexed, not at the deadline.
513    let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
514    let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
515        crate::trace!(
516            "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
517            crate::index::parse_jobs()
518        );
519        let root = root.clone().expect("checked");
520        let active = active_paths.clone();
521        let q = query.to_string();
522        let warm_done = std::sync::Arc::clone(&warm_done);
523        std::thread::spawn(move || {
524            if let Ok(mut idx) = open_store() {
525                // path-prioritize toward the query so the relevant file indexes first
526                let _ = if block {
527                    // the abort flag (`INTERRUPTED`) lets a Ctrl-C, a wait timeout,
528                    // or an early answer stop the pass without losing committed work
529                    crate::index::index_budgeted_cancellable(
530                        &mut idx,
531                        &root,
532                        &active,
533                        indexer_budget,
534                        Some(&q),
535                        &INTERRUPTED,
536                    )
537                } else {
538                    crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
539                };
540            }
541            warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
542        })
543    });
544
545    // Poll while a cold/partial repo warms. Don't print the first hit off a sparse
546    // index — a fuzzy or path match can be wrong once more is indexed. Hold until a
547    // *high-confidence* (exact or prefix name) match appears; otherwise keep
548    // building until the index is complete (a "no matches" is then trustworthy), a
549    // wait deadline passes, or — interactively — Ctrl-C. A human sees a progress
550    // line once the pause is noticeable.
551    crate::trace!(
552        "setup (open + repo detect + warm decision): {} ms",
553        t_setup.elapsed().as_millis()
554    );
555    let poll_start = std::time::Instant::now();
556    // Deadline: an interactive block waits unbounded (Ctrl-C escapes); a
557    // programmatic block waits out the wait budget; a non-block (complete repo)
558    // keeps the original fast answer budget.
559    let deadline = if progress_ui {
560        None
561    } else if block {
562        Some(poll_start + wait_budget)
563    } else {
564        Some(poll_start + answer_warm_budget())
565    };
566    drop(warm_span);
567    let polling = indexer.is_some() && was_warming;
568    // Everything before the first search: resolving the repo root, checking
569    // coverage, deciding whether to warm. It runs on every query, so it counts
570    // toward the first-answer budget even though no searching happened yet.
571    drop(setup_span);
572    let mut query_span = crate::profile::span("query");
573    let label = repo_label(root.as_deref());
574    let mut drew_progress = false;
575    let mut last_draw = poll_start;
576    let mut hits = loop {
577        match crate::search::search(&store, query, current, only_repo, &active, limit) {
578            Ok(h) => {
579                let confident = h.first().is_some_and(|hit| {
580                    hit.features
581                        .iter()
582                        .any(|f| matches!(f.name, "exact" | "prefix"))
583                });
584                let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
585                let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
586                let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
587                if !polling || confident || warm_finished || stopped || timed_out {
588                    break h;
589                }
590                if progress_ui
591                    && poll_start.elapsed() >= HEADS_UP_DELAY
592                    && last_draw.elapsed() >= PROGRESS_REDRAW
593                {
594                    draw_progress(&store, identity.as_deref(), &label);
595                    drew_progress = true;
596                    last_draw = std::time::Instant::now();
597                }
598            }
599            Err(e) => {
600                if let Some(h) = indexer {
601                    let _ = h.join();
602                }
603                return fail(format_args!("rq: {e}"));
604            }
605        }
606        std::thread::sleep(POLL_INTERVAL);
607    };
608    query_span.note(|| {
609        if polling {
610            "polled a warming index".to_string()
611        } else {
612            String::new()
613        }
614    });
615    drop(query_span);
616    if drew_progress {
617        clear_progress();
618    }
619    // Captured before we self-cancel below, so it reflects only a *user's* Ctrl-C.
620    let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
621
622    // Staleness: revalidate the files behind the top hits; re-rank once if changed.
623    if !hits.is_empty() && revalidate_top(&mut store, &hits) {
624        hits = crate::search::search(&store, query, current, only_repo, &active, limit)
625            .unwrap_or_default();
626    }
627
628    // Untracked non-git dir — nothing persisted, no warmer running — so scan it
629    // live in-memory (substring, then fuzzy) and blend with whatever the index
630    // gave. The only non-persisting scan left.
631    if !hits.iter().any(strong)
632        && indexer.is_none()
633        && coverage.is_none()
634        && let Some(root) = &root
635    {
636        let tail = live_fallback(root, query, limit);
637        hits = crate::search::merge(hits, tail, limit);
638    }
639
640    apply_gates(query, &mut hits);
641    apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
642
643    if hits.is_empty() {
644        // Stop a still-running block so the join is prompt, then settle coverage.
645        if block {
646            INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
647        }
648        if let Some(h) = indexer {
649            let _ = h.join();
650        }
651        // A miss against a *complete* index is definitive (the symbol isn't
652        // there); against a still-warming one it's only "not yet". Distinguish
653        // them so a caller — agent or script — isn't misled into thinking the
654        // symbol is absent when the index simply hasn't reached it. `--no-wait`
655        // returns without blocking, so its miss is judged the same way — an
656        // incomplete index yields `warming` (exit 2, "retry"), not a false absence.
657        let incomplete = (block || no_wait)
658            && identity
659                .as_deref()
660                .and_then(|id| store.coverage_status(id).ok().flatten())
661                .as_deref()
662                != Some("complete");
663        // a "not yet" miss leaves work behind — let a detached child keep
664        // warming so a retry lands on a more complete index
665        maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
666        return no_match_code(out, query, interrupted, incomplete);
667    }
668
669    // Attach each result's definition line (e.g. `def perform(refund)`) — shown
670    // in text output and carried in JSON. Cheap: only the displayed results.
671    for hit in &mut hits {
672        hit.signature = read_signature(
673            &store,
674            &hit.repo_identity,
675            &hit.file,
676            hit.line,
677            cwd.as_deref(),
678        );
679    }
680    attach_confidence(&mut hits);
681
682    // --show: print the top hit's full source when confident; otherwise fall
683    // through to the normal ranked list (rq won't dump a body it isn't sure of).
684    if show && let Some(code) = show_top_definition(&store, &mut hits, query, out, cwd.as_deref()) {
685        return code;
686    }
687
688    // --open: pick the best match (prompting on a TTY with several), record the
689    // pick so ranking learns, and hand off to the editor. Returns before the
690    // normal print / warm-join — opening should be snappy, and a launcher `exec`s.
691    if open {
692        return finish_open(
693            &mut store,
694            &hits,
695            query,
696            current,
697            root.as_deref(),
698            no_record,
699        );
700    }
701
702    if let Some(code) = render_hits(args, &hits) {
703        return code;
704    }
705
706    // Report before the deferred maintenance below, so the total covers
707    // getting answers out rather than the bookkeeping that follows them.
708    if crate::profile::enabled() {
709        let total = profile_started.elapsed();
710        if args.out == Output::Text {
711            for line in crate::profile::report(total) {
712                eprintln!("{line}");
713            }
714        } else {
715            // stdout stays exactly the results, so the profile can be captured
716            // separately and diffed.
717            eprintln!("{}", crate::profile::json(total));
718        }
719    }
720
721    // Collect the refresh started back at setup. It ran alongside the search
722    // rather than after it, so by now it has usually finished — and it only
723    // ever feeds the *next* query, never this one's ranking, so waiting on it
724    // can't reorder what was just printed.
725    if let Some(refresh) = branch_refresh {
726        refresh.store(&store);
727    }
728
729    // Results are out — now do the cheap deferred work, amortized across
730    // interactions. Under --no-record we skip logging this search (so it isn't a
731    // behavioral signal) but still run maintenance, which only rolls up and
732    // prunes pre-existing events.
733    if !no_record {
734        let _ = store.record_event(
735            "search",
736            Some(&query.to_ascii_lowercase()),
737            current,
738            None,
739            None,
740            None,
741        );
742    }
743    deferred_maintenance(&mut store);
744
745    // Results are out; stop the in-process warm (it persists as it goes, so a
746    // cut pass keeps everything parsed) and join it — then hand whatever's left
747    // to a detached child, which finishes coverage with a budget no foreground
748    // query could afford. The shell only ever waits on the answer.
749    if block {
750        INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
751    }
752    if let Some(h) = indexer {
753        let _ = h.join();
754    }
755    maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
756
757    ExitCode::SUCCESS
758}
759
760/// Re-exec a detached warm child when this query's warming didn't finish the
761/// job. No-op when detach is off, nothing was warming, or coverage completed.
762fn maybe_detach_warm(
763    store: &Store,
764    want_warm: bool,
765    root: Option<&std::path::Path>,
766    identity: Option<&str>,
767) {
768    if !warm_detach_enabled() || !want_warm {
769        return;
770    }
771    let (Some(root), Some(id)) = (root, identity) else {
772        return;
773    };
774    if store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
775        return; // the in-process pass finished the job
776    }
777    spawn_detached_warm(root);
778}
779
780/// Spawn `rq --warm <root>` fully detached: null stdio and its own process
781/// group, so it survives this process and a later Ctrl-C in the terminal
782/// can't reach it. The child nices itself and is single-flighted per repo.
783fn spawn_detached_warm(root: &std::path::Path) {
784    use std::os::unix::process::CommandExt;
785    let Ok(exe) = std::env::current_exe() else {
786        return;
787    };
788    let mut cmd = std::process::Command::new(exe);
789    cmd.arg("--warm")
790        .arg(root)
791        .stdin(std::process::Stdio::null())
792        .stdout(std::process::Stdio::null())
793        .stderr(std::process::Stdio::null())
794        .process_group(0);
795    match cmd.spawn() {
796        Ok(child) => crate::trace!(
797            "detached warm: pid {} for {}",
798            child.id(),
799            crate::trace::abbrev(root)
800        ),
801        Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
802    }
803}
804
805/// How long a warm lock is trusted without a liveness hit — past this, a
806/// stamp is a crashed warmer's leftover and a new child takes over.
807const WARM_LOCK_TTL_SECS: i64 = 600;
808
809/// `rq --warm [PATH]`: the detached child a search re-execs after printing —
810/// finishes warming the repo's index in the background. Niced so it stays out
811/// of the foreground's way; single-flighted per repo so a burst of queries
812/// runs at most one warmer. Safe (and boring) to run by hand.
813fn cmd_warm(path: Option<&str>) -> ExitCode {
814    // Stay out of the way: drop scheduling priority, and throttle disk I/O on
815    // macOS. Best-effort — a failure just means a less-polite warm.
816    #[cfg(target_os = "macos")]
817    unsafe extern "C" {
818        // <sys/resource.h>; not in the libc crate. Args below:
819        // IOPOL_TYPE_DISK=0, IOPOL_SCOPE_PROCESS=0, IOPOL_THROTTLE=3.
820        fn setiopolicy_np(
821            iotype: libc::c_int,
822            scope: libc::c_int,
823            policy: libc::c_int,
824        ) -> libc::c_int;
825    }
826    unsafe {
827        libc::nice(10);
828        #[cfg(target_os = "macos")]
829        setiopolicy_np(0, 0, 3);
830    }
831    let mut store = match open_store() {
832        Ok(s) => s,
833        Err(_) => return ExitCode::FAILURE,
834    };
835    let start = path
836        .map(PathBuf::from)
837        .or_else(|| std::env::current_dir().ok())
838        .unwrap_or_else(|| PathBuf::from("."));
839    let root = crate::index::repo_root(&start).unwrap_or(start);
840    let identity = resolve_identity(&store, &root);
841
842    // Single-flight: if another live rq is already warming this repo, bow out.
843    // A dead pid or a stale stamp is a crashed warmer — take over.
844    if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
845        && pid != std::process::id()
846        && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
847        && now_secs() - ts < WARM_LOCK_TTL_SECS
848    {
849        return ExitCode::SUCCESS;
850    }
851    let _ = store.set_warm_lock(&identity, std::process::id());
852
853    // Sweep until coverage completes, the budget runs out, or a pass stops
854    // making progress (each pass converges — mtime-skips what's done).
855    let deadline = std::time::Instant::now() + warm_bg_budget();
856    let active = crate::index::branch_changed_files(&root);
857    loop {
858        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
859        if remaining.is_zero() {
860            break;
861        }
862        let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
863        {
864            Ok(s) => s,
865            Err(_) => break,
866        };
867        if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
868            || stats.files_indexed == 0
869        {
870            break;
871        }
872    }
873    let _ = store.clear_warm_lock(&identity);
874    ExitCode::SUCCESS
875}
876
877fn now_secs() -> i64 {
878    std::time::SystemTime::now()
879        .duration_since(std::time::UNIX_EPOCH)
880        .map(|d| d.as_secs() as i64)
881        .unwrap_or(0)
882}
883
884/// Live in-memory scan of an untracked (non-git, never-indexed) dir: substring
885/// pre-filtered first, then the unfiltered fuzzy retry. Persists nothing.
886fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
887    crate::trace!("empty → live (in-memory) scan of an untracked dir");
888    let deadline = std::time::Instant::now() + live_fallback_budget();
889    let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
890    if !h.is_empty() {
891        return h;
892    }
893    crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
894}
895
896/// A high-confidence name match: exact or prefix (not fuzzy/path-only).
897fn strong(h: &crate::search::Hit) -> bool {
898    h.features
899        .iter()
900        .any(|f| matches!(f.name, "exact" | "prefix"))
901}
902
903/// The result-quality gates, in order:
904/// - relevance: when the query lands a real name match (exact or prefix), drop
905///   the scattered fuzzy / path-only near-matches — they're noise next to a
906///   solid hit, and rq favors fewer, better results. A purely-fuzzy query (no
907///   exact/prefix anywhere) keeps its matches.
908/// - scope: a qualified query (`Foo::Bar#baz`) that lands inside the named
909///   scope keeps only the in-scope results; if none match, the others stay
910///   (the definition may live elsewhere).
911fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
912    if hits.iter().any(strong) {
913        hits.retain(strong);
914    }
915    crate::search::apply_scope_gate(query, hits);
916}
917
918/// Post-filters: keep only results under a `--path` dir, of a `--kind`, and/or
919/// in a `--lang`, then trim to the requested count.
920fn apply_post_filters(
921    args: &SearchArgs,
922    cwd: Option<&std::path::Path>,
923    root: Option<&std::path::Path>,
924    hits: &mut Vec<crate::search::Hit>,
925) {
926    if !args.paths.is_empty() {
927        // --path values may be absolute or cwd-relative; stored files are
928        // repo-root-relative, so normalize before prefix-matching or an
929        // absolute path would silently filter everything out.
930        let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
931        let base = root.map_or_else(|| here.clone(), PathBuf::from);
932        let norm: Vec<String> = args
933            .paths
934            .iter()
935            .map(|p| repo_relative(&base, &here, p))
936            .collect();
937        hits.retain(|h| under_any(&h.file, &norm));
938    }
939    if !args.kinds.is_empty() {
940        hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
941    }
942    if !args.langs.is_empty() {
943        hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
944    }
945    if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
946        hits.truncate(args.want);
947    }
948}
949
950/// Report a miss and pick its exit code. Structured callers get a reason, not
951/// a bare `[]`/empty: `warming` (retry — index incomplete), `interrupted` (a
952/// stopped block), or `no_match` (definitive). Text keeps its human message.
953/// Exit 2 = indeterminate (index incomplete), 1 = a definitive miss — both
954/// non-zero, so `rq … && …` still reads as "found something".
955fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
956    let status = if interrupted {
957        "interrupted"
958    } else if incomplete {
959        "warming"
960    } else {
961        "no_match"
962    };
963    match out {
964        Output::Json | Output::Ndjson => {
965            let obj = serde_json::json!({ "status": status, "query": query });
966            let _ = emit_json(out, &obj); // the exit code below carries the miss
967        }
968        Output::Text if interrupted => {
969            eprintln!("rq: indexing interrupted — run again to finish")
970        }
971        Output::Text if incomplete => eprintln!(
972            "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
973        ),
974        Output::Text => eprintln!("no matches for {query:?}"),
975    }
976    if incomplete {
977        ExitCode::from(2)
978    } else {
979        ExitCode::FAILURE
980    }
981}
982
983/// Normalized confidence per hit: match quality scaled by dominance over the
984/// other results (needs the whole ranked set). "Best other" is the top score —
985/// or the runner-up, for the top hit itself.
986fn attach_confidence(hits: &mut [crate::search::Hit]) {
987    let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
988        if t.is_none_or(|t| h.score > t) {
989            (Some(h.score), t)
990        } else if s.is_none_or(|s| h.score > s) {
991            (t, Some(h.score))
992        } else {
993            (t, s)
994        }
995    });
996    for hit in hits.iter_mut() {
997        let best_other = if Some(hit.score) == top { second } else { top };
998        hit.confidence = crate::search::confidence(
999            hit.score,
1000            crate::search::match_quality(&hit.features),
1001            best_other,
1002        );
1003    }
1004}
1005
1006/// Print the ranked results (JSON array, NDJSON lines, or highlighted text).
1007/// `Some(exit)` on a serialization failure, `None` on success.
1008fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1009    // Time to the first printed result, not to the last: rq streams, and the
1010    // sub-50 ms budget is about the first answer. A change that speeds the
1011    // total while delaying this one is a regression.
1012    let render_span = crate::profile::span("render");
1013    if let Some(code) = emit_rows(args.out, hits) {
1014        return Some(code);
1015    }
1016    if args.out != Output::Text {
1017        return None;
1018    }
1019    drop(render_span);
1020    let color = match_color();
1021    let c = color.as_deref();
1022    let query = args.query;
1023    if args.show {
1024        // fell through from --show: no single confident match to print
1025        eprintln!(
1026            "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1027            hits.len()
1028        );
1029    }
1030    for hit in hits {
1031        // highlight the chars the query matched — in the name, the
1032        // filename, and the definition line (great for fuzzy matches)
1033        let name = hl(&hit.name, query, c);
1034        let qualified = match &hit.parent {
1035            Some(p) => format!("{name} · {p}"),
1036            None => name,
1037        };
1038        println!(
1039            "{}:{}  {} {}",
1040            hl_path(&hit.file, query, c),
1041            hit.line,
1042            hit.kind,
1043            qualified
1044        );
1045        if let Some(sig) = &hit.signature {
1046            println!("    {}", hl(sig, query, c));
1047        }
1048        if args.explain {
1049            let parts: Vec<String> = hit
1050                .features
1051                .iter()
1052                .map(|f| format!("{} {:.0}", f.name, f.value))
1053                .collect();
1054            println!(
1055                "    confidence {:.2} · score {:.0} = {}",
1056                hit.confidence,
1057                hit.score,
1058                parts.join(" + ")
1059            );
1060        }
1061    }
1062    None
1063}
1064
1065/// Pick a hit for `--open`: the top match, unless we're on an interactive
1066/// terminal with several — then print a short numbered menu and read a choice
1067/// (empty = the top match). `None` means abort (EOF or unparseable input).
1068fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1069    use std::io::{IsTerminal, Write};
1070    if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1071        return hits.first();
1072    }
1073    let mut err = std::io::stderr();
1074    let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1075    for (i, h) in hits.iter().enumerate() {
1076        let _ = writeln!(
1077            err,
1078            "  {}. {}:{}  {} {}",
1079            i + 1,
1080            h.file,
1081            h.line,
1082            h.kind,
1083            h.name
1084        );
1085    }
1086    let _ = write!(err, "rq> ");
1087    let _ = err.flush();
1088    let mut line = String::new();
1089    if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1090        return None; // Ctrl-D
1091    }
1092    parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1093}
1094
1095/// Resolve a menu reply to a 0-based index: blank → 0 (the top match), `N` → N-1
1096/// when in range, anything else → `None` (abort). Pure, so it's unit-tested.
1097fn parse_choice(input: &str, n: usize) -> Option<usize> {
1098    let s = input.trim();
1099    if s.is_empty() {
1100        return Some(0);
1101    }
1102    let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1103    (i < n).then_some(i)
1104}
1105
1106/// `--open`: choose a hit, record it as a selection so ranking learns, then hand
1107/// off to the editor. The launcher `exec`s (replacing this process), so the shell
1108/// waits on the editor — not on rq's background warm.
1109fn finish_open(
1110    store: &mut Store,
1111    hits: &[crate::search::Hit],
1112    query: &str,
1113    current: Option<i64>,
1114    root: Option<&std::path::Path>,
1115    no_record: bool,
1116) -> ExitCode {
1117    let Some(hit) = choose_hit(hits) else {
1118        return ExitCode::SUCCESS; // aborted at the prompt
1119    };
1120
1121    // Record the pick — same signal as `rq --record`. The hit's path is already
1122    // repo-relative, which is what the selection rollup keys off.
1123    if !no_record {
1124        let _ = store.record_event(
1125            "select",
1126            Some(&query.to_ascii_lowercase()),
1127            current,
1128            Some(&hit.file),
1129            Some(hit.line),
1130            None,
1131        );
1132        deferred_maintenance(store);
1133    }
1134
1135    // Results are repo-root-relative, so resolve against the root — the bare path
1136    // wouldn't open from a subdirectory.
1137    let target = match root {
1138        Some(r) => r.join(&hit.file),
1139        None => PathBuf::from(&hit.file),
1140    };
1141    launch_editor(&target, hit.line)
1142}
1143
1144/// Launch the editor on `file:line`, resolving the command in order: `RQ_OPEN`
1145/// template → VS Code (`code`) → `$VISUAL`/`$EDITOR` → print the location. The
1146/// chosen command replaces this process via `exec`.
1147fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1148    use std::os::unix::process::CommandExt;
1149    let loc = format!("{}:{}", file.display(), line);
1150    match open_command(file, line, &loc) {
1151        Some((prog, args)) => {
1152            // exec returns only on failure
1153            let err = std::process::Command::new(&prog).args(&args).exec();
1154            fail(format_args!("rq --open: cannot run {prog}: {err}"))
1155        }
1156        None => {
1157            println!("{loc}");
1158            ExitCode::SUCCESS
1159        }
1160    }
1161}
1162
1163/// Resolve the editor command + args. `None` → no launcher configured (the
1164/// caller prints the location). `RQ_OPEN` is split on whitespace (no shell) with
1165/// `{file}` / `{line}` / `{}` (= `path:line`) substituted per token.
1166fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1167    let fstr = file.to_string_lossy().into_owned();
1168
1169    if let Some(t) = std::env::var_os("RQ_OPEN") {
1170        let t = t.to_string_lossy();
1171        let mut parts = t.split_whitespace().map(|p| {
1172            p.replace("{file}", &fstr)
1173                .replace("{line}", &line.to_string())
1174                .replace("{}", loc)
1175        });
1176        if let Some(prog) = parts.next() {
1177            return Some((prog, parts.collect()));
1178        }
1179    }
1180
1181    if on_path("code") {
1182        return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1183    }
1184
1185    if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1186        let ed = ed.to_string_lossy().into_owned();
1187        let l = ed.to_ascii_lowercase();
1188        // line-aware launch for the common terminal editors; others just get the file
1189        if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1190            .iter()
1191            .any(|e| l.contains(e))
1192        {
1193            return Some((ed, vec![format!("+{line}"), fstr]));
1194        }
1195        return Some((ed, vec![fstr]));
1196    }
1197
1198    None
1199}
1200
1201/// Whether `prog` resolves on `PATH` (a regular file; symlinks followed).
1202fn on_path(prog: &str) -> bool {
1203    std::env::var_os("PATH")
1204        .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1205}
1206
1207/// Whether a complete repo is provably unchanged since its last index — same
1208/// HEAD and a clean work tree — so the deferred re-walk can be skipped. The git
1209/// HEAD + dirty check is cheap (~tens of ms) and authoritative at any size, so
1210/// it gates warming for small and large repos alike: a clean, fully-indexed repo
1211/// has nothing to warm, and re-walking it on every query just to discover that
1212/// wasted a full sweep (~hundreds of ms) per search. Conservative: any
1213/// uncertainty (not complete, non-git / no recorded head, git hiccup) returns
1214/// false, so we warm.
1215/// Seconds since the epoch.
1216fn unix_now() -> i64 {
1217    std::time::SystemTime::now()
1218        .duration_since(std::time::UNIX_EPOCH)
1219        .map(|d| d.as_secs() as i64)
1220        .unwrap_or(0)
1221}
1222
1223/// How long a branch-file list is served before it's refreshed. A commit or a
1224/// checkout is caught by the stamp; a bare working-tree edit touches neither
1225/// `.git/HEAD` nor `.git/index`, so only elapsed time catches that — short
1226/// enough that a burst of searches shares one computation and the edit you just
1227/// made is reflected on the next search.
1228const BRANCH_FILES_TTL_SECS: i64 = 15;
1229
1230/// A branch-file recomputation running alongside the search. The git work
1231/// happens on the thread; the store write waits for the main thread, since a
1232/// SQLite connection isn't shared.
1233struct BranchRefresh {
1234    handle: std::thread::JoinHandle<Vec<String>>,
1235    identity: String,
1236    stamp: String,
1237}
1238
1239impl BranchRefresh {
1240    /// Wait for the recomputation and store it for the next query.
1241    fn store(self, store: &Store) {
1242        let Ok(files) = self.handle.join() else {
1243            return;
1244        };
1245        let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1246    }
1247}
1248
1249/// The branch-changed file list, served from the store when it's still good.
1250/// Returns the list, plus a recomputation to collect after results print when
1251/// the stored one has aged out.
1252///
1253/// The list feeds a *ranking boost*, so serving a slightly old one costs a
1254/// little ranking quality, while recomputing it first would cost every search
1255/// the git diff behind it — which is O(tracked files). So the stored list is
1256/// served immediately and the refresh runs concurrently with the search rather
1257/// than after it, which usually hides its cost entirely. It feeds only the next
1258/// query, so nothing about this run's ranking depends on how the race lands.
1259///
1260/// The first search in a repo has nothing to serve and computes inline; that's
1261/// once per repo, like the first index.
1262fn cached_branch_files(
1263    store: &Store,
1264    root: &std::path::Path,
1265) -> (Vec<String>, Option<BranchRefresh>) {
1266    let identity = resolve_identity(store, root);
1267    let stamp = crate::index::branch_files_stamp(root);
1268    let cached = store.branch_files_get(&identity).ok().flatten();
1269    let now = unix_now();
1270
1271    if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1272        if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1273            return (files.clone(), None);
1274        }
1275        let owned_root = root.to_path_buf();
1276        let refresh = BranchRefresh {
1277            handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1278            identity,
1279            stamp: stamp.clone(),
1280        };
1281        return (files.clone(), Some(refresh));
1282    }
1283
1284    // Nothing cached (or nowhere to cache it, e.g. a worktree): compute inline.
1285    let files = crate::index::branch_changed_files(root);
1286    if let Some(stamp) = stamp {
1287        let _ = store.branch_files_set(&identity, &stamp, now, &files);
1288    }
1289    (files, None)
1290}
1291
1292fn repo_unchanged_since_index(
1293    store: &Store,
1294    cwd: &std::path::Path,
1295    current: Option<i64>,
1296    coverage: Option<&str>,
1297) -> bool {
1298    if coverage != Some("complete") {
1299        return false;
1300    }
1301    let Some(id) = current else { return false };
1302    let indexed_head = store.indexed_head(id).ok().flatten();
1303    indexed_head.is_some()
1304        && crate::index::git_head(cwd) == indexed_head
1305        && !crate::index::is_dirty(cwd)
1306}
1307
1308/// Inline warm budget on the search path. A *cap*, not a fixed delay:
1309/// `index_budgeted` returns the moment a full sweep finishes, so small/medium
1310/// repos index completely and pay only their real cost. The cap only bites a
1311/// genuinely huge, never-indexed repo — where a bigger budget buys a much better
1312/// first answer (a tiny budget can return nothing, since a git repo has no
1313/// live-scan fallback). 500 ms is a one-time cold-cache cost, trivial next to
1314/// scanning a large tree from scratch; the deferred pass and later queries fill
1315/// in the rest.
1316fn answer_warm_budget() -> Duration {
1317    env_budget("RQ_ANSWER_BUDGET_MS", 500)
1318}
1319
1320/// Deferred warm budget, spent after results are printed: larger, to make real
1321/// progress on coverage per query while keeping each invocation snappy.
1322fn deferred_warm_budget() -> Duration {
1323    env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1324}
1325
1326/// Bound for the git-repo live-scan fallback (index empty, still warming): enough
1327/// to surface a result the warm hasn't reached, without an unbounded walk.
1328fn live_fallback_budget() -> Duration {
1329    env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1330}
1331
1332/// Budget for the *detached* warm child — generous, because nothing waits on
1333/// it: the shell got its results and the child runs niced in the background.
1334fn warm_bg_budget() -> Duration {
1335    env_budget("RQ_WARM_BUDGET_MS", 20_000)
1336}
1337
1338/// Whether a search hands leftover warming to a detached child (default) or
1339/// finishes it in-process before exiting (`RQ_WARM_DETACH=0` — used by the
1340/// test harness for hermetic runs, and handy for debugging).
1341fn warm_detach_enabled() -> bool {
1342    std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1343}
1344
1345/// How long a query may block indexing a cold repo before giving up with an
1346/// honest "still indexing" rather than a false miss. A generous backstop, not the
1347/// real cost: `index_budgeted` returns the moment the sweep completes, so any
1348/// normal repo finishes well under it, and an interactive run isn't bounded by it
1349/// at all (Ctrl-C escapes). It mainly bounds a programmatic caller on a
1350/// pathologically huge repo — where the partial index still persists for the next
1351/// query. `RQ_WAIT_BUDGET_MS=0` makes a programmatic caller non-blocking again —
1352/// it answers immediately from whatever's already indexed.
1353fn wait_budget() -> Duration {
1354    env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1355}
1356
1357/// Parse a `--wait` value into a duration: `<n>ms`, `<n>s`, `<n>m`, or a bare
1358/// `<n>` (seconds). Fractions are allowed (`1.5s`); `0` (any unit) means "don't
1359/// wait". A `clap` value parser, so an invalid duration is rejected at parse
1360/// time with a usage error.
1361fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1362    let s = s.trim();
1363    let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1364    // check "ms" before "s" so the "s" arm doesn't swallow it
1365    let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1366        (n, 1.0)
1367    } else if let Some(n) = s.strip_suffix('s') {
1368        (n, 1_000.0)
1369    } else if let Some(n) = s.strip_suffix('m') {
1370        (n, 60_000.0)
1371    } else {
1372        // a bare number is seconds
1373        (s, 1_000.0)
1374    };
1375    let val: f64 = num.trim().parse().map_err(|_| bad())?;
1376    if !val.is_finite() || val < 0.0 {
1377        return Err(bad());
1378    }
1379    Ok(Duration::from_millis((val * unit_ms).round() as u64))
1380}
1381
1382/// Set by the SIGINT handler during an interactive cold-start escalation. The
1383/// poll loop and the running index pass watch it, so Ctrl-C stops the wait
1384/// promptly and prints the best partial results instead of killing the process.
1385static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1386
1387extern "C" fn on_sigint(_: libc::c_int) {
1388    // Async-signal-safe: a lone relaxed atomic store — no allocation, no locks.
1389    INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1390}
1391
1392/// Install the SIGINT handler once. Scoped to the escalation path: a normal fast
1393/// query keeps the default behavior (Ctrl-C kills it outright).
1394fn install_interrupt_handler() {
1395    static ONCE: std::sync::Once = std::sync::Once::new();
1396    ONCE.call_once(|| unsafe {
1397        let mut action: libc::sigaction = std::mem::zeroed();
1398        action.sa_sigaction = on_sigint as *const () as usize;
1399        libc::sigemptyset(&mut action.sa_mask);
1400        libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1401    });
1402}
1403
1404/// Is a human watching stderr? True for a real terminal; `RQ_ASSUME_INTERACTIVE`
1405/// forces it on so the progress/Ctrl-C path is exercisable under test (where
1406/// stderr is a pipe), mirroring the `RQ_*_BUDGET_MS` testing knobs.
1407fn stderr_interactive() -> bool {
1408    std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1409}
1410
1411/// Whether to show the live "indexing…" progress heads-up and handle Ctrl-C
1412/// gracefully while a cold repo blocks — a human watching a plain-text terminal.
1413/// Piped / `--json` / `--ndjson` callers block silently instead (no line to draw,
1414/// no one to interrupt); the *decision to block* is the same for both.
1415fn show_progress(out: Output, interactive: bool) -> bool {
1416    interactive && matches!(out, Output::Text)
1417}
1418
1419/// A short, friendly name for the repo being indexed — its directory name, for
1420/// the progress line.
1421fn repo_label(root: Option<&std::path::Path>) -> String {
1422    root.and_then(|r| r.file_name())
1423        .map(|n| n.to_string_lossy().into_owned())
1424        .unwrap_or_else(|| "repo".into())
1425}
1426
1427/// Redraw the in-place "indexing…" progress line on stderr (kept off stdout so
1428/// piped/`--json` output stays clean). The file count comes from the index the
1429/// background pass is filling, so it climbs as warming proceeds.
1430fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1431    let files = identity
1432        .and_then(|id| store.repository_id(id).ok().flatten())
1433        .and_then(|rid| store.repo_totals(rid).ok())
1434        .map_or(0, |(f, _)| f);
1435    eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1436    let _ = std::io::stderr().flush();
1437}
1438
1439/// Erase the progress line so results print to a clean terminal.
1440fn clear_progress() {
1441    eprint!("\r\x1b[K");
1442    let _ = std::io::stderr().flush();
1443}
1444
1445/// Read a budget (milliseconds) from an env var, else the default. The env knobs
1446/// exist mainly for testing — a tiny budget reproduces large-repo warming
1447/// behavior on a small repo.
1448fn env_budget(var: &str, default_ms: u64) -> Duration {
1449    let ms = std::env::var(var)
1450        .ok()
1451        .and_then(|v| v.parse().ok())
1452        .unwrap_or(default_ms);
1453    Duration::from_millis(ms)
1454}
1455
1456/// How many events to roll up per interaction. Bounded so the deferred pass
1457/// after a command stays quick.
1458const AGGREGATE_BATCH: usize = 256;
1459
1460/// Recent raw events to retain after rollup (enough for repeat detection); the
1461/// rest, once aggregated, are pruned to keep the log from growing unbounded.
1462const KEEP_RECENT_EVENTS: i64 = 200;
1463
1464/// The bounded background work run after a user interaction, once results are
1465/// out: roll new events into the learning rollup, then prune the raw log.
1466fn deferred_maintenance(store: &mut Store) {
1467    let _ = store.aggregate_events(AGGREGATE_BATCH);
1468    let _ = store.prune_events(KEEP_RECENT_EVENTS);
1469}
1470
1471/// Hook entry point: record that `file` was opened/selected for `query`, then
1472/// amortize a chunk of event aggregation.
1473fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1474    let mut store = match open_store() {
1475        Ok(s) => s,
1476        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1477    };
1478    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1479    let identity = crate::index::detect_identity(&cwd).to_string();
1480    let repo_id = store.repository_id(&identity).ok().flatten();
1481
1482    // Store the path repo-relative so the rollup can resolve it against indexed
1483    // files.
1484    let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1485        Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1486        None => file.to_string(),
1487    };
1488    let query_norm = query.map(|q| q.to_ascii_lowercase());
1489
1490    if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1491    {
1492        return fail(format_args!("rq record: {e}"));
1493    }
1494    deferred_maintenance(&mut store);
1495    ExitCode::SUCCESS
1496}
1497
1498/// Candidate on-disk roots that may hold a hit's file, most-current first: every
1499/// checkout root recorded for the repo (newest first), then the cwd (for live
1500/// results, and as a fallback when the stored root is stale — a moved repo keeps
1501/// its old checkout row, and reading from that path fails). Callers read from the
1502/// first candidate that actually has the file.
1503fn hit_file_roots(
1504    store: &Store,
1505    repo_identity: &str,
1506    cwd: Option<&std::path::Path>,
1507) -> Vec<PathBuf> {
1508    let mut roots: Vec<PathBuf> = store
1509        .repository_id(repo_identity)
1510        .ok()
1511        .flatten()
1512        .map(|id| store.checkout_roots(id).unwrap_or_default())
1513        .unwrap_or_default()
1514        .into_iter()
1515        .map(PathBuf::from)
1516        .collect();
1517    if let Some(c) = cwd {
1518        let c = c.to_path_buf();
1519        if !roots.contains(&c) {
1520            roots.push(c);
1521        }
1522    }
1523    roots
1524}
1525
1526/// The definition's source line (trimmed) for a hit — read from the first
1527/// candidate root that has the file (see [`hit_file_roots`]). Best-effort.
1528fn read_signature(
1529    store: &Store,
1530    repo_identity: &str,
1531    file: &str,
1532    line: i64,
1533    cwd: Option<&std::path::Path>,
1534) -> Option<String> {
1535    hit_file_roots(store, repo_identity, cwd)
1536        .into_iter()
1537        .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1538}
1539
1540/// Confidence at or above which `--show` prints a body instead of a list. Exact
1541/// (1.0) and a unique prefix (0.9) clear it; a fuzzy or tied match does not — so
1542/// `--show` never prints a definition it isn't sure about.
1543const SHOW_CONFIDENCE: f64 = 0.85;
1544
1545/// `--show`: if the top hit is confident, read and print its full source span
1546/// and return the exit code; otherwise return `None` to fall through to the
1547/// ranked list. Emits a single object in JSON/NDJSON (with a `body` field).
1548fn show_top_definition(
1549    store: &Store,
1550    hits: &mut [crate::search::Hit],
1551    query: &str,
1552    out: Output,
1553    cwd: Option<&std::path::Path>,
1554) -> Option<ExitCode> {
1555    let top = hits.first()?;
1556    if top.confidence < SHOW_CONFIDENCE {
1557        return None; // ambiguous / weak — let the caller list candidates
1558    }
1559    let end = top.end_line.unwrap_or(top.line);
1560    let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1561    hits[0].body = body;
1562    let top = &hits[0];
1563    match out {
1564        Output::Json | Output::Ndjson => {
1565            // fail loudly on a serialize error, like every other JSON path
1566            return Some(emit_json(out, top));
1567        }
1568        Output::Text => {
1569            let color = match_color();
1570            let c = color.as_deref();
1571            let name = hl(&top.name, query, c);
1572            let qualified = match &top.parent {
1573                Some(p) => format!("{name} · {p}"),
1574                None => name,
1575            };
1576            println!(
1577                "{}:{}  {} {}",
1578                hl_path(&top.file, query, c),
1579                top.line,
1580                top.kind,
1581                qualified
1582            );
1583            match (&top.body, &top.signature) {
1584                (Some(body), _) => println!("{body}"),
1585                // end_line unknown (pre-v4 row) → at least the definition line
1586                (None, Some(sig)) => println!("{sig}"),
1587                (None, None) => {}
1588            }
1589        }
1590    }
1591    Some(ExitCode::SUCCESS)
1592}
1593
1594/// The source span `start..=end` (1-based, inclusive) of a hit — the full
1595/// definition body for `--show`. Best-effort, mirroring [`read_signature`].
1596fn read_span(
1597    store: &Store,
1598    repo_identity: &str,
1599    file: &str,
1600    start: i64,
1601    end: i64,
1602    cwd: Option<&std::path::Path>,
1603) -> Option<String> {
1604    hit_file_roots(store, repo_identity, cwd)
1605        .into_iter()
1606        .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1607}
1608
1609/// Lines `start..=end` (1-based, inclusive) of already-read `content`, joined —
1610/// clamped to the file's bounds. `None` if `start` is past the end.
1611fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1612    let s = usize::try_from(start).ok()?.checked_sub(1)?;
1613    let lines: Vec<&str> = content.lines().collect();
1614    if s >= lines.len() {
1615        return None;
1616    }
1617    let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1618    Some(lines[s..e].join("\n"))
1619}
1620
1621/// The trimmed source line `line` (1-based) of already-read `content`, if
1622/// non-empty — a symbol's definition line. Splitting this out lets `--symbols`
1623/// read one file once instead of re-reading it per symbol.
1624fn signature_in(content: &str, line: i64) -> Option<String> {
1625    let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1626    let l = content.lines().nth(idx)?.trim();
1627    (!l.is_empty()).then(|| l.to_string())
1628}
1629
1630/// One symbol in `rq --symbols` output. Same field names as a search hit
1631/// (`repo`, `signature`) for agent consistency, but no score/features — an
1632/// outline is structural, not ranked.
1633#[derive(serde::Serialize)]
1634struct SymbolOut {
1635    name: String,
1636    kind: String,
1637    language: String,
1638    file: String,
1639    line: i64,
1640    #[serde(skip_serializing_if = "Option::is_none")]
1641    end_line: Option<i64>,
1642    #[serde(skip_serializing_if = "Option::is_none")]
1643    parent: Option<String>,
1644    #[serde(skip_serializing_if = "Option::is_none")]
1645    visibility: Option<String>,
1646    repo: String,
1647    #[serde(skip_serializing_if = "Option::is_none")]
1648    signature: Option<String>,
1649}
1650
1651/// `rq --symbols <file>`: list a file's symbols in line order — a structural
1652/// outline, not a ranked search. Warms the file's repo if it's cold/incomplete or
1653/// changed (same gate as search), then reads straight from the index. Honors
1654/// --kind/--lang filters and --json/--ndjson.
1655fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1656    let mut store = match open_store() {
1657        Ok(s) => s,
1658        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1659    };
1660    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1661    let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1662    let rel = repo_relative(&root, &cwd, file_arg);
1663
1664    let identity = resolve_identity(&store, &root);
1665    let coverage = store.coverage_status(&identity).ok().flatten();
1666    let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1667    let current = store.repository_id(&identity).ok().flatten();
1668    let needs_warm = warming_ok
1669        && (coverage.as_deref() != Some("complete")
1670            || !repo_unchanged_since_index(&store, &root, current, coverage.as_deref()));
1671    if needs_warm {
1672        // Path-prioritize the warm toward the requested file so it indexes first.
1673        let budget = answer_warm_budget() + deferred_warm_budget();
1674        let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1675    }
1676
1677    let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1678        return emit_symbols(out, &[]); // unknown / un-indexed repo → nothing
1679    };
1680    let mut rows = match store.symbols_in_file(repo_id, &rel) {
1681        Ok(r) => r,
1682        Err(e) => return fail(format_args!("rq: {e}")),
1683    };
1684    if !kinds.is_empty() {
1685        rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
1686    }
1687    if !langs.is_empty() {
1688        rows.retain(|r| langs.iter().any(|l| l == &r.language));
1689    }
1690
1691    // Read the source once for signatures (every row is the same file), from
1692    // the first root that actually has it (see `hit_file_roots` — a moved repo
1693    // keeps a stale checkout row, so the first-recorded root can be dead).
1694    let content = hit_file_roots(&store, &identity, Some(&root))
1695        .iter()
1696        .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
1697    let syms: Vec<SymbolOut> = rows
1698        .into_iter()
1699        .map(|r| SymbolOut {
1700            signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
1701            name: r.name,
1702            kind: r.kind,
1703            language: r.language,
1704            file: r.file,
1705            line: r.line,
1706            end_line: r.end_line,
1707            parent: r.parent,
1708            visibility: r.visibility,
1709            repo: r.repo_identity,
1710        })
1711        .collect();
1712    emit_symbols(out, &syms)
1713}
1714
1715/// Render the outline. Exit 0 if any symbols, non-zero if none — rq's exit-code
1716/// convention, matching how search reports an empty result per format.
1717fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
1718    if syms.is_empty() {
1719        match out {
1720            Output::Json | Output::Ndjson => {
1721                let obj = serde_json::json!({ "status": "no_match" });
1722                let _ = emit_json(out, &obj); // exit code below carries the miss
1723            }
1724            Output::Text => eprintln!("no symbols"),
1725        }
1726        return ExitCode::FAILURE;
1727    }
1728    if let Some(code) = emit_rows(out, syms) {
1729        return code;
1730    }
1731    match out {
1732        Output::Json | Output::Ndjson => {}
1733        Output::Text => {
1734            for s in syms {
1735                let qualified = match &s.parent {
1736                    Some(p) => format!("{} · {p}", s.name),
1737                    None => s.name.clone(),
1738                };
1739                println!("{}:{}  {} {}", s.file, s.line, s.kind, qualified);
1740                if let Some(sig) = &s.signature {
1741                    println!("    {sig}");
1742                }
1743            }
1744        }
1745    }
1746    ExitCode::SUCCESS
1747}
1748
1749/// A leading positional that names a symbol kind — the shorthand behind
1750/// `rq class Foo` and `rq method zoom`. Only the full, unambiguous keyword forms
1751/// count (never the single-letter `-k` shortcuts, which are far likelier to be a
1752/// real query). Returns the canonical kind, so it filters exactly like `--kind`.
1753fn keyword_kind(token: &str) -> Option<&'static str> {
1754    match token.to_ascii_lowercase().as_str() {
1755        "class" => Some("class"),
1756        "module" => Some("module"),
1757        "method" => Some("method"),
1758        "function" | "fn" => Some("function"),
1759        "struct" => Some("struct"),
1760        "enum" => Some("enum"),
1761        "trait" => Some("trait"),
1762        _ => None,
1763    }
1764}
1765
1766/// Peel a leading kind keyword off the query, so `rq class Foo` (or the quoted
1767/// `rq 'class Foo'`) means `-k class` + query `Foo`. The keyword must be followed
1768/// by a real query token — a bare `rq class` stays a search for a symbol literally
1769/// named `class`. Returns `(kind, query, trailing_path_dirs)`; the trailing dirs
1770/// are the rg-style positionals left after the query is consumed.
1771fn split_kind_keyword(
1772    target: String,
1773    dirs: Vec<String>,
1774) -> (Option<&'static str>, String, Vec<String>) {
1775    // Quoted form: the whole thing is one arg (`"class Foo"`), so peel the first
1776    // whitespace-separated word and keep the remainder as the query.
1777    if let Some((head, rest)) = target.split_once(char::is_whitespace) {
1778        let rest = rest.trim();
1779        if let Some(k) = keyword_kind(head)
1780            && !rest.is_empty()
1781        {
1782            return (Some(k), rest.to_string(), dirs);
1783        }
1784    } else if let Some(k) = keyword_kind(&target)
1785        && let Some((query, extra)) = dirs.split_first()
1786    {
1787        // Unquoted form: `rq class Foo` — the next positional is the query.
1788        return (Some(k), query.clone(), extra.to_vec());
1789    }
1790    (None, target, dirs)
1791}
1792
1793/// Normalize a `--kind` value (name or shortcut) to a canonical symbol kind.
1794/// Unknown values pass through lowercased (so they simply match nothing).
1795fn canonical_kind(s: &str) -> String {
1796    match s.to_ascii_lowercase().as_str() {
1797        "c" | "class" => "class",
1798        "m" | "method" => "method",
1799        "f" | "fn" | "func" | "function" => "function",
1800        "mod" | "module" => "module",
1801        "s" | "struct" => "struct",
1802        "e" | "enum" => "enum",
1803        "t" | "trait" => "trait",
1804        other => return other.to_string(),
1805    }
1806    .to_string()
1807}
1808
1809/// Expand a `--lang` value to the language tag(s) it selects: a **prefix** of any
1810/// known language name (so `r` → ruby+rust, `p`/`py` → python, `g` → go), plus a
1811/// few non-prefix aliases (`rb`→ruby, `rs`→rust, `golang`→go). An unknown value
1812/// passes through lowercased so it simply matches nothing.
1813fn canonical_langs(s: &str) -> Vec<String> {
1814    let t = s.to_ascii_lowercase();
1815    let alias = match t.as_str() {
1816        "rb" => Some("ruby"),
1817        "rs" => Some("rust"),
1818        "golang" => Some("go"),
1819        _ => None,
1820    };
1821    let matched: Vec<String> = crate::lang::languages()
1822        .into_iter()
1823        .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
1824        .map(str::to_string)
1825        .collect();
1826    if matched.is_empty() { vec![t] } else { matched }
1827}
1828
1829/// The ANSI SGR code for highlighting matches, or `None` to disable color.
1830/// Off unless stdout is a terminal; honors `NO_COLOR`; takes the match style
1831/// from `GREP_COLORS` (`mt`/`ms`) when set, else grep's default bold red.
1832fn match_color() -> Option<String> {
1833    if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
1834        return None;
1835    }
1836    let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
1837        gc.split(':').find_map(|e| {
1838            e.strip_prefix("mt=")
1839                .or_else(|| e.strip_prefix("ms="))
1840                .filter(|v| !v.is_empty())
1841                .map(str::to_string)
1842        })
1843    });
1844    Some(style.unwrap_or_else(|| "1;31".to_string()))
1845}
1846
1847/// Highlight the chars of `text` that `query` matched (no-op when `color` is
1848/// `None`, e.g. piped output).
1849fn hl(text: &str, query: &str, color: Option<&str>) -> String {
1850    match color {
1851        Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
1852        None => text.to_string(),
1853    }
1854}
1855
1856/// Like [`hl`], but only over a path's filename — so matched chars light up in
1857/// `payrolls_controller.rb`, not scattered across the directory parts.
1858fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
1859    let Some(c) = color else {
1860        return path.to_string();
1861    };
1862    let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
1863    let base_start = path[..base_byte].chars().count();
1864    // align on the filename *stem* (drop the extension), the same string the
1865    // scorer matched — so the query can't straggle into `.rb` instead of lighting
1866    // up the logical name (`employees_controller`)
1867    let stem = crate::search::path_stem(path);
1868    let positions: Vec<usize> = crate::search::match_positions(query, stem)
1869        .into_iter()
1870        .map(|p| p + base_start)
1871        .collect();
1872    highlight(path, &positions, c)
1873}
1874
1875/// Wrap the matched character positions of `text` in an ANSI color run.
1876/// Consecutive matched chars share one escape sequence.
1877fn highlight(text: &str, positions: &[usize], color: &str) -> String {
1878    if positions.is_empty() {
1879        return text.to_string();
1880    }
1881    let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
1882    let mut out = String::new();
1883    let mut on = false;
1884    for (i, c) in text.chars().enumerate() {
1885        match (matched.contains(&i), on) {
1886            (true, false) => {
1887                out.push_str("\x1b[");
1888                out.push_str(color);
1889                out.push('m');
1890                on = true;
1891            }
1892            (false, true) => {
1893                out.push_str("\x1b[0m");
1894                on = false;
1895            }
1896            _ => {}
1897        }
1898        out.push(c);
1899    }
1900    if on {
1901        out.push_str("\x1b[0m");
1902    }
1903    out
1904}
1905
1906/// Whether a repo-relative `file` sits under one of the `--path` directories
1907/// (prefix match on a path boundary). `app/services` matches
1908/// `app/services/refund.rb` but not `app/services_old/x.rb`.
1909fn under_any(file: &str, paths: &[String]) -> bool {
1910    paths.iter().any(|p| {
1911        let p = p.trim_start_matches("./").trim_end_matches('/');
1912        p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
1913    })
1914}
1915
1916/// Resolve a possibly-absolute or cwd-relative path to a repo-relative one.
1917fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
1918    let p = std::path::Path::new(file);
1919    let abs = if p.is_absolute() {
1920        p.to_path_buf()
1921    } else {
1922        cwd.join(p)
1923    };
1924    let abs = abs.canonicalize().unwrap_or(abs);
1925    abs.strip_prefix(root)
1926        .map(|r| r.to_string_lossy().into_owned())
1927        .unwrap_or_else(|_| file.to_string())
1928}
1929
1930/// Revalidate the files behind the top hits against disk, refreshing any that
1931/// changed and forgetting any that were deleted. Returns true if anything
1932/// changed (so the caller re-runs the search).
1933fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
1934    use std::collections::HashSet;
1935    let mut seen = HashSet::new();
1936    let mut changed = false;
1937    for hit in hits {
1938        if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
1939            continue;
1940        }
1941        let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
1942            continue;
1943        };
1944        let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
1945            continue;
1946        };
1947        if let Ok(crate::index::Refresh::Updated) =
1948            crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
1949        {
1950            changed = true;
1951        }
1952    }
1953    changed
1954}
1955
1956/// The repository's normalized identity for `cwd`, cache-first: look it up by
1957/// the canonical cwd (the checkout root indexing records), so a known repo (git
1958/// or explicitly `--index`ed) costs no `git` fork. On a cache miss, a non-git
1959/// dir resolves to its `local:` path directly (still no fork); only a git work
1960/// tree we haven't seen yet pays a `git remote` call.
1961fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
1962    if let Ok(canon) = cwd.canonicalize() {
1963        if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
1964            return identity;
1965        }
1966        if crate::index::repo_root(cwd).is_none() {
1967            return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
1968        }
1969    }
1970    crate::index::detect_identity(cwd).to_string()
1971}
1972
1973fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
1974    let explicit = path.is_some();
1975    let target = path.unwrap_or_else(|| PathBuf::from("."));
1976    // Normalize to the repo root: the index is repo-root-relative, so indexing
1977    // from a subdirectory must still key off the root (a subdir-relative index
1978    // would mismatch a later search and get reconciled away). `--path` scopes a
1979    // subset; outside git the target is used as-is.
1980    let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
1981    // An explicit TARGET *inside* the repo scopes the index to that subtree — the
1982    // user pointed at a subdir, not the whole repo, and shouldn't pay to walk
1983    // everything. Folded in alongside any `--path` subdirs. (A bare `rq --index`
1984    // with no target still walks the whole repo.)
1985    let mut subdirs = subdirs.to_vec();
1986    if explicit
1987        && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
1988        && t != r
1989        && let Ok(rel) = t.strip_prefix(&r)
1990        && !rel.as_os_str().is_empty()
1991    {
1992        subdirs.push(rel.to_string_lossy().into_owned());
1993    }
1994    let mut store = match open_store() {
1995        Ok(s) => s,
1996        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1997    };
1998    let identity = crate::index::detect_identity(&root).to_string();
1999    match crate::index::index_under(&mut store, &root, &subdirs) {
2000        Ok(stats) => {
2001            let subtree = !subdirs.is_empty();
2002            // distinguish this run's incremental work from the index totals
2003            let totals = store
2004                .repository_id(&identity)
2005                .ok()
2006                .flatten()
2007                .and_then(|id| store.repo_totals(id).ok());
2008            match out {
2009                Output::Json | Output::Ndjson => {
2010                    let (files, symbols) = match totals {
2011                        Some((f, s)) => (Some(f), Some(s)),
2012                        None => (None, None),
2013                    };
2014                    return emit_json(
2015                        out,
2016                        &serde_json::json!({
2017                            "repo": identity,
2018                            "scope": if subtree { "subtree" } else { "full" },
2019                            "files_added": stats.files_indexed,
2020                            "symbols_added": stats.symbols,
2021                            "files": files,
2022                            "symbols": symbols,
2023                        }),
2024                    );
2025                }
2026                Output::Text => {
2027                    let scope = if subtree { " (subtree seed)" } else { "" };
2028                    match totals {
2029                        Some((files, symbols)) => println!(
2030                            "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2031                            stats.files_indexed, stats.symbols
2032                        ),
2033                        None => println!(
2034                            "{} file(s)/{} symbol(s) added this run{scope}",
2035                            stats.files_indexed, stats.symbols
2036                        ),
2037                    }
2038                }
2039            }
2040            ExitCode::SUCCESS
2041        }
2042        Err(e) => fail(format_args!("rq --index: {e}")),
2043    }
2044}
2045
2046fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2047    let mut store = match open_store() {
2048        Ok(s) => s,
2049        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2050    };
2051
2052    // Resolve the repo to drop: TARGET as a path (→ repo root → identity, like
2053    // --index), falling back to TARGET as a literal identity string — so cruft
2054    // shown by --status can be dropped by name even if the checkout is gone.
2055    let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2056    let root = crate::index::repo_root(&path).unwrap_or(path);
2057    let from_path = crate::index::detect_identity(&root).to_string();
2058    let resolved = match store.repository_id(&from_path) {
2059        Ok(Some(id)) => Some((from_path.clone(), id)),
2060        Ok(None) => target.as_deref().and_then(|s| {
2061            store
2062                .repository_id(s)
2063                .ok()
2064                .flatten()
2065                .map(|id| (s.to_string(), id))
2066        }),
2067        Err(e) => return fail(format_args!("rq --drop: {e}")),
2068    };
2069
2070    let Some((identity, repo_id)) = resolved else {
2071        // nothing to drop — idempotent. `dropped: false` lets a script tell.
2072        return match out {
2073            Output::Text => {
2074                println!("not indexed: {from_path}");
2075                ExitCode::SUCCESS
2076            }
2077            _ => emit_json(
2078                out,
2079                &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2080            ),
2081        };
2082    };
2083
2084    let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2085    match store.drop_repository(repo_id) {
2086        Ok(()) => match out {
2087            Output::Text => {
2088                println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2089                ExitCode::SUCCESS
2090            }
2091            _ => emit_json(
2092                out,
2093                &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2094            ),
2095        },
2096        Err(e) => fail(format_args!("rq --drop: {e}")),
2097    }
2098}
2099
2100/// Print a single value as JSON: `--json` pretty, `--ndjson` compact one-liner.
2101/// Used by the single-object operations (`--index`, `--drop`) and the
2102/// no-match status objects; [`emit_rows`] is the multi-row twin.
2103fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2104    let rendered = if out == Output::Json {
2105        serde_json::to_string_pretty(value)
2106    } else {
2107        serde_json::to_string(value)
2108    };
2109    match rendered {
2110        Ok(s) => {
2111            println!("{s}");
2112            ExitCode::SUCCESS
2113        }
2114        Err(e) => fail(format_args!("rq: {e}")),
2115    }
2116}
2117
2118/// Print a row set as structured output: `--json` one pretty array, `--ndjson`
2119/// one compact object per line. Returns `Some(exit)` on a serialization
2120/// failure, `None` on success (Text output is the caller's business).
2121fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2122    match out {
2123        Output::Json => match serde_json::to_string_pretty(rows) {
2124            Ok(s) => println!("{s}"),
2125            Err(e) => return Some(fail(format_args!("rq: {e}"))),
2126        },
2127        Output::Ndjson => {
2128            for r in rows {
2129                match serde_json::to_string(r) {
2130                    Ok(line) => println!("{line}"),
2131                    Err(e) => return Some(fail(format_args!("rq: {e}"))),
2132                }
2133            }
2134        }
2135        Output::Text => {}
2136    }
2137    None
2138}
2139
2140fn cmd_status(out: Output) -> ExitCode {
2141    let store = match open_store() {
2142        Ok(s) => s,
2143        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2144    };
2145    let rows = match store.coverage_overview() {
2146        Ok(rows) => rows,
2147        Err(e) => return fail(format_args!("rq --status: {e}")),
2148    };
2149    if let Some(code) = emit_rows(out, &rows) {
2150        return code;
2151    }
2152    match out {
2153        Output::Json | Output::Ndjson => {}
2154        Output::Text if rows.is_empty() => {
2155            println!("no repositories indexed yet (try `rq --index`)");
2156        }
2157        Output::Text => {
2158            for r in &rows {
2159                println!(
2160                    "{:<10} {:>6} files  {:>7} symbols  {}",
2161                    r.status, r.files, r.symbols, r.identity
2162                );
2163            }
2164        }
2165    }
2166    ExitCode::SUCCESS
2167}
2168
2169/// Open the rq database, honoring `RQ_DB` and creating parent dirs.
2170fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2171    let path = db_path()?;
2172    if let Some(parent) = path.parent() {
2173        std::fs::create_dir_all(parent)?;
2174    }
2175    Ok(Store::open(&path)?)
2176}
2177
2178/// Resolve the database path: `$RQ_DB`, else `$HOME/.local/share/rq/rq.db`.
2179fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2180    if let Ok(p) = std::env::var("RQ_DB") {
2181        return Ok(PathBuf::from(p));
2182    }
2183    let home = std::env::var("HOME")?;
2184    Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2185}
2186
2187fn fail(args: std::fmt::Arguments) -> ExitCode {
2188    eprintln!("{args}");
2189    ExitCode::FAILURE
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194    use super::*;
2195
2196    #[test]
2197    fn open_menu_choice_parsing() {
2198        // blank reply takes the top match; a valid number maps to its index
2199        assert_eq!(parse_choice("\n", 5), Some(0));
2200        assert_eq!(parse_choice("  ", 5), Some(0));
2201        assert_eq!(parse_choice("3", 5), Some(2));
2202        assert_eq!(parse_choice("5", 5), Some(4));
2203        // out of range, zero, or non-numeric aborts
2204        assert_eq!(parse_choice("6", 5), None);
2205        assert_eq!(parse_choice("0", 5), None);
2206        assert_eq!(parse_choice("q", 5), None);
2207    }
2208
2209    #[test]
2210    fn wait_duration_parsing() {
2211        use std::time::Duration;
2212        // units: ms / s / m, and a bare number is seconds
2213        assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2214        assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2215        assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2216        assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2217        // fractions and zero
2218        assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2219        assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2220        assert!(parse_wait("0s").unwrap().is_zero());
2221        // surrounding whitespace is tolerated
2222        assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2223        // garbage, empty, and negatives are rejected (a usage error at parse time)
2224        assert!(parse_wait("2x").is_err());
2225        assert!(parse_wait("").is_err());
2226        assert!(parse_wait("s").is_err());
2227        assert!(parse_wait("-1s").is_err());
2228    }
2229
2230    #[test]
2231    fn leading_kind_keyword_becomes_a_kind_filter() {
2232        let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2233        // unquoted: `rq class Widget` — keyword + next positional is the query
2234        assert_eq!(
2235            split_kind_keyword("class".into(), d(&["Widget"])),
2236            (Some("class"), "Widget".into(), vec![])
2237        );
2238        // quoted: `rq 'method zoom'` — one arg, peel the first word
2239        assert_eq!(
2240            split_kind_keyword("method zoom".into(), vec![]),
2241            (Some("method"), "zoom".into(), vec![])
2242        );
2243        // `fn` is an alias for function; composes with a qualifier tail
2244        assert_eq!(
2245            split_kind_keyword("fn".into(), d(&["Foo::run"])),
2246            (Some("function"), "Foo::run".into(), vec![])
2247        );
2248        // extra positionals after the query stay as rg-style path dirs
2249        assert_eq!(
2250            split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2251            (Some("struct"), "Gadget".into(), d(&["src"]))
2252        );
2253    }
2254
2255    #[test]
2256    fn a_bare_or_non_keyword_query_is_left_alone() {
2257        let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2258        // a keyword with no following query token is a search for that literal name
2259        assert_eq!(
2260            split_kind_keyword("class".into(), vec![]),
2261            (None, "class".into(), vec![])
2262        );
2263        // an ordinary query is untouched, trailing dirs preserved
2264        assert_eq!(
2265            split_kind_keyword("Widget".into(), d(&["app"])),
2266            (None, "Widget".into(), d(&["app"]))
2267        );
2268        // single-letter `-k` shortcuts are NOT keywords here (too query-like)
2269        assert_eq!(
2270            split_kind_keyword("c".into(), d(&["Foo"])),
2271            (None, "c".into(), d(&["Foo"]))
2272        );
2273    }
2274
2275    #[test]
2276    fn highlight_wraps_matched_runs() {
2277        assert_eq!(
2278            highlight("FooThing", &[0, 1, 2], "1;31"),
2279            "\u{1b}[1;31mFoo\u{1b}[0mThing"
2280        );
2281        // scattered matches get separate runs
2282        assert_eq!(
2283            highlight("FooThing", &[0, 3], "1"),
2284            "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2285        );
2286        // nothing matched → unchanged
2287        assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2288    }
2289
2290    #[test]
2291    fn progress_ui_only_for_an_interactive_text_terminal() {
2292        // a person at a terminal, plain text → live progress + graceful Ctrl-C
2293        assert!(show_progress(Output::Text, true));
2294
2295        // machine-readable output blocks silently (no progress line to corrupt it)
2296        assert!(!show_progress(Output::Json, true));
2297        assert!(!show_progress(Output::Ndjson, true));
2298
2299        // not a terminal (a script/agent/pipe) — block, but without the UI
2300        assert!(!show_progress(Output::Text, false));
2301    }
2302
2303    #[test]
2304    fn repo_label_uses_the_directory_name() {
2305        assert_eq!(
2306            repo_label(Some(std::path::Path::new("/src/widgets"))),
2307            "widgets"
2308        );
2309        assert_eq!(repo_label(None), "repo");
2310    }
2311
2312    #[test]
2313    fn hl_path_highlights_the_stem_not_the_extension() {
2314        // matching `employeescontroller`, the highlight covers the logical name in
2315        // the stem and never straggles into `.rb`
2316        let out = hl_path(
2317            "app/employees_controller.rb",
2318            "employeescontroller",
2319            Some("1;31"),
2320        );
2321        assert!(
2322            out.starts_with("app/\u{1b}[1;31memployees"),
2323            "stem highlighted: {out:?}"
2324        );
2325        assert!(
2326            out.ends_with("controller\u{1b}[0m.rb"),
2327            "`.rb` left un-highlighted: {out:?}"
2328        );
2329    }
2330}