use serde::Serialize;
use rqmd_core::store::rrf::{HybridQueryExplain, QueryType, RRFContributionTrace, RRFExplain};
use rqmd_core::store::search::{SearchResult, SearchSource};
use rqmd_core::store::snippet::{add_line_numbers, extract_snippet};
use rqmd_core::store::virtual_path::{build_virtual_path, parse_virtual_path};
use rqmd_core::store_ops::{HybridQueryResult, VectorSearchResult};
use crate::color::Palette;
use crate::output::{OutputFormat, escape_csv, escape_xml};
pub fn to_qmd_path(display_path: &str, index_name: Option<&str>) -> String {
match display_path.split_once('/') {
Some((coll, rest)) if !coll.is_empty() && !rest.is_empty() => {
build_virtual_path(coll, rest, index_name)
}
_ => format!("qmd://{display_path}"),
}
}
pub const DEFAULT_EDITOR_URI_TEMPLATE: &str = "vscode://file/{path}:{line}:{col}";
pub fn editor_uri_template(config_editor_uri: Option<&str>) -> String {
for var in ["RQMD_EDITOR_URI", "QMD_EDITOR_URI"] {
if let Ok(t) = std::env::var(var) {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
}
}
if let Some(t) = config_editor_uri {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
}
DEFAULT_EDITOR_URI_TEMPLATE.to_string()
}
fn encode_path_for_editor_uri(abs_path: &str) -> String {
const KEEP: &[u8] = b"-_.!~*'();,/?:@&=+$#";
let mut out = String::with_capacity(abs_path.len());
for &b in abs_path.as_bytes() {
if b.is_ascii_alphanumeric() || KEEP.contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out.replace('?', "%3F").replace('#', "%23")
}
pub fn build_editor_uri(template: &str, abs_path: &str, line: usize, col: usize) -> String {
let safe_line = line.max(1);
let safe_col = col.max(1);
let encoded = encode_path_for_editor_uri(abs_path);
template
.replace("{path}", &encoded)
.replace("{line}", &safe_line.to_string())
.replace("{col}", &safe_col.to_string())
.replace("{column}", &safe_col.to_string())
}
pub fn term_link(text: &str, url: &str, is_tty: bool) -> String {
if !is_tty {
return text.to_string();
}
format!("\x1b]8;;{url}\x07{text}\x1b]8;;\x07")
}
pub fn highlight_terms(text: &str, query: &str, p: &Palette) -> String {
if !p.enabled {
return text.to_string();
}
let pre = format!("{}{}", p.yellow(), p.bold());
let post = p.reset();
let mut result = text.to_string();
for term in query.split_whitespace().filter(|t| t.chars().count() >= 3) {
result = wrap_ascii_ci(&result, term, &pre, post);
}
result
}
fn wrap_ascii_ci(haystack: &str, needle: &str, pre: &str, post: &str) -> String {
if needle.is_empty() {
return haystack.to_string();
}
let hay_lc = haystack.to_ascii_lowercase();
let needle_lc = needle.to_ascii_lowercase();
let n = needle_lc.len();
let mut out = String::with_capacity(haystack.len());
let mut i = 0;
while i < haystack.len() {
if hay_lc.as_bytes()[i..].starts_with(needle_lc.as_bytes()) {
out.push_str(pre);
out.push_str(&haystack[i..i + n]);
out.push_str(post);
i += n;
} else {
let ch = haystack[i..].chars().next().expect("char boundary");
let clen = ch.len_utf8();
out.push_str(&haystack[i..i + clen]);
i += clen;
}
}
out
}
pub fn format_score(score: f64, p: &Palette) -> String {
let pct = format!("{:>3}", (score * 100.0).round() as i64);
if !p.enabled {
return format!("{pct}%");
}
let color = if score >= 0.7 {
p.green()
} else if score >= 0.4 {
p.yellow()
} else {
p.dim()
};
format!("{color}{pct}%{}", p.reset())
}
fn format_explain_number(value: f64) -> String {
format!("{value:.4}")
}
pub struct CliLinkCtx {
pub editor_template: String,
pub stdout_tty: bool,
}
#[derive(Debug, Serialize)]
pub struct Hit {
pub file: String,
pub display_path: String,
pub title: String,
pub score: f64,
pub line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
pub snippet: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub docid: String,
#[serde(skip)]
pub abs_path: Option<String>,
#[serde(skip)]
pub explain: Option<HybridQueryExplain>,
}
pub fn search_result_to_hit(
r: &SearchResult,
query: &str,
intent: Option<&str>,
full: bool,
index_name: Option<&str>,
) -> Hit {
let body = r.doc.body.as_deref().unwrap_or("");
let snip = extract_snippet(
body,
query,
None,
r.chunk_pos.map(|p| p as usize),
None,
intent,
);
Hit {
file: to_qmd_path(&r.doc.display_path, index_name),
display_path: r.doc.display_path.clone(),
title: r.doc.title.clone(),
score: r.score,
line: snip.line,
context: r.doc.context.clone(),
snippet: snip.snippet,
body: full.then(|| body.to_string()),
docid: r.doc.docid.clone(),
abs_path: None,
explain: None,
}
}
pub fn vector_result_to_hit(
r: &VectorSearchResult,
query: &str,
intent: Option<&str>,
full: bool,
index_name: Option<&str>,
) -> Hit {
let snip = extract_snippet(&r.body, query, None, None, None, intent);
Hit {
file: to_qmd_path(&r.display_path, index_name),
display_path: r.display_path.clone(),
title: r.title.clone(),
score: r.score,
line: snip.line,
context: r.context.clone(),
snippet: snip.snippet,
body: full.then(|| r.body.clone()),
docid: r.docid.clone(),
abs_path: None,
explain: None,
}
}
pub fn hybrid_result_to_hit(r: &HybridQueryResult, full: bool, index_name: Option<&str>) -> Hit {
Hit {
file: to_qmd_path(&r.display_path, index_name),
display_path: r.display_path.clone(),
title: r.title.clone(),
score: r.score,
line: line_at_byte_offset(&r.body, r.best_chunk_pos),
context: r.context.clone(),
snippet: r.best_chunk.clone(),
body: full.then(|| r.body.clone()),
docid: r.docid.clone(),
abs_path: None,
explain: r.explain.clone(),
}
}
pub(crate) fn line_at_byte_offset(body: &str, byte_pos: usize) -> usize {
if byte_pos == 0 {
return 1;
}
let clamp = byte_pos.min(body.len());
body[..clamp].matches('\n').count() + 1
}
fn render_content(h: &Hit, line_numbers: bool) -> String {
let (text, start) = match &h.body {
Some(b) => (b.as_str(), 1),
None => (h.snippet.as_str(), h.line),
};
if line_numbers {
add_line_numbers(text, Some(start))
} else {
text.to_string()
}
}
pub fn print_hits_cli(
hits: &[Hit],
p: &Palette,
line_numbers: bool,
query: &str,
link: &CliLinkCtx,
) {
if hits.is_empty() {
println!("No results found.");
return;
}
let query_lc = query.to_lowercase();
let n = hits.len();
for (i, h) in hits.iter().enumerate() {
let snippet_body = h
.snippet
.split('\n')
.skip(1)
.collect::<Vec<_>>()
.join("\n")
.to_lowercase();
let has_match = query_lc
.split_whitespace()
.any(|t| !t.is_empty() && snippet_body.contains(t));
let line_info = if has_match {
format!(":{}", h.line)
} else {
String::new()
};
let docid_str = if h.docid.is_empty() {
String::new()
} else {
format!(" {}#{}{}", p.dim(), h.docid, p.reset())
};
let parsed_rel = parse_virtual_path(&h.file)
.ok()
.map(|vp| vp.path)
.filter(|s| !s.is_empty());
let line1 = match (
link.stdout_tty,
h.abs_path.as_deref(),
parsed_rel.as_deref(),
) {
(true, Some(abs), Some(rel)) => {
let link_line = if has_match { h.line } else { 1 };
let target = build_editor_uri(&link.editor_template, abs, link_line, 1);
let clickable = term_link(&format!("{rel}{line_info}"), &target, true);
format!("{}{clickable}{}{docid_str}", p.cyan(), p.reset())
}
_ => format!(
"{}{}{}{line_info}{}{docid_str}",
p.cyan(),
h.file,
p.dim(),
p.reset()
),
};
println!("{line1}");
if !h.title.is_empty() {
println!("{}Title: {}{}", p.bold(), h.title, p.reset());
}
if let Some(ctx) = &h.context {
println!("{}Context: {}{}", p.dim(), ctx, p.reset());
}
println!(
"Score: {}{}{}",
p.bold(),
format_score(h.score, p),
p.reset()
);
if let Some(e) = &h.explain {
print_explain_block(e, p);
}
println!();
let content = render_content(h, line_numbers);
println!("{}", highlight_terms(&content, query, p));
if i < n - 1 {
println!("\n");
}
}
}
fn print_explain_block(e: &HybridQueryExplain, p: &Palette) {
let join_scores = |xs: &[f64]| -> String {
if xs.is_empty() {
"none".to_string()
} else {
xs.iter()
.map(|v| format_explain_number(*v))
.collect::<Vec<_>>()
.join(", ")
}
};
println!(
"{}Explain: fts=[{}] vec=[{}]{}",
p.dim(),
join_scores(&e.fts_scores),
join_scores(&e.vector_scores),
p.reset()
);
println!(
"{} RRF: total={} base={} bonus={} rank={}{}",
p.dim(),
format_explain_number(e.rrf.total_score),
format_explain_number(e.rrf.base_score),
format_explain_number(e.rrf.top_rank_bonus),
e.rrf.rank,
p.reset(),
);
println!(
"{} Blend: {}%*{} + {}%*{} = {}{}",
p.dim(),
(e.rrf.weight * 100.0).round() as i64,
format_explain_number(e.rrf.position_score),
((1.0 - e.rrf.weight) * 100.0).round() as i64,
format_explain_number(e.rerank_score),
format_explain_number(e.blended_score),
p.reset(),
);
let mut contribs: Vec<&RRFContributionTrace> = e.rrf.contributions.iter().collect();
contribs.sort_by(|a, b| {
b.rrf_contribution
.partial_cmp(&a.rrf_contribution)
.unwrap_or(std::cmp::Ordering::Equal)
});
let summary = contribs
.iter()
.take(3)
.map(|c| {
format!(
"{}/{}#{}:{}",
source_str(c.source),
query_type_str(c.query_type),
c.rank,
format_explain_number(c.rrf_contribution)
)
})
.collect::<Vec<_>>()
.join(" | ");
if !summary.is_empty() {
println!("{} Top RRF contributions: {summary}{}", p.dim(), p.reset());
}
}
pub fn print_hits_json(hits: &[Hit]) -> anyhow::Result<()> {
let s = serde_json::to_string_pretty(hits)?;
println!("{s}");
Ok(())
}
pub fn fmt_hits_csv(hits: &[Hit], line_numbers: bool) -> String {
let mut out = String::from("docid,score,file,title,context,line,snippet");
for h in hits {
let content = render_content(h, line_numbers);
out.push('\n');
out.push_str(&format!(
"#{},{:.4},{},{},{},{},{}",
h.docid,
h.score,
escape_csv(&h.file),
escape_csv(&h.title),
escape_csv(h.context.as_deref().unwrap_or("")),
h.line,
escape_csv(&content),
));
}
out
}
pub fn print_hits_csv(hits: &[Hit], line_numbers: bool) {
println!("{}", fmt_hits_csv(hits, line_numbers));
}
pub fn fmt_hits_files(hits: &[Hit]) -> String {
hits.iter()
.map(|h| {
if let Some(ctx) = &h.context {
let esc = ctx.replace('"', "\"\"");
format!("#{},{:.2},{},\"{esc}\"", h.docid, h.score, h.file)
} else {
format!("#{},{:.2},{}", h.docid, h.score, h.file)
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn print_hits_files(hits: &[Hit]) {
let s = fmt_hits_files(hits);
if !s.is_empty() {
println!("{s}");
}
}
pub fn fmt_hits_md(hits: &[Hit], line_numbers: bool) -> String {
hits.iter()
.map(|h| {
let heading = if !h.title.is_empty() {
h.title.as_str()
} else {
h.display_path.as_str()
};
let context_line = match &h.context {
Some(ctx) => format!("**context:** {ctx}\n"),
None => String::new(),
};
let content = render_content(h, line_numbers);
format!(
"---\n# {heading}\n\n**docid:** `#{docid}`\n{context_line}\n{content}\n",
docid = h.docid,
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn print_hits_md(hits: &[Hit], line_numbers: bool) {
let s = fmt_hits_md(hits, line_numbers);
if !s.is_empty() {
print!("{s}");
println!();
}
}
pub fn fmt_hits_xml(hits: &[Hit], line_numbers: bool) -> String {
hits.iter()
.map(|h| {
let title_attr = if !h.title.is_empty() {
format!(" title=\"{}\"", escape_xml(&h.title))
} else {
String::new()
};
let context_attr = match &h.context {
Some(ctx) => format!(" context=\"{}\"", escape_xml(ctx)),
None => String::new(),
};
let content = render_content(h, line_numbers);
format!(
"<file docid=\"#{docid}\" name=\"{name}\"{title_attr}{context_attr}>\n{body}\n</file>",
docid = h.docid,
name = escape_xml(&h.file),
body = escape_xml(&content),
)
})
.collect::<Vec<_>>()
.join("\n\n")
}
pub fn print_hits_xml(hits: &[Hit], line_numbers: bool) {
let s = fmt_hits_xml(hits, line_numbers);
if !s.is_empty() {
print!("{s}");
println!();
}
}
pub fn print_hits(
hits: &[Hit],
format: OutputFormat,
p: &Palette,
line_numbers: bool,
query: &str,
link: &CliLinkCtx,
) -> anyhow::Result<()> {
match format {
OutputFormat::Cli => print_hits_cli(hits, p, line_numbers, query, link),
OutputFormat::Json => print_hits_json(hits)?,
OutputFormat::Csv => print_hits_csv(hits, line_numbers),
OutputFormat::Md => print_hits_md(hits, line_numbers),
OutputFormat::Xml => print_hits_xml(hits, line_numbers),
OutputFormat::Files => print_hits_files(hits),
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct ExplainView<'a> {
pub file: String,
pub fts_scores: &'a [f64],
pub vector_scores: &'a [f64],
pub rrf: RrfExplainView<'a>,
pub rerank_score: f64,
pub blended_score: f64,
}
#[derive(Debug, Serialize)]
pub struct RrfExplainView<'a> {
pub rank: usize,
pub position_score: f64,
pub weight: f64,
pub base_score: f64,
pub top_rank_bonus: f64,
pub total_score: f64,
pub contributions: Vec<ContributionView<'a>>,
}
#[derive(Debug, Serialize)]
pub struct ContributionView<'a> {
pub list_index: usize,
pub source: &'static str,
pub query_type: &'static str,
pub query: &'a str,
pub rank: usize,
pub weight: f64,
pub backend_score: f64,
pub rrf_contribution: f64,
}
impl<'a> ExplainView<'a> {
pub fn new(file: String, e: &'a HybridQueryExplain) -> Self {
Self {
file,
fts_scores: &e.fts_scores,
vector_scores: &e.vector_scores,
rrf: RrfExplainView::from_ref(&e.rrf),
rerank_score: e.rerank_score,
blended_score: e.blended_score,
}
}
}
impl<'a> RrfExplainView<'a> {
fn from_ref(e: &'a RRFExplain) -> Self {
Self {
rank: e.rank,
position_score: e.position_score,
weight: e.weight,
base_score: e.base_score,
top_rank_bonus: e.top_rank_bonus,
total_score: e.total_score,
contributions: e
.contributions
.iter()
.map(ContributionView::from_ref)
.collect(),
}
}
}
impl<'a> ContributionView<'a> {
fn from_ref(c: &'a RRFContributionTrace) -> Self {
Self {
list_index: c.list_index,
source: source_str(c.source),
query_type: query_type_str(c.query_type),
query: &c.query,
rank: c.rank,
weight: c.weight,
backend_score: c.backend_score,
rrf_contribution: c.rrf_contribution,
}
}
}
fn source_str(s: SearchSource) -> &'static str {
match s {
SearchSource::Fts => "fts",
SearchSource::Vec => "vec",
}
}
fn query_type_str(t: QueryType) -> &'static str {
match t {
QueryType::Original => "original",
QueryType::Lex => "lex",
QueryType::Vec => "vec",
QueryType::Hyde => "hyde",
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct McpHit {
pub docid: String,
pub file: String,
pub title: String,
pub score: f64,
pub context: Option<String>,
pub snippet: String,
}
#[allow(dead_code)]
pub fn mcp_results_to_csv(rows: &[McpHit]) -> String {
let mut out = String::from("docid,file,title,score,context,snippet");
for r in rows {
out.push('\n');
out.push_str(
&[
escape_csv(&format!("#{}", r.docid)),
escape_csv(&r.file),
escape_csv(&r.title),
escape_csv(&r.score.to_string()),
escape_csv(r.context.as_deref().unwrap_or("")),
escape_csv(&r.snippet),
]
.join(","),
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn line_at_byte_offset_basics() {
assert_eq!(line_at_byte_offset("", 0), 1);
assert_eq!(line_at_byte_offset("abc\ndef", 0), 1);
assert_eq!(line_at_byte_offset("abc\ndef", 4), 2);
assert_eq!(line_at_byte_offset("a\nb\nc", 4), 3);
assert_eq!(line_at_byte_offset("日本\nabc", 7), 2);
assert_eq!(line_at_byte_offset("a\nb", 100), 2);
}
#[allow(clippy::too_many_arguments)]
fn hit(
docid: &str,
display_path: &str,
title: &str,
context: Option<&str>,
score: f64,
line: usize,
snippet: &str,
body: Option<&str>,
) -> Hit {
Hit {
file: display_path.to_string(),
display_path: display_path.to_string(),
title: title.to_string(),
score,
line,
context: context.map(String::from),
snippet: snippet.to_string(),
body: body.map(String::from),
docid: docid.to_string(),
abs_path: None,
explain: None,
}
}
#[test]
fn render_content_picks_body_with_start_1_when_full() {
let h = hit("a", "f", "", None, 0.0, 5, "snip", Some("L1\nL2"));
assert_eq!(render_content(&h, false), "L1\nL2");
assert_eq!(render_content(&h, true), "1: L1\n2: L2");
}
#[test]
fn render_content_picks_snippet_with_start_at_hit_line() {
let h = hit("a", "f", "", None, 0.0, 7, "X\nY", None);
assert_eq!(render_content(&h, false), "X\nY");
assert_eq!(render_content(&h, true), "7: X\n8: Y");
}
#[test]
fn csv_header_and_basic_row() {
let hits = vec![hit(
"abc123",
"docs/x.md",
"Title",
Some("ctx"),
0.125,
3,
"snip",
None,
)];
let s = fmt_hits_csv(&hits, false);
let mut lines = s.lines();
assert_eq!(
lines.next(),
Some("docid,score,file,title,context,line,snippet")
);
assert_eq!(
lines.next(),
Some("#abc123,0.1250,docs/x.md,Title,ctx,3,snip")
);
assert_eq!(lines.next(), None);
}
#[test]
fn csv_empty_emits_header_only() {
let s = fmt_hits_csv(&[], false);
assert_eq!(s, "docid,score,file,title,context,line,snippet");
}
#[test]
fn csv_escapes_commas_quotes_newlines() {
let hits = vec![hit(
"x",
"a,b.md",
"T\"itle",
Some("c\nd"),
0.5,
1,
"s,n",
None,
)];
let s = fmt_hits_csv(&hits, false);
assert!(s.contains("\"a,b.md\""));
assert!(s.contains("\"T\"\"itle\""));
assert!(s.contains("\"c\nd\""));
assert!(s.contains("\"s,n\""));
}
#[test]
fn files_with_and_without_context() {
let hits = vec![
hit("a", "x.md", "T", None, 0.12, 1, "s", None),
hit("b", "y.md", "T", Some("ctx\"q"), 0.34, 1, "s", None),
];
let s = fmt_hits_files(&hits);
assert_eq!(s, "#a,0.12,x.md\n#b,0.34,y.md,\"ctx\"\"q\"");
}
#[test]
fn md_with_context_has_blank_line_before_content() {
let hits = vec![hit("a", "x.md", "Title", Some("ctx"), 0.0, 1, "BODY", None)];
let s = fmt_hits_md(&hits, false);
assert_eq!(
s,
"---\n# Title\n\n**docid:** `#a`\n**context:** ctx\n\nBODY\n"
);
}
#[test]
fn md_without_context_keeps_same_blank_line_above_content() {
let hits = vec![hit("a", "x.md", "Title", None, 0.0, 1, "BODY", None)];
let s = fmt_hits_md(&hits, false);
assert_eq!(s, "---\n# Title\n\n**docid:** `#a`\n\nBODY\n");
}
#[test]
fn md_falls_back_to_display_path_when_title_empty() {
let hits = vec![hit("a", "docs/x.md", "", None, 0.0, 1, "B", None)];
let s = fmt_hits_md(&hits, false);
assert!(s.starts_with("---\n# docs/x.md\n"));
}
#[test]
fn xml_omits_title_attr_when_title_empty() {
let hits = vec![hit("a", "x.md", "", None, 0.0, 1, "B", None)];
let s = fmt_hits_xml(&hits, false);
assert_eq!(s, "<file docid=\"#a\" name=\"x.md\">\nB\n</file>");
assert!(!s.contains("title="));
}
#[test]
fn xml_includes_title_and_context_when_present() {
let hits = vec![hit("a", "x.md", "T<", Some("c&d"), 0.0, 1, "B>", None)];
let s = fmt_hits_xml(&hits, false);
assert_eq!(
s,
"<file docid=\"#a\" name=\"x.md\" title=\"T<\" context=\"c&d\">\nB>\n</file>"
);
}
#[test]
fn xml_two_hits_joined_with_blank_line() {
let hits = vec![
hit("a", "x.md", "", None, 0.0, 1, "B1", None),
hit("b", "y.md", "", None, 0.0, 1, "B2", None),
];
let s = fmt_hits_xml(&hits, false);
assert_eq!(
s,
"<file docid=\"#a\" name=\"x.md\">\nB1\n</file>\n\n<file docid=\"#b\" name=\"y.md\">\nB2\n</file>"
);
}
#[test]
fn full_plus_line_numbers_numbers_from_one() {
let hits = vec![hit("a", "x.md", "", None, 0.0, 42, "snip", Some("L1\nL2"))];
let csv_row = fmt_hits_csv(&hits, true);
assert!(
csv_row.contains("1: L1\n2: L2"),
"expected body to be numbered from 1 with --full --line-numbers, got: {csv_row}"
);
}
#[test]
fn snippet_plus_line_numbers_numbers_from_hit_line() {
let hits = vec![hit("a", "x.md", "", None, 0.0, 42, "S1\nS2", None)];
let csv_row = fmt_hits_csv(&hits, true);
assert!(
csv_row.contains("42: S1\n43: S2"),
"expected snippet numbering anchored at line 42, got: {csv_row}"
);
}
#[test]
fn empty_inputs_produce_minimal_output() {
assert_eq!(fmt_hits_files(&[]), "");
assert_eq!(fmt_hits_md(&[], false), "");
assert_eq!(fmt_hits_xml(&[], false), "");
}
const TEST_CONTEXT: &str = "Internal engineering keynotes from company summit events";
fn ctx_hits() -> Vec<Hit> {
vec![hit(
"dc5590",
"qmd://archive/summit/keynote.md",
"Summit Keynote",
Some(TEST_CONTEXT),
0.84,
3,
"This is the keynote content.",
None,
)]
}
#[test]
fn json_includes_context() {
let v = serde_json::to_value(ctx_hits()).unwrap();
assert_eq!(v[0]["context"], TEST_CONTEXT);
}
#[test]
fn json_omits_context_when_none() {
let hits = vec![hit("a", "x.md", "T", None, 0.5, 1, "s", None)];
let v = serde_json::to_value(hits).unwrap();
assert!(v[0].get("context").is_none());
}
#[test]
fn json_includes_line() {
let v = serde_json::to_value(ctx_hits()).unwrap();
assert!(v[0]["line"].as_u64().unwrap() > 0);
}
#[test]
fn json_includes_line_with_full() {
let hits = vec![hit("a", "x.md", "T", None, 0.5, 5, "snip", Some("L1\nL2"))];
let v = serde_json::to_value(hits).unwrap();
assert!(v[0]["line"].as_u64().unwrap() > 0);
}
#[test]
fn format_search_results_json_includes_context() {
assert!(
serde_json::to_string(&ctx_hits())
.unwrap()
.contains(TEST_CONTEXT)
);
}
#[test]
fn format_search_results_csv_includes_context() {
assert!(fmt_hits_csv(&ctx_hits(), false).contains(TEST_CONTEXT));
}
#[test]
fn format_search_results_files_includes_context() {
assert!(fmt_hits_files(&ctx_hits()).contains(TEST_CONTEXT));
}
#[test]
fn format_search_results_md_includes_context() {
assert!(fmt_hits_md(&ctx_hits(), false).contains(TEST_CONTEXT));
}
#[test]
fn format_search_results_xml_includes_context() {
assert!(fmt_hits_xml(&ctx_hits(), false).contains(TEST_CONTEXT));
}
#[test]
fn mcp_csv_includes_context() {
let rows = vec![McpHit {
docid: "dc5590".to_string(),
file: "qmd://archive/summit/keynote.md".to_string(),
title: "Summit Keynote".to_string(),
score: 0.84,
context: Some(TEST_CONTEXT.to_string()),
snippet: "This is the keynote content.".to_string(),
}];
let out = mcp_results_to_csv(&rows);
assert!(out.lines().next().unwrap().contains("context"));
assert!(out.contains(TEST_CONTEXT));
}
#[test]
fn to_qmd_path_default_index_has_no_query() {
assert_eq!(
to_qmd_path("fixtures/test1.md", None),
"qmd://fixtures/test1.md"
);
}
#[test]
fn to_qmd_path_custom_index_appends_query() {
assert_eq!(
to_qmd_path("fixtures/a/b.md", Some("release-notes")),
"qmd://fixtures/a/b.md?index=release-notes"
);
}
#[test]
fn to_qmd_path_no_slash_is_left_bare() {
assert_eq!(to_qmd_path("fixtures", Some("x")), "qmd://fixtures");
}
#[test]
fn to_qmd_path_trailing_slash_keeps_bare() {
assert_eq!(to_qmd_path("fixtures/", Some("x")), "qmd://fixtures/");
}
#[test]
fn to_qmd_path_encodes_index_name() {
assert_eq!(
to_qmd_path("c/p.md", Some("a b")),
"qmd://c/p.md?index=a%20b"
);
}
#[test]
fn term_link_wraps_only_on_tty() {
assert_eq!(term_link("text", "url", false), "text");
assert_eq!(
term_link("text", "url", true),
"\x1b]8;;url\x07text\x1b]8;;\x07"
);
}
#[test]
fn build_editor_uri_substitutes_placeholders() {
let s = build_editor_uri("vscode://file/{path}:{line}:{col}", "/a/b.md", 12, 1);
assert_eq!(s, "vscode://file//a/b.md:12:1");
}
#[test]
fn build_editor_uri_column_alias_and_clamp_and_encode() {
let s = build_editor_uri("e://{path}#{line},{column}", "/x y.md", 0, 0);
assert_eq!(s, "e:///x%20y.md#1,1");
}
#[test]
fn highlight_terms_noop_when_color_disabled() {
let p = Palette { enabled: false };
assert_eq!(highlight_terms("hello world", "world", &p), "hello world");
}
#[test]
fn highlight_terms_wraps_long_terms_case_insensitively() {
let p = Palette { enabled: true };
let out = highlight_terms("Hello WORLD", "world", &p);
assert_eq!(
out,
format!("Hello {}{}WORLD{}", p.yellow(), p.bold(), p.reset())
);
}
#[test]
fn highlight_terms_skips_short_terms() {
let p = Palette { enabled: true };
assert_eq!(highlight_terms("a of b", "of", &p), "a of b");
}
#[test]
fn format_score_plain_when_disabled() {
let p = Palette { enabled: false };
assert_eq!(format_score(0.842, &p), " 84%");
assert_eq!(format_score(0.05, &p), " 5%");
}
#[test]
fn format_score_colors_by_threshold() {
let p = Palette { enabled: true };
assert!(format_score(0.8, &p).contains(p.green()));
assert!(format_score(0.5, &p).contains(p.yellow()));
assert!(format_score(0.1, &p).contains(p.dim()));
}
}