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