use std::collections::HashMap;
use std::path::PathBuf;
use crate::core::multi_repo::{FusedSearchResult, format_fused_results, global_manager};
fn per_root_k(max_results: usize) -> usize {
(max_results * 2).max(20)
}
pub(super) fn handle_search(
query: Option<&str>,
max_results: usize,
roots_filter: Option<&[String]>,
mode: Option<&str>,
) -> (String, usize) {
let Some(query) = query else {
return ("ERROR: query is required for search".to_string(), 0);
};
let mode = mode.unwrap_or("hybrid").trim().to_ascii_lowercase();
match mode.as_str() {
"bm25" => bm25_search(query, max_results, roots_filter),
"hybrid" => hybrid_search(query, max_results, roots_filter),
other => (
format!("ERROR: unknown search mode '{other}' (expected 'bm25' or 'hybrid')"),
0,
),
}
}
fn bm25_search(
query: &str,
max_results: usize,
roots_filter: Option<&[String]>,
) -> (String, usize) {
let manager = global_manager();
let Ok(mut mgr) = manager.lock() else {
return ("ERROR: failed to acquire multi-repo lock".to_string(), 0);
};
if mgr.root_count() == 0 {
return (
"ERROR: no repo roots configured. Use add_root first.".to_string(),
0,
);
}
let results = mgr.search(query, max_results, roots_filter);
let output = format_fused_results(&results);
let tokens = crate::core::tokens::count_tokens(&output);
(output, tokens)
}
fn hybrid_search(
query: &str,
max_results: usize,
roots_filter: Option<&[String]>,
) -> (String, usize) {
let (roots, rrf_k) = {
let manager = global_manager();
let Ok(mgr) = manager.lock() else {
return ("ERROR: failed to acquire multi-repo lock".to_string(), 0);
};
if mgr.root_count() == 0 {
return (
"ERROR: no repo roots configured. Use add_root first.".to_string(),
0,
);
}
let roots: Vec<(String, PathBuf)> = mgr
.list_roots()
.into_iter()
.filter(|r| {
roots_filter
.is_none_or(|filter| filter.iter().any(|f| f == &r.alias || f == &r.path))
})
.map(|r| (r.alias, PathBuf::from(r.path)))
.collect();
(roots, mgr.rrf_k())
};
let (fused, searched_roots, warnings) = search_roots_hybrid(query, &roots, max_results, rrf_k);
let mut output = format!(
"Cross-repo hybrid search (BM25+dense+SPLADE, RRF over {searched_roots} roots):\n{}",
format_fused_results(&fused)
);
if !warnings.is_empty() {
output.push_str(&format!("\nWarnings ({}):\n", warnings.len()));
for w in warnings.iter().take(8) {
output.push_str(&format!("- {w}\n"));
}
}
let tokens = crate::core::tokens::count_tokens(&output);
(output, tokens)
}
fn search_roots_hybrid(
query: &str,
roots: &[(String, PathBuf)],
max_results: usize,
rrf_k: f64,
) -> (Vec<FusedSearchResult>, usize, Vec<String>) {
let k = per_root_k(max_results);
let mut warnings: Vec<String> = Vec::new();
let mut per_root: Vec<(
String,
String,
Vec<crate::core::hybrid_search::HybridResult>,
)> = Vec::new();
for (alias, path) in roots {
let index = crate::core::bm25_index::BM25Index::load_or_build(path);
if index.doc_count == 0 {
continue;
}
let (results, warning) = per_root_hybrid(query, path, &index, k);
if let Some(w) = warning {
warnings.push(format!("[{alias}] {w}"));
}
per_root.push((alias.clone(), path.to_string_lossy().to_string(), results));
}
let searched = per_root.len();
(
fuse_repo_lists(per_root, max_results, rrf_k),
searched,
warnings,
)
}
#[cfg(feature = "embeddings")]
fn per_root_hybrid(
query: &str,
root: &std::path::Path,
index: &crate::core::bm25_index::BM25Index,
k: usize,
) -> (
Vec<crate::core::hybrid_search::HybridResult>,
Option<String>,
) {
use crate::tools::ctx_semantic_search::multi_root;
let cfg = crate::core::hybrid_search::HybridConfig::from_config();
if !cfg.dense_enabled {
let mut results =
crate::core::hybrid_search::hybrid_search(query, index, None, None, k, &cfg, None);
if cfg.splade_weight > 0.0 {
let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, k);
if !splade.is_empty() {
multi_root::boost_with_splade(&mut results, &splade, cfg.splade_weight);
}
}
results.truncate(k);
return (results, None);
}
let filter = match crate::tools::ctx_semantic_search::SearchFilter::new(None, None) {
Ok(f) => f,
Err(e) => return (bm25_ranking(index, query, k), Some(e)),
};
match multi_root::hybrid_results_for_root(query, root, index, k, &filter) {
Ok((results, _coverage)) => (results, None),
Err(e) => (
bm25_ranking(index, query, k),
Some(format!("hybrid failed: {e}; degraded to BM25")),
),
}
}
#[cfg(not(feature = "embeddings"))]
fn per_root_hybrid(
query: &str,
_root: &std::path::Path,
index: &crate::core::bm25_index::BM25Index,
k: usize,
) -> (
Vec<crate::core::hybrid_search::HybridResult>,
Option<String>,
) {
(bm25_ranking(index, query, k), None)
}
fn bm25_ranking(
index: &crate::core::bm25_index::BM25Index,
query: &str,
k: usize,
) -> Vec<crate::core::hybrid_search::HybridResult> {
index
.search(query, k)
.into_iter()
.map(crate::core::hybrid_search::HybridResult::from_bm25_public)
.collect()
}
fn fuse_repo_lists(
lists: Vec<(
String,
String,
Vec<crate::core::hybrid_search::HybridResult>,
)>,
max_results: usize,
rrf_k: f64,
) -> Vec<FusedSearchResult> {
let mut acc: HashMap<String, FusedSearchResult> = HashMap::new();
for (alias, repo_path, results) in lists {
for (rank, r) in results.into_iter().enumerate() {
let contribution = 1.0 / (rrf_k + rank as f64 + 1.0);
let key = format!("{alias}:{}:{}", r.file_path, r.start_line);
acc.entry(key)
.and_modify(|existing| existing.rrf_score += contribution)
.or_insert_with(|| FusedSearchResult {
repo_alias: alias.clone(),
repo_path: repo_path.clone(),
file_path: r.file_path,
symbol_name: r.symbol_name,
content: r.snippet,
start_line: r.start_line,
end_line: r.end_line,
rrf_score: contribution,
});
}
}
let mut fused: Vec<FusedSearchResult> = acc.into_values().collect();
fused.sort_by(|a, b| {
b.rrf_score
.partial_cmp(&a.rrf_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.repo_alias.cmp(&b.repo_alias))
.then_with(|| a.file_path.cmp(&b.file_path))
.then_with(|| a.start_line.cmp(&b.start_line))
});
fused.truncate(max_results);
fused
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::hybrid_search::HybridResult;
fn hit(file: &str, symbol: &str, start: usize) -> HybridResult {
HybridResult {
file_path: file.to_string(),
symbol_name: symbol.to_string(),
kind: crate::core::bm25_index::ChunkKind::Function,
start_line: start,
end_line: start + 5,
snippet: format!("fn {symbol}() {{}}"),
rrf_score: 0.0,
bm25_score: None,
dense_score: None,
bm25_rank: None,
dense_rank: None,
}
}
#[test]
fn fuse_repo_lists_ranks_shared_hits_first() {
let backend = (
"backend".to_string(),
"/repos/backend".to_string(),
vec![hit("src/auth.rs", "login", 1), hit("src/db.rs", "pool", 1)],
);
let frontend = (
"frontend".to_string(),
"/repos/frontend".to_string(),
vec![hit("src/api.ts", "login", 1)],
);
let fused = fuse_repo_lists(vec![backend, frontend], 10, 60.0);
assert_eq!(fused.len(), 3);
assert_eq!(fused[0].repo_alias, "backend");
assert_eq!(fused[0].file_path, "src/auth.rs");
assert_eq!(fused[1].repo_alias, "frontend");
assert!((fused[0].rrf_score - fused[1].rrf_score).abs() < f64::EPSILON);
assert!(fused[1].rrf_score > fused[2].rrf_score);
}
#[test]
fn fuse_repo_lists_deduplicates_same_chunk_within_repo() {
let lists = vec![(
"app".to_string(),
"/repos/app".to_string(),
vec![hit("src/a.rs", "f", 1), hit("src/a.rs", "f", 1)],
)];
let fused = fuse_repo_lists(lists, 10, 60.0);
assert_eq!(fused.len(), 1);
let expected = 1.0 / 61.0 + 1.0 / 62.0;
assert!((fused[0].rrf_score - expected).abs() < 1e-12);
}
#[test]
fn fuse_repo_lists_truncates_to_max_results() {
let lists = vec![(
"app".to_string(),
"/repos/app".to_string(),
(0..30).map(|i| hit(&format!("f{i}.rs"), "f", 1)).collect(),
)];
let fused = fuse_repo_lists(lists, 5, 60.0);
assert_eq!(fused.len(), 5);
}
#[test]
fn search_roots_hybrid_skips_empty_roots_and_warns_nothing() {
let dir = tempfile::tempdir().unwrap();
let roots = vec![("empty".to_string(), dir.path().to_path_buf())];
let (fused, searched, warnings) = search_roots_hybrid("query", &roots, 10, 60.0);
assert!(fused.is_empty());
assert_eq!(searched, 0);
assert!(warnings.is_empty());
}
#[test]
fn handle_search_rejects_unknown_mode() {
let (out, _) = handle_search(Some("q"), 10, None, Some("vector"));
assert!(out.contains("unknown search mode"));
}
#[test]
fn per_root_k_has_floor() {
assert_eq!(per_root_k(5), 20);
assert_eq!(per_root_k(50), 100);
}
}