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