Skip to main content

reference_query/cli/
mod.rs

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