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