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