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