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;
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 app/web          restrict to a directory (rg-style)\n  \
34rq perform -k method      restrict to a symbol kind (c/mod/m/f/s/e/t)\n  \
35rq --symbols FILE         outline a file's definitions, in line order\n  \
36rq thing -x rust          restrict to a language (ruby/rust/go/python)\n  \
37rq -o thing               open the best match in your editor (and record it)\n  \
38rq --index                index the current repository\n  \
39rq --status               show indexing coverage\n  \
40rq --drop                 remove this repo's index (opposite of --index)\n\n\
41SHORT FLAGS (easy to misread):\n  \
42-j = --json (not jobs; --jobs is long-only)   -l = --limit (not lang)   -x = --lang\n\n\
43RECORDING (editor/shell hook):\n  \
44rq --record --file <path> --line <n> <query>\n  \
45Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
46to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
47The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
48automatically on the first search in a git repo."
49)]
50struct Cli {
51    /// Search query. With --drop, the repo path/identity to drop; with --record,
52    /// the query the selection was made for.
53    //
54    // `Other` keeps shells from offering filenames here: a search query isn't a
55    // path. The path-valued operations (--index, --symbols) carry their own
56    // value with a path hint instead, so completion is scoped to them.
57    #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
58    target: Option<String>,
59
60    /// Directories to restrict results to (rg-style; same as repeated --path).
61    #[arg(value_name = "PATH")]
62    dirs: Vec<String>,
63
64    /// Show the score breakdown for each result.
65    #[arg(short = 'e', long)]
66    explain: bool,
67
68    /// Don't record this search as a behavioral signal (for agents/scripts).
69    #[arg(long)]
70    no_record: bool,
71
72    /// Open the best match in your editor and record the pick, so ranking learns.
73    /// On a terminal with several matches, prompts to choose. Launcher: `RQ_OPEN`
74    /// (a template with `{file}`/`{line}`/`{}` = path:line), else VS Code
75    /// (`code`), else `$VISUAL`/`$EDITOR`, else prints the resolved path:line.
76    #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
77    open: bool,
78
79    /// Emit results as a JSON array (for editors and scripts).
80    #[arg(short = 'j', long)]
81    json: bool,
82
83    /// Emit results as newline-delimited JSON, one object per line.
84    #[arg(short = 'J', long, conflicts_with = "json")]
85    ndjson: bool,
86
87    /// Restrict results to files under this repo-relative directory (repeatable).
88    #[arg(short = 'p', long, value_name = "DIR")]
89    path: Vec<String>,
90
91    /// Maximum number of results to show.
92    #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
93    limit: usize,
94
95    /// Restrict to symbol kinds: class, module, method, function, struct, enum,
96    /// trait (shortcuts: c, mod, m, f, s, e, t). Repeatable or comma-separated.
97    #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
98    kind: Vec<String>,
99
100    /// Restrict to languages: ruby, rust, go, python. Prefix-matched, so `r`
101    /// means ruby+rust and `p` means python; aliases rb, rs, golang. Repeatable
102    /// or comma-separated.
103    #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
104    lang: Vec<String>,
105
106    /// Index a repository (PATH, or the current directory).
107    #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
108    index: Option<Option<String>>,
109
110    /// Show indexing coverage per known repository.
111    #[arg(long, conflicts_with_all = ["index", "record"])]
112    status: bool,
113
114    /// List the symbols defined in FILE, in line order — a structural outline,
115    /// not a ranked search. Honors -k/-x to filter by kind/language.
116    #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
117    symbols: Option<String>,
118
119    /// Drop a repository's index — the opposite of --index. Removes its symbols,
120    /// files, coverage, and learned ranking. TARGET is the repo's path (or the
121    /// current repo); a known identity string (as shown by --status) also works.
122    #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
123    drop: bool,
124
125    /// Record an interaction (editor/shell hook): the result opened for a query.
126    /// Requires --file.
127    #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
128    record: bool,
129
130    /// (--record) File that was opened/selected.
131    #[arg(long)]
132    file: Option<String>,
133
134    /// (--record) Line landed on (attributes the selection to a definition).
135    #[arg(long)]
136    line: Option<i64>,
137
138    /// (--record) Event kind (select or open).
139    #[arg(long, default_value = "select")]
140    event: String,
141
142    /// Print a shell completion script (bash, zsh, fish, elvish, powershell).
143    #[arg(long, value_name = "SHELL")]
144    completions: Option<Shell>,
145
146    /// Trace what rq decides (root, coverage, warming, reconcile) to stderr —
147    /// for debugging. `RQ_LOG=1` does the same for an installed binary.
148    #[arg(short = 'v', long)]
149    verbose: bool,
150
151    /// Parse worker threads the background indexer uses (0 = auto). (`-j` is
152    /// taken by `--json`, so this is `--jobs` only.) `RQ_JOBS` works too.
153    #[arg(long, value_name = "N", default_value_t = 0)]
154    jobs: usize,
155}
156
157/// Parse arguments and dispatch. Returns the process exit code.
158pub fn run() -> ExitCode {
159    let cli = Cli::parse();
160    crate::trace::enable_from(cli.verbose);
161    crate::index::set_parse_jobs(cli.jobs);
162
163    if let Some(shell) = cli.completions {
164        clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
165        return ExitCode::SUCCESS;
166    }
167    if let Some(path) = &cli.index {
168        // index PATH (else cwd); with --path, only those subtrees (partial)
169        let out = output_format(&cli);
170        return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
171    }
172    if cli.status {
173        return cmd_status(output_format(&cli));
174    }
175    if cli.drop {
176        let out = output_format(&cli);
177        return cmd_drop(cli.target, out);
178    }
179    if cli.record {
180        // clap guarantees --file is present via `requires`
181        let file = cli.file.expect("--record requires --file");
182        return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
183    }
184    let out = output_format(&cli);
185    // path filters: trailing positionals (rg-style) plus any --path flags
186    let mut paths = cli.path.clone();
187    paths.extend(cli.dirs.clone());
188    let kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
189    // a language token can expand to several tags (`r` → ruby + rust)
190    let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
191    if let Some(file) = &cli.symbols {
192        return cmd_symbols(file, &kinds, &langs, out);
193    }
194    match cli.target {
195        Some(query) => cmd_search(
196            &query,
197            cli.explain,
198            out,
199            &paths,
200            &kinds,
201            &langs,
202            cli.limit,
203            cli.no_record,
204            cli.open,
205        ),
206        // bare `rq` (or just flags like --explain with no query): show help
207        None => {
208            let _ = Cli::command().print_long_help();
209            ExitCode::SUCCESS
210        }
211    }
212}
213
214/// How results are rendered.
215#[derive(Clone, Copy, PartialEq)]
216enum Output {
217    Text,
218    Json,
219    Ndjson,
220}
221
222fn output_format(cli: &Cli) -> Output {
223    if cli.ndjson {
224        Output::Ndjson
225    } else if cli.json {
226        Output::Json
227    } else {
228        Output::Text
229    }
230}
231
232/// Minimum headroom to rank before a `--path` filter (so filtered-in results
233/// aren't lost to the cutoff).
234const PATH_HEADROOM: usize = 200;
235
236/// How often the search re-checks the index while a cold repo warms on the
237/// background thread — short enough to feel instant, long enough not to spin.
238const POLL_INTERVAL: Duration = Duration::from_millis(15);
239
240/// Default action: search the index and print ranked results. `want` is the
241/// number of results to show (`--limit`).
242#[allow(clippy::too_many_arguments)]
243fn cmd_search(
244    query: &str,
245    explain: bool,
246    out: Output,
247    paths: &[String],
248    kinds: &[String],
249    langs: &[String],
250    want: usize,
251    no_record: bool,
252    open: bool,
253) -> ExitCode {
254    // post-filters (--path, --kind, --lang) need headroom before the cutoff so a
255    // filtered-in result isn't lost to the top-N truncation
256    let limit = if paths.is_empty() && kinds.is_empty() && langs.is_empty() {
257        want
258    } else {
259        (want * 20).max(PATH_HEADROOM)
260    };
261    let _timer = crate::trace::Timer::start("search done");
262    let t_setup = std::time::Instant::now();
263    let mut store = match open_store() {
264        Ok(s) => s,
265        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
266    };
267    let cwd = std::env::current_dir().ok();
268    let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
269
270    // Index relative to the repo ROOT, not wherever the search happens to run.
271    // Paths and the stored checkout root must be repo-root-relative and stable, or
272    // a search from a subdirectory would re-key the same repo under subdir-relative
273    // paths — and the deletion reconcile / staleness revalidation would then forget
274    // everything indexed from the root. Outside git, the root is just the cwd.
275    let root = cwd
276        .as_deref()
277        .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
278
279    // Files you're changing on this feature branch (and their directory
280    // neighbors): the branch ranking boost, and the warm pass's priority set.
281    let active_paths: Vec<String> = match &root {
282        Some(c) if cwd_is_git => crate::index::branch_changed_files(c),
283        _ => Vec::new(),
284    };
285
286    // Resolve identity from the repo root, cache-first: looked up by checkout root
287    // (no `git remote` fork), falling back to git only the first time we see a
288    // repo. Computed even for non-git dirs so an explicitly `--index`ed one is
289    // still recognized as the current repo below.
290    let identity = root.as_deref().map(|c| resolve_identity(&store, c));
291    let coverage = identity
292        .as_deref()
293        .and_then(|id| store.coverage_status(id).ok())
294        .flatten();
295
296    // Opportunistic indexing (Layer 5), time-bounded so the first query in a
297    // large repo never blocks on a full walk. We may warm a git work tree (safe
298    // to auto-discover) *or* any dir we already track — one earns tracking by
299    // being explicitly `--index`ed, which opts a non-git dir in. We never warm an
300    // unknown non-git dir (don't walk a random directory) or a deliberate partial
301    // subset (`--index --path …`, status "partial").
302    let known = coverage.is_some();
303    let warming_ok = (cwd_is_git || known) && coverage.as_deref() != Some("partial");
304    if crate::trace::enabled() {
305        crate::trace!(
306            "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
307            root.as_deref().map_or("?".into(), crate::trace::abbrev),
308            identity.as_deref().unwrap_or("none"),
309            coverage.as_deref().unwrap_or("none"),
310            active_paths.len(),
311        );
312    }
313    let current = identity
314        .as_deref()
315        .and_then(|id| store.repository_id(id).ok().flatten());
316    let active = crate::search::ActiveFiles::new(active_paths.clone());
317
318    // A repeated search (same query, nothing opened since) means last time missed
319    // — decay this query's learned boost before ranking so a stale learned pick
320    // stops dominating. Skipped under --no-record so an agent doesn't perturb it.
321    if !no_record && let Some(repo) = current {
322        let qn = query.to_ascii_lowercase();
323        if store.is_repeat_search(repo, &qn).unwrap_or(false) {
324            let _ = store.decay_selections(repo, &qn);
325        }
326    }
327
328    // Warm the index on a background thread (its own connection — WAL lets it
329    // write while we read), for the whole budget, whenever there's work: a
330    // not-yet-complete repo, or a complete one changed since it was indexed. The
331    // search below reads whatever it has committed so far, and we block on it
332    // before exiting so the shell waits the same total time.
333    let warm_budget = answer_warm_budget() + deferred_warm_budget();
334    let was_warming = coverage.as_deref() != Some("complete");
335    let want_warm = warming_ok
336        && match &root {
337            Some(c) => {
338                was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
339            }
340            None => false,
341        };
342    // `warm_done` lets the poll stop the instant the indexer finishes — so a miss
343    // on a small repo returns as soon as it's indexed, not at the deadline.
344    let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
345    let indexer = (want_warm && root.is_some()).then(|| {
346        crate::trace!(
347            "background warm ({warm_budget:?}, {} jobs)",
348            crate::index::parse_jobs()
349        );
350        let root = root.clone().expect("checked");
351        let active = active_paths.clone();
352        let q = query.to_string();
353        let warm_done = std::sync::Arc::clone(&warm_done);
354        std::thread::spawn(move || {
355            if let Ok(mut idx) = open_store() {
356                // path-prioritize toward the query so the relevant file indexes first
357                let _ =
358                    crate::index::index_budgeted(&mut idx, &root, &active, warm_budget, Some(&q));
359            }
360            warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
361        })
362    });
363
364    // Poll while a cold/partial repo warms. Don't print the first hit off a
365    // sparse index — a fuzzy or path match can be wrong once more is indexed.
366    // Hold until a *high-confidence* (exact or prefix name) match appears, which
367    // means the index has built enough to rank it; otherwise keep building until
368    // warming finishes or the answer deadline passes, then rank the fuller index.
369    crate::trace!(
370        "setup (open + repo detect + warm decision): {} ms",
371        t_setup.elapsed().as_millis()
372    );
373    let answer_deadline = std::time::Instant::now() + answer_warm_budget();
374    let polling = indexer.is_some() && was_warming;
375    let mut hits = loop {
376        match crate::search::search(&store, query, current, &active, limit) {
377            Ok(h) => {
378                let confident = h.first().is_some_and(|hit| {
379                    hit.features
380                        .iter()
381                        .any(|f| matches!(f.name, "exact" | "prefix"))
382                });
383                if !polling
384                    || confident
385                    || warm_done.load(std::sync::atomic::Ordering::Relaxed)
386                    || std::time::Instant::now() >= answer_deadline
387                {
388                    break h;
389                }
390            }
391            Err(e) => {
392                if let Some(h) = indexer {
393                    let _ = h.join();
394                }
395                return fail(format_args!("rq: {e}"));
396            }
397        }
398        std::thread::sleep(POLL_INTERVAL);
399    };
400
401    // Staleness: revalidate the files behind the top hits; re-rank once if changed.
402    if !hits.is_empty() && revalidate_top(&mut store, &hits) {
403        hits = crate::search::search(&store, query, current, &active, limit).unwrap_or_default();
404    }
405
406    // Untracked non-git dir — nothing persisted, no warmer running — so scan it
407    // live in-memory to answer at all (substring, then fuzzy). The only
408    // non-persisting scan left.
409    if hits.is_empty()
410        && indexer.is_none()
411        && coverage.is_none()
412        && let Some(root) = &root
413    {
414        crate::trace!("empty → live (in-memory) scan of an untracked dir");
415        let deadline = std::time::Instant::now() + live_fallback_budget();
416        let mut h =
417            crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
418        if h.is_empty() {
419            h = crate::search::live_search(
420                root,
421                query,
422                limit,
423                &HashSet::new(),
424                Some(deadline),
425                false,
426            );
427        }
428        hits = h;
429    }
430
431    // Relevance gate: when the query lands a real name match (exact or prefix),
432    // drop the scattered fuzzy / path-only near-matches — they're noise next to a
433    // solid hit, and rq favors fewer, better results. A purely-fuzzy query (no
434    // exact/prefix anywhere) keeps its matches.
435    let strong = |h: &crate::search::Hit| {
436        h.features
437            .iter()
438            .any(|f| matches!(f.name, "exact" | "prefix"))
439    };
440    if hits.iter().any(strong) {
441        hits.retain(strong);
442    }
443
444    // post-filters: keep only results under a --path dir, of a --kind, and/or in
445    // a --lang, then trim to the requested count.
446    if !paths.is_empty() {
447        // --path values may be absolute or cwd-relative; stored files are
448        // repo-root-relative, so normalize before prefix-matching or an
449        // absolute path would silently filter everything out.
450        let here = cwd.clone().unwrap_or_else(|| PathBuf::from("."));
451        let base = root.clone().unwrap_or_else(|| here.clone());
452        let norm: Vec<String> = paths
453            .iter()
454            .map(|p| repo_relative(&base, &here, p))
455            .collect();
456        hits.retain(|h| under_any(&h.file, &norm));
457    }
458    if !kinds.is_empty() {
459        hits.retain(|h| kinds.iter().any(|k| k == &h.kind));
460    }
461    if !langs.is_empty() {
462        hits.retain(|h| langs.iter().any(|l| l == &h.language));
463    }
464    if !paths.is_empty() || !kinds.is_empty() || !langs.is_empty() {
465        hits.truncate(want);
466    }
467
468    if hits.is_empty() {
469        match out {
470            Output::Json => println!("[]"),
471            Output::Ndjson => {}
472            Output::Text => eprintln!("no matches for {query:?}"),
473        }
474        // a miss still warms for next time — block on the background pass
475        if let Some(h) = indexer {
476            let _ = h.join();
477        }
478        return ExitCode::FAILURE;
479    }
480
481    // Attach each result's definition line (e.g. `def perform(refund)`) — shown
482    // in text output and carried in JSON. Cheap: only the displayed results.
483    for hit in &mut hits {
484        hit.signature = read_signature(
485            &store,
486            &hit.repo_identity,
487            &hit.file,
488            hit.line,
489            cwd.as_deref(),
490        );
491    }
492
493    // --open: pick the best match (prompting on a TTY with several), record the
494    // pick so ranking learns, and hand off to the editor. Returns before the
495    // normal print / warm-join — opening should be snappy, and a launcher `exec`s.
496    if open {
497        return finish_open(
498            &mut store,
499            &hits,
500            query,
501            current,
502            root.as_deref(),
503            no_record,
504        );
505    }
506
507    match out {
508        Output::Ndjson => {
509            for hit in &hits {
510                match serde_json::to_string(hit) {
511                    Ok(line) => println!("{line}"),
512                    Err(e) => return fail(format_args!("rq: {e}")),
513                }
514            }
515        }
516        Output::Json => match serde_json::to_string_pretty(&hits) {
517            Ok(s) => println!("{s}"),
518            Err(e) => return fail(format_args!("rq: {e}")),
519        },
520        Output::Text => {
521            let color = match_color();
522            let c = color.as_deref();
523            for hit in &hits {
524                // highlight the chars the query matched — in the name, the
525                // filename, and the definition line (great for fuzzy matches)
526                let name = hl(&hit.name, query, c);
527                let qualified = match &hit.parent {
528                    Some(p) => format!("{name} · {p}"),
529                    None => name,
530                };
531                println!(
532                    "{}:{}  {} {}",
533                    hl_path(&hit.file, query, c),
534                    hit.line,
535                    hit.kind,
536                    qualified
537                );
538                if let Some(sig) = &hit.signature {
539                    println!("    {}", hl(sig, query, c));
540                }
541                if explain {
542                    let parts: Vec<String> = hit
543                        .features
544                        .iter()
545                        .map(|f| format!("{} {:.0}", f.name, f.value))
546                        .collect();
547                    println!("    score {:.0} = {}", hit.score, parts.join(" + "));
548                }
549            }
550        }
551    }
552
553    // Results are out — now do the cheap deferred work, amortized across
554    // interactions. Under --no-record we skip logging this search (so it isn't a
555    // behavioral signal) but still run maintenance, which only rolls up and
556    // prunes pre-existing events.
557    if !no_record {
558        let _ = store.record_event(
559            "search",
560            Some(&query.to_ascii_lowercase()),
561            current,
562            None,
563            None,
564            None,
565        );
566    }
567    deferred_maintenance(&mut store);
568
569    // Results are out; block until the background warm finishes its budget. It
570    // persists as it goes (incremental commits), so even a pass cut short by the
571    // budget keeps everything it parsed — building coverage across queries and,
572    // on a changed repo, picking up edits and reconciling deletions on a full
573    // sweep, all without a daemon.
574    if let Some(h) = indexer {
575        let _ = h.join();
576    }
577
578    ExitCode::SUCCESS
579}
580
581/// Pick a hit for `--open`: the top match, unless we're on an interactive
582/// terminal with several — then print a short numbered menu and read a choice
583/// (empty = the top match). `None` means abort (EOF or unparseable input).
584fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
585    use std::io::{IsTerminal, Write};
586    if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
587        return hits.first();
588    }
589    let mut err = std::io::stderr();
590    let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
591    for (i, h) in hits.iter().enumerate() {
592        let _ = writeln!(
593            err,
594            "  {}. {}:{}  {} {}",
595            i + 1,
596            h.file,
597            h.line,
598            h.kind,
599            h.name
600        );
601    }
602    let _ = write!(err, "rq> ");
603    let _ = err.flush();
604    let mut line = String::new();
605    if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
606        return None; // Ctrl-D
607    }
608    parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
609}
610
611/// Resolve a menu reply to a 0-based index: blank → 0 (the top match), `N` → N-1
612/// when in range, anything else → `None` (abort). Pure, so it's unit-tested.
613fn parse_choice(input: &str, n: usize) -> Option<usize> {
614    let s = input.trim();
615    if s.is_empty() {
616        return Some(0);
617    }
618    let i = s.parse::<usize>().ok()?.checked_sub(1)?;
619    (i < n).then_some(i)
620}
621
622/// `--open`: choose a hit, record it as a selection so ranking learns, then hand
623/// off to the editor. The launcher `exec`s (replacing this process), so the shell
624/// waits on the editor — not on rq's background warm.
625fn finish_open(
626    store: &mut Store,
627    hits: &[crate::search::Hit],
628    query: &str,
629    current: Option<i64>,
630    root: Option<&std::path::Path>,
631    no_record: bool,
632) -> ExitCode {
633    let Some(hit) = choose_hit(hits) else {
634        return ExitCode::SUCCESS; // aborted at the prompt
635    };
636
637    // Record the pick — same signal as `rq --record`. The hit's path is already
638    // repo-relative, which is what the selection rollup keys off.
639    if !no_record {
640        let _ = store.record_event(
641            "select",
642            Some(&query.to_ascii_lowercase()),
643            current,
644            Some(&hit.file),
645            Some(hit.line),
646            None,
647        );
648        deferred_maintenance(store);
649    }
650
651    // Results are repo-root-relative, so resolve against the root — the bare path
652    // wouldn't open from a subdirectory.
653    let target = match root {
654        Some(r) => r.join(&hit.file),
655        None => PathBuf::from(&hit.file),
656    };
657    launch_editor(&target, hit.line)
658}
659
660/// Launch the editor on `file:line`, resolving the command in order: `RQ_OPEN`
661/// template → VS Code (`code`) → `$VISUAL`/`$EDITOR` → print the location. The
662/// chosen command replaces this process via `exec`.
663fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
664    use std::os::unix::process::CommandExt;
665    let loc = format!("{}:{}", file.display(), line);
666    match open_command(file, line, &loc) {
667        Some((prog, args)) => {
668            // exec returns only on failure
669            let err = std::process::Command::new(&prog).args(&args).exec();
670            fail(format_args!("rq --open: cannot run {prog}: {err}"))
671        }
672        None => {
673            println!("{loc}");
674            ExitCode::SUCCESS
675        }
676    }
677}
678
679/// Resolve the editor command + args. `None` → no launcher configured (the
680/// caller prints the location). `RQ_OPEN` is split on whitespace (no shell) with
681/// `{file}` / `{line}` / `{}` (= `path:line`) substituted per token.
682fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
683    let fstr = file.to_string_lossy().into_owned();
684
685    if let Some(t) = std::env::var_os("RQ_OPEN") {
686        let t = t.to_string_lossy();
687        let mut parts = t.split_whitespace().map(|p| {
688            p.replace("{file}", &fstr)
689                .replace("{line}", &line.to_string())
690                .replace("{}", loc)
691        });
692        if let Some(prog) = parts.next() {
693            return Some((prog, parts.collect()));
694        }
695    }
696
697    if on_path("code") {
698        return Some(("code".into(), vec!["--goto".into(), loc.into()]));
699    }
700
701    if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
702        let ed = ed.to_string_lossy().into_owned();
703        let l = ed.to_ascii_lowercase();
704        // line-aware launch for the common terminal editors; others just get the file
705        if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
706            .iter()
707            .any(|e| l.contains(e))
708        {
709            return Some((ed, vec![format!("+{line}"), fstr]));
710        }
711        return Some((ed, vec![fstr]));
712    }
713
714    None
715}
716
717/// Whether `prog` resolves on `PATH` (a regular file; symlinks followed).
718fn on_path(prog: &str) -> bool {
719    std::env::var_os("PATH")
720        .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
721}
722
723/// Whether a complete repo is provably unchanged since its last index — same
724/// HEAD and a clean work tree — so the deferred re-walk can be skipped. The git
725/// HEAD + dirty check is cheap (~tens of ms) and authoritative at any size, so
726/// it gates warming for small and large repos alike: a clean, fully-indexed repo
727/// has nothing to warm, and re-walking it on every query just to discover that
728/// wasted a full sweep (~hundreds of ms) per search. Conservative: any
729/// uncertainty (not complete, non-git / no recorded head, git hiccup) returns
730/// false, so we warm.
731fn repo_unchanged_since_index(
732    store: &Store,
733    cwd: &std::path::Path,
734    current: Option<i64>,
735    coverage: Option<&str>,
736) -> bool {
737    if coverage != Some("complete") {
738        return false;
739    }
740    let Some(id) = current else { return false };
741    let indexed_head = store.indexed_head(id).ok().flatten();
742    indexed_head.is_some()
743        && crate::index::git_head(cwd) == indexed_head
744        && !crate::index::is_dirty(cwd)
745}
746
747/// Inline warm budget on the search path. A *cap*, not a fixed delay:
748/// `index_budgeted` returns the moment a full sweep finishes, so small/medium
749/// repos index completely and pay only their real cost. The cap only bites a
750/// genuinely huge, never-indexed repo — where a bigger budget buys a much better
751/// first answer (a tiny budget can return nothing, since a git repo has no
752/// live-scan fallback). 500 ms is a one-time cold-cache cost, trivial next to
753/// scanning a large tree from scratch; the deferred pass and later queries fill
754/// in the rest.
755fn answer_warm_budget() -> Duration {
756    env_budget("RQ_ANSWER_BUDGET_MS", 500)
757}
758
759/// Deferred warm budget, spent after results are printed: larger, to make real
760/// progress on coverage per query while keeping each invocation snappy.
761fn deferred_warm_budget() -> Duration {
762    env_budget("RQ_DEFERRED_BUDGET_MS", 250)
763}
764
765/// Bound for the git-repo live-scan fallback (index empty, still warming): enough
766/// to surface a result the warm hasn't reached, without an unbounded walk.
767fn live_fallback_budget() -> Duration {
768    env_budget("RQ_FALLBACK_BUDGET_MS", 250)
769}
770
771/// Read a budget (milliseconds) from an env var, else the default. The env knobs
772/// exist mainly for testing — a tiny budget reproduces large-repo warming
773/// behavior on a small repo.
774fn env_budget(var: &str, default_ms: u64) -> Duration {
775    let ms = std::env::var(var)
776        .ok()
777        .and_then(|v| v.parse().ok())
778        .unwrap_or(default_ms);
779    Duration::from_millis(ms)
780}
781
782/// How many events to roll up per interaction. Bounded so the deferred pass
783/// after a command stays quick.
784const AGGREGATE_BATCH: usize = 256;
785
786/// Recent raw events to retain after rollup (enough for repeat detection); the
787/// rest, once aggregated, are pruned to keep the log from growing unbounded.
788const KEEP_RECENT_EVENTS: i64 = 200;
789
790/// The bounded background work run after a user interaction, once results are
791/// out: roll new events into the learning rollup, then prune the raw log.
792fn deferred_maintenance(store: &mut Store) {
793    let _ = store.aggregate_events(AGGREGATE_BATCH);
794    let _ = store.prune_events(KEEP_RECENT_EVENTS);
795}
796
797/// Hook entry point: record that `file` was opened/selected for `query`, then
798/// amortize a chunk of event aggregation.
799fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
800    let mut store = match open_store() {
801        Ok(s) => s,
802        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
803    };
804    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
805    let identity = crate::index::detect_identity(&cwd).to_string();
806    let repo_id = store.repository_id(&identity).ok().flatten();
807
808    // Store the path repo-relative so the rollup can resolve it against indexed
809    // files.
810    let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
811        Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
812        None => file.to_string(),
813    };
814    let query_norm = query.map(|q| q.to_ascii_lowercase());
815
816    if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
817    {
818        return fail(format_args!("rq record: {e}"));
819    }
820    deferred_maintenance(&mut store);
821    ExitCode::SUCCESS
822}
823
824/// The definition's source line (trimmed) for a hit — read from disk, resolving
825/// the repo root from the store (or the cwd for live results). Best-effort.
826fn read_signature(
827    store: &Store,
828    repo_identity: &str,
829    file: &str,
830    line: i64,
831    cwd: Option<&std::path::Path>,
832) -> Option<String> {
833    let root = store
834        .repository_id(repo_identity)
835        .ok()
836        .flatten()
837        .and_then(|id| store.checkout_root(id).ok().flatten())
838        .map(PathBuf::from)
839        .or_else(|| cwd.map(std::path::Path::to_path_buf))?;
840    let content = std::fs::read_to_string(root.join(file)).ok()?;
841    signature_in(&content, line)
842}
843
844/// The trimmed source line `line` (1-based) of already-read `content`, if
845/// non-empty — a symbol's definition line. Splitting this out lets `--symbols`
846/// read one file once instead of re-reading it per symbol.
847fn signature_in(content: &str, line: i64) -> Option<String> {
848    let idx = usize::try_from(line).ok()?.checked_sub(1)?;
849    let l = content.lines().nth(idx)?.trim();
850    (!l.is_empty()).then(|| l.to_string())
851}
852
853/// One symbol in `rq --symbols` output. Same field names as a search hit
854/// (`repo`, `signature`) for agent consistency, but no score/features — an
855/// outline is structural, not ranked.
856#[derive(serde::Serialize)]
857struct SymbolOut {
858    name: String,
859    kind: String,
860    language: String,
861    file: String,
862    line: i64,
863    #[serde(skip_serializing_if = "Option::is_none")]
864    parent: Option<String>,
865    repo: String,
866    #[serde(skip_serializing_if = "Option::is_none")]
867    signature: Option<String>,
868}
869
870/// `rq --symbols <file>`: list a file's symbols in line order — a structural
871/// outline, not a ranked search. Warms the file's repo if it's cold/partial or
872/// changed (same gate as search), then reads straight from the index. Honors
873/// --kind/--lang filters and --json/--ndjson.
874fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
875    let mut store = match open_store() {
876        Ok(s) => s,
877        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
878    };
879    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
880    let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
881    let rel = repo_relative(&root, &cwd, file_arg);
882
883    let identity = resolve_identity(&store, &root);
884    let coverage = store.coverage_status(&identity).ok().flatten();
885    let warming_ok = (crate::index::is_git_repo(&root) || coverage.is_some())
886        && coverage.as_deref() != Some("partial");
887    let current = store.repository_id(&identity).ok().flatten();
888    let needs_warm = warming_ok
889        && (coverage.as_deref() != Some("complete")
890            || !repo_unchanged_since_index(&store, &root, current, coverage.as_deref()));
891    if needs_warm {
892        // Path-prioritize the warm toward the requested file so it indexes first.
893        let budget = answer_warm_budget() + deferred_warm_budget();
894        let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
895    }
896
897    let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
898        return emit_symbols(out, &[]); // unknown / un-indexed repo → nothing
899    };
900    let mut rows = match store.symbols_in_file(repo_id, &rel) {
901        Ok(r) => r,
902        Err(e) => return fail(format_args!("rq: {e}")),
903    };
904    if !kinds.is_empty() {
905        rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
906    }
907    if !langs.is_empty() {
908        rows.retain(|r| langs.iter().any(|l| l == &r.language));
909    }
910
911    // Read the source once for signatures (every row is the same file).
912    let file_root = store
913        .checkout_root(repo_id)
914        .ok()
915        .flatten()
916        .map(PathBuf::from)
917        .unwrap_or_else(|| root.clone());
918    let content = std::fs::read_to_string(file_root.join(&rel)).ok();
919    let syms: Vec<SymbolOut> = rows
920        .into_iter()
921        .map(|r| SymbolOut {
922            signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
923            name: r.name,
924            kind: r.kind,
925            language: r.language,
926            file: r.file,
927            line: r.line,
928            parent: r.parent,
929            repo: r.repo_identity,
930        })
931        .collect();
932    emit_symbols(out, &syms)
933}
934
935/// Render the outline. Exit 0 if any symbols, non-zero if none — rq's exit-code
936/// convention, matching how search reports an empty result per format.
937fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
938    if syms.is_empty() {
939        match out {
940            Output::Json => println!("[]"),
941            Output::Ndjson => {}
942            Output::Text => eprintln!("no symbols"),
943        }
944        return ExitCode::FAILURE;
945    }
946    match out {
947        Output::Ndjson => {
948            for s in syms {
949                match serde_json::to_string(s) {
950                    Ok(line) => println!("{line}"),
951                    Err(e) => return fail(format_args!("rq: {e}")),
952                }
953            }
954        }
955        Output::Json => match serde_json::to_string_pretty(&syms) {
956            Ok(s) => println!("{s}"),
957            Err(e) => return fail(format_args!("rq: {e}")),
958        },
959        Output::Text => {
960            for s in syms {
961                let qualified = match &s.parent {
962                    Some(p) => format!("{} · {p}", s.name),
963                    None => s.name.clone(),
964                };
965                println!("{}:{}  {} {}", s.file, s.line, s.kind, qualified);
966                if let Some(sig) = &s.signature {
967                    println!("    {sig}");
968                }
969            }
970        }
971    }
972    ExitCode::SUCCESS
973}
974
975/// Normalize a `--kind` value (name or shortcut) to a canonical symbol kind.
976/// Unknown values pass through lowercased (so they simply match nothing).
977fn canonical_kind(s: &str) -> String {
978    match s.to_ascii_lowercase().as_str() {
979        "c" | "class" => "class",
980        "m" | "method" => "method",
981        "f" | "fn" | "func" | "function" => "function",
982        "mod" | "module" => "module",
983        "s" | "struct" => "struct",
984        "e" | "enum" => "enum",
985        "t" | "trait" => "trait",
986        other => return other.to_string(),
987    }
988    .to_string()
989}
990
991/// Expand a `--lang` value to the language tag(s) it selects: a **prefix** of any
992/// known language name (so `r` → ruby+rust, `p`/`py` → python, `g` → go), plus a
993/// few non-prefix aliases (`rb`→ruby, `rs`→rust, `golang`→go). An unknown value
994/// passes through lowercased so it simply matches nothing.
995fn canonical_langs(s: &str) -> Vec<String> {
996    let t = s.to_ascii_lowercase();
997    let alias = match t.as_str() {
998        "rb" => Some("ruby"),
999        "rs" => Some("rust"),
1000        "golang" => Some("go"),
1001        _ => None,
1002    };
1003    let matched: Vec<String> = crate::lang::languages()
1004        .into_iter()
1005        .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
1006        .map(str::to_string)
1007        .collect();
1008    if matched.is_empty() { vec![t] } else { matched }
1009}
1010
1011/// The ANSI SGR code for highlighting matches, or `None` to disable color.
1012/// Off unless stdout is a terminal; honors `NO_COLOR`; takes the match style
1013/// from `GREP_COLORS` (`mt`/`ms`) when set, else grep's default bold red.
1014fn match_color() -> Option<String> {
1015    if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
1016        return None;
1017    }
1018    let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
1019        gc.split(':').find_map(|e| {
1020            e.strip_prefix("mt=")
1021                .or_else(|| e.strip_prefix("ms="))
1022                .filter(|v| !v.is_empty())
1023                .map(str::to_string)
1024        })
1025    });
1026    Some(style.unwrap_or_else(|| "1;31".to_string()))
1027}
1028
1029/// Highlight the chars of `text` that `query` matched (no-op when `color` is
1030/// `None`, e.g. piped output).
1031fn hl(text: &str, query: &str, color: Option<&str>) -> String {
1032    match color {
1033        Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
1034        None => text.to_string(),
1035    }
1036}
1037
1038/// Like [`hl`], but only over a path's filename — so matched chars light up in
1039/// `payrolls_controller.rb`, not scattered across the directory parts.
1040fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
1041    let Some(c) = color else {
1042        return path.to_string();
1043    };
1044    let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
1045    let base_start = path[..base_byte].chars().count();
1046    // align on the filename *stem* (drop the extension), the same string the
1047    // scorer matched — so the query can't straggle into `.rb` instead of lighting
1048    // up the logical name (`employees_controller`)
1049    let base = &path[base_byte..];
1050    let stem = match base.rfind('.') {
1051        Some(i) if i > 0 => &base[..i],
1052        _ => base,
1053    };
1054    let positions: Vec<usize> = crate::search::match_positions(query, stem)
1055        .into_iter()
1056        .map(|p| p + base_start)
1057        .collect();
1058    highlight(path, &positions, c)
1059}
1060
1061/// Wrap the matched character positions of `text` in an ANSI color run.
1062/// Consecutive matched chars share one escape sequence.
1063fn highlight(text: &str, positions: &[usize], color: &str) -> String {
1064    if positions.is_empty() {
1065        return text.to_string();
1066    }
1067    let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
1068    let mut out = String::new();
1069    let mut on = false;
1070    for (i, c) in text.chars().enumerate() {
1071        match (matched.contains(&i), on) {
1072            (true, false) => {
1073                out.push_str("\x1b[");
1074                out.push_str(color);
1075                out.push('m');
1076                on = true;
1077            }
1078            (false, true) => {
1079                out.push_str("\x1b[0m");
1080                on = false;
1081            }
1082            _ => {}
1083        }
1084        out.push(c);
1085    }
1086    if on {
1087        out.push_str("\x1b[0m");
1088    }
1089    out
1090}
1091
1092/// Whether a repo-relative `file` sits under one of the `--path` directories
1093/// (prefix match on a path boundary). `app/services` matches
1094/// `app/services/refund.rb` but not `app/services_old/x.rb`.
1095fn under_any(file: &str, paths: &[String]) -> bool {
1096    paths.iter().any(|p| {
1097        let p = p.trim_start_matches("./").trim_end_matches('/');
1098        p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
1099    })
1100}
1101
1102/// Resolve a possibly-absolute or cwd-relative path to a repo-relative one.
1103fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
1104    let p = std::path::Path::new(file);
1105    let abs = if p.is_absolute() {
1106        p.to_path_buf()
1107    } else {
1108        cwd.join(p)
1109    };
1110    let abs = abs.canonicalize().unwrap_or(abs);
1111    abs.strip_prefix(root)
1112        .map(|r| r.to_string_lossy().into_owned())
1113        .unwrap_or_else(|_| file.to_string())
1114}
1115
1116/// Revalidate the files behind the top hits against disk, refreshing any that
1117/// changed and forgetting any that were deleted. Returns true if anything
1118/// changed (so the caller re-runs the search).
1119fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
1120    use std::collections::HashSet;
1121    let mut seen = HashSet::new();
1122    let mut changed = false;
1123    for hit in hits {
1124        if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
1125            continue;
1126        }
1127        let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
1128            continue;
1129        };
1130        let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
1131            continue;
1132        };
1133        if let Ok(crate::index::Refresh::Updated) =
1134            crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
1135        {
1136            changed = true;
1137        }
1138    }
1139    changed
1140}
1141
1142/// The repository's normalized identity for `cwd`, cache-first: look it up by
1143/// the canonical cwd (the checkout root indexing records), so a known repo (git
1144/// or explicitly `--index`ed) costs no `git` fork. On a cache miss, a non-git
1145/// dir resolves to its `local:` path directly (still no fork); only a git work
1146/// tree we haven't seen yet pays a `git remote` call.
1147fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
1148    if let Ok(canon) = cwd.canonicalize() {
1149        if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
1150            return identity;
1151        }
1152        if crate::index::repo_root(cwd).is_none() {
1153            return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
1154        }
1155    }
1156    crate::index::detect_identity(cwd).to_string()
1157}
1158
1159fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
1160    let explicit = path.is_some();
1161    let target = path.unwrap_or_else(|| PathBuf::from("."));
1162    // Normalize to the repo root: the index is repo-root-relative, so indexing
1163    // from a subdirectory must still key off the root (a subdir-relative index
1164    // would mismatch a later search and get reconciled away). `--path` scopes a
1165    // subset; outside git the target is used as-is.
1166    let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
1167    // An explicit TARGET *inside* the repo scopes the index to that subtree — the
1168    // user pointed at a subdir, not the whole repo, and shouldn't pay to walk
1169    // everything. Folded in alongside any `--path` subdirs. (A bare `rq --index`
1170    // with no target still walks the whole repo.)
1171    let mut subdirs = subdirs.to_vec();
1172    if explicit
1173        && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
1174        && t != r
1175        && let Ok(rel) = t.strip_prefix(&r)
1176        && !rel.as_os_str().is_empty()
1177    {
1178        subdirs.push(rel.to_string_lossy().into_owned());
1179    }
1180    let mut store = match open_store() {
1181        Ok(s) => s,
1182        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1183    };
1184    let identity = crate::index::detect_identity(&root).to_string();
1185    match crate::index::index_under(&mut store, &root, &subdirs) {
1186        Ok(stats) => {
1187            let partial = !subdirs.is_empty();
1188            // distinguish this run's incremental work from the index totals
1189            let totals = store
1190                .repository_id(&identity)
1191                .ok()
1192                .flatten()
1193                .and_then(|id| store.repo_totals(id).ok());
1194            match out {
1195                Output::Json | Output::Ndjson => {
1196                    let (files, symbols) = match totals {
1197                        Some((f, s)) => (Some(f), Some(s)),
1198                        None => (None, None),
1199                    };
1200                    return emit_json(
1201                        out,
1202                        &serde_json::json!({
1203                            "repo": identity,
1204                            "scope": if partial { "partial" } else { "full" },
1205                            "files_added": stats.files_indexed,
1206                            "symbols_added": stats.symbols,
1207                            "files": files,
1208                            "symbols": symbols,
1209                        }),
1210                    );
1211                }
1212                Output::Text => {
1213                    let scope = if partial { " (partial)" } else { "" };
1214                    match totals {
1215                        Some((files, symbols)) => println!(
1216                            "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
1217                            stats.files_indexed, stats.symbols
1218                        ),
1219                        None => println!(
1220                            "{} file(s)/{} symbol(s) added this run{scope}",
1221                            stats.files_indexed, stats.symbols
1222                        ),
1223                    }
1224                }
1225            }
1226            ExitCode::SUCCESS
1227        }
1228        Err(e) => fail(format_args!("rq --index: {e}")),
1229    }
1230}
1231
1232fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
1233    let mut store = match open_store() {
1234        Ok(s) => s,
1235        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1236    };
1237
1238    // Resolve the repo to drop: TARGET as a path (→ repo root → identity, like
1239    // --index), falling back to TARGET as a literal identity string — so cruft
1240    // shown by --status can be dropped by name even if the checkout is gone.
1241    let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
1242    let root = crate::index::repo_root(&path).unwrap_or(path);
1243    let from_path = crate::index::detect_identity(&root).to_string();
1244    let resolved = match store.repository_id(&from_path) {
1245        Ok(Some(id)) => Some((from_path.clone(), id)),
1246        Ok(None) => target.as_deref().and_then(|s| {
1247            store
1248                .repository_id(s)
1249                .ok()
1250                .flatten()
1251                .map(|id| (s.to_string(), id))
1252        }),
1253        Err(e) => return fail(format_args!("rq --drop: {e}")),
1254    };
1255
1256    let Some((identity, repo_id)) = resolved else {
1257        // nothing to drop — idempotent. `dropped: false` lets a script tell.
1258        return match out {
1259            Output::Text => {
1260                println!("not indexed: {from_path}");
1261                ExitCode::SUCCESS
1262            }
1263            _ => emit_json(
1264                out,
1265                &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
1266            ),
1267        };
1268    };
1269
1270    let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
1271    match store.drop_repository(repo_id) {
1272        Ok(()) => match out {
1273            Output::Text => {
1274                println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
1275                ExitCode::SUCCESS
1276            }
1277            _ => emit_json(
1278                out,
1279                &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
1280            ),
1281        },
1282        Err(e) => fail(format_args!("rq --drop: {e}")),
1283    }
1284}
1285
1286/// Print a single value as JSON: `--json` pretty, `--ndjson` compact one-liner.
1287/// Used by the single-object operations (`--index`, `--drop`); `--status` builds
1288/// an array / one-row-per-line itself.
1289fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
1290    let rendered = if out == Output::Json {
1291        serde_json::to_string_pretty(value)
1292    } else {
1293        serde_json::to_string(value)
1294    };
1295    match rendered {
1296        Ok(s) => {
1297            println!("{s}");
1298            ExitCode::SUCCESS
1299        }
1300        Err(e) => fail(format_args!("rq: {e}")),
1301    }
1302}
1303
1304fn cmd_status(out: Output) -> ExitCode {
1305    let store = match open_store() {
1306        Ok(s) => s,
1307        Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1308    };
1309    let rows = match store.coverage_overview() {
1310        Ok(rows) => rows,
1311        Err(e) => return fail(format_args!("rq --status: {e}")),
1312    };
1313    match out {
1314        Output::Json => match serde_json::to_string_pretty(&rows) {
1315            Ok(s) => println!("{s}"),
1316            Err(e) => return fail(format_args!("rq: {e}")),
1317        },
1318        Output::Ndjson => {
1319            for r in &rows {
1320                match serde_json::to_string(r) {
1321                    Ok(line) => println!("{line}"),
1322                    Err(e) => return fail(format_args!("rq: {e}")),
1323                }
1324            }
1325        }
1326        Output::Text if rows.is_empty() => {
1327            println!("no repositories indexed yet (try `rq --index`)");
1328        }
1329        Output::Text => {
1330            for r in &rows {
1331                println!(
1332                    "{:<10} {:>6} files  {:>7} symbols  {}",
1333                    r.status, r.files, r.symbols, r.identity
1334                );
1335            }
1336        }
1337    }
1338    ExitCode::SUCCESS
1339}
1340
1341/// Open the rq database, honoring `RQ_DB` and creating parent dirs.
1342fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
1343    let path = db_path()?;
1344    if let Some(parent) = path.parent() {
1345        std::fs::create_dir_all(parent)?;
1346    }
1347    Ok(Store::open(&path)?)
1348}
1349
1350/// Resolve the database path: `$RQ_DB`, else `$HOME/.local/share/rq/rq.db`.
1351fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
1352    if let Ok(p) = std::env::var("RQ_DB") {
1353        return Ok(PathBuf::from(p));
1354    }
1355    let home = std::env::var("HOME")?;
1356    Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
1357}
1358
1359fn fail(args: std::fmt::Arguments) -> ExitCode {
1360    eprintln!("{args}");
1361    ExitCode::FAILURE
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    #[test]
1369    fn open_menu_choice_parsing() {
1370        // blank reply takes the top match; a valid number maps to its index
1371        assert_eq!(parse_choice("\n", 5), Some(0));
1372        assert_eq!(parse_choice("  ", 5), Some(0));
1373        assert_eq!(parse_choice("3", 5), Some(2));
1374        assert_eq!(parse_choice("5", 5), Some(4));
1375        // out of range, zero, or non-numeric aborts
1376        assert_eq!(parse_choice("6", 5), None);
1377        assert_eq!(parse_choice("0", 5), None);
1378        assert_eq!(parse_choice("q", 5), None);
1379    }
1380
1381    #[test]
1382    fn highlight_wraps_matched_runs() {
1383        assert_eq!(
1384            highlight("FooThing", &[0, 1, 2], "1;31"),
1385            "\u{1b}[1;31mFoo\u{1b}[0mThing"
1386        );
1387        // scattered matches get separate runs
1388        assert_eq!(
1389            highlight("FooThing", &[0, 3], "1"),
1390            "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
1391        );
1392        // nothing matched → unchanged
1393        assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
1394    }
1395
1396    #[test]
1397    fn hl_path_highlights_the_stem_not_the_extension() {
1398        // matching `employeescontroller`, the highlight covers the logical name in
1399        // the stem and never straggles into `.rb`
1400        let out = hl_path(
1401            "app/employees_controller.rb",
1402            "employeescontroller",
1403            Some("1;31"),
1404        );
1405        assert!(
1406            out.starts_with("app/\u{1b}[1;31memployees"),
1407            "stem highlighted: {out:?}"
1408        );
1409        assert!(
1410            out.ends_with("controller\u{1b}[0m.rb"),
1411            "`.rb` left un-highlighted: {out:?}"
1412        );
1413    }
1414}