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