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