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