1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//! `rqmd search` — BM25 full-text search. Sync because no LLM is involved.
//!
//! Maps to qmd's `search` CLI handler (`src/cli/qmd.ts` lines 2392–2426).
use std::io::IsTerminal;
use anyhow::Result;
use rqmd_core::store::search::search_fts;
use rqmd_core::store::virtual_path::resolve_virtual_path;
use crate::cli::SearchArgs;
use crate::collection_filter::{
filter_by_collections, resolve_collection_filter, single_collection,
};
use crate::color::Palette;
use crate::output::OutputFormat;
use crate::search_view::{CliLinkCtx, editor_uri_template, print_hits, search_result_to_hit};
use crate::state::IndexState;
pub fn run(args: SearchArgs, state: &mut IndexState, p: &Palette) -> Result<()> {
let q = args.query.join(" ");
// Empty query: `search_fts` returns Ok(vec![]) via `build_fts5_query`'s
// None path, so we deliberately do NOT bail — `rqmd search "$VAR" --json`
// with an empty $VAR should produce `[]`, not error.
let min_score = args.flags.min_score.unwrap_or(0.0);
let fmt = OutputFormat::from(&args.format);
// Active index name for `?index=` link annotation (qmd `getActiveIndexName`).
// Captured as an owned String before borrowing the store, then `idx` is the
// non-default name (or None) passed to the hit constructor.
let index_name = state.index_name().to_string();
let idx = (index_name != "index").then_some(index_name.as_str());
// `-c` omitted → default collections; explicit names validated. Resolve
// before borrowing the store (the helper returns an owned Vec, releasing the
// `&mut Config` borrow). TS `resolveCollectionFilter(opts.collection, true)`,
// qmd.ts:2397.
let collection_names =
resolve_collection_filter(state.config_mut()?, &args.flags.collection, true)?;
let collection = single_collection(&collection_names);
// Clickable-link context for the cli format (resolved before the store
// borrow; `editor_uri` comes from env/config, see `editor_uri_template`).
let link = CliLinkCtx {
editor_template: editor_uri_template(state.config_mut()?.data().editor_uri.as_deref()),
stdout_tty: std::io::stdout().is_terminal(),
};
// Over-fetch so the multi-collection post-filter still has enough rows to
// fill `display_limit`, then truncate — mirrors TS qmd.ts:2401 plus the
// `slice(0, opts.limit)` in `outputResults` (qmd.ts:2098). `--all` sets the
// limit to 100_000 (qmd.ts:2730), i.e. effectively unbounded.
let display_limit = if args.flags.all {
100_000
} else {
args.flags.limit.unwrap_or(20)
};
let fetch_limit = if args.flags.all {
100_000
} else {
std::cmp::max(50, display_limit.saturating_mul(2))
};
let store = state.store_mut()?;
let results = store
.with_connection(|conn| search_fts(conn, &q, Some(fetch_limit), collection.as_deref()))?;
// Multi-collection post-filter (TS `filterByCollections`, qmd.ts:2402-2405);
// no-op for 0/1 collection. `SearchResult` carries its path at `doc.filepath`.
let results = filter_by_collections(results, &collection_names, |r| r.doc.filepath.as_str());
// TS `outputResults`: `filter(score >= minScore).slice(0, limit)`.
let mut hits: Vec<_> = results
.iter()
.filter(|r| r.score >= min_score)
.take(display_limit)
.map(|r| search_result_to_hit(r, &q, None, args.flags.full, idx))
.collect();
// Resolve absolute paths for the cli format's OSC-8 links while the
// connection is open (only needed on a TTY; non-TTY uses the plain link).
if fmt == OutputFormat::Cli && link.stdout_tty {
store.with_connection(|conn| {
for h in &mut hits {
h.abs_path = resolve_virtual_path(conn, &h.file)
.ok()
.flatten()
.map(|pp| pp.to_string_lossy().into_owned());
}
});
}
print_hits(&hits, fmt, p, args.flags.line_numbers, &q, &link)?;
Ok(())
}