use std::path::Path;
use crate::tools::CrpMode;
use lean_ctx::core::bm25_index::{BM25Index, format_search_results};
#[cfg(feature = "embeddings")]
use lean_ctx::core::embedding_index::EmbeddingIndex;
#[cfg(feature = "embeddings")]
use lean_ctx::core::embeddings::EmbeddingEngine;
use lean_ctx::core::hybrid_search::HybridResult;
#[allow(clippy::too_many_arguments)]
pub fn handle(
query: &str,
path: &str,
top_k: usize,
crp_mode: CrpMode,
languages: Option<&[String]>,
path_glob: Option<&str>,
mode: Option<&str>,
workspace: Option<bool>,
artifacts: Option<bool>,
) -> String {
handle_for_tool(
"ctx_search",
query,
path,
top_k,
crp_mode,
languages,
path_glob,
mode,
workspace,
artifacts,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn handle_for_tool(
tool: &str,
query: &str,
path: &str,
top_k: usize,
crp_mode: CrpMode,
languages: Option<&[String]>,
path_glob: Option<&str>,
mode: Option<&str>,
workspace: Option<bool>,
artifacts: Option<bool>,
) -> String {
let (root, _) = match resolve_search_root(path) {
Ok(value) => value,
Err(error) => return format!("ERR: {error}"),
};
lean_ctx::core::policy::runtime::with_project_source_view(&root.to_string_lossy(), || {
handle_inner(
tool, query, path, top_k, crp_mode, languages, path_glob, mode, workspace, artifacts,
)
})
.unwrap_or_else(|error| format!("ERR: {error}"))
}
#[allow(clippy::too_many_arguments)]
fn handle_inner(
tool: &str,
query: &str,
path: &str,
top_k: usize,
crp_mode: CrpMode,
languages: Option<&[String]>,
path_glob: Option<&str>,
mode: Option<&str>,
workspace: Option<bool>,
artifacts: Option<bool>,
) -> String {
let (root_buf, subdir) = match resolve_search_root(path) {
Ok(v) => v,
Err(e) => return format!("ERR: {e}"),
};
let root = root_buf.as_path();
if !lean_ctx::core::policy::runtime::is_active() && !query.trim().is_empty() {
lean_ctx::core::session::caller::with_mut(|session| {
if session.last_semantic_query.as_deref() != Some(query) {
session.last_semantic_query = Some(query.to_string());
session.increment();
}
});
}
let filter = match SearchFilter::new(languages, path_glob) {
Ok(f) => f.with_subdir(subdir),
Err(e) => return format!("ERR: invalid filter: {e}"),
};
let compact = crp_mode.is_tdd();
let mode_defaulted = mode.is_none();
let mode = mode.unwrap_or("bm25").to_lowercase();
let workspace = workspace.unwrap_or(false);
let artifacts = artifacts.unwrap_or(false);
if lean_ctx::core::policy::runtime::is_active() && (mode != "bm25" || workspace || artifacts) {
return admitted::UNSUPPORTED.into();
}
if artifacts {
return artifacts_search(query, root, top_k, compact, &filter, workspace);
}
if workspace {
return workspace_search(query, root, top_k, compact, &filter, &mode);
}
let index = if mode == "bm25" {
match admitted::fresh_index(root, &filter, tool) {
Ok(index) => std::sync::Arc::new(index),
Err(error) => return format!("ERR: {error}"),
}
} else {
let _index_phase = lean_ctx::core::phase_timing::enter("semantic: BM25 index load");
match load_or_refresh_bm25(root) {
Bm25LoadResult::Ready(idx) => idx,
Bm25LoadResult::Building => {
return "BM25 index is being built in the background. \
Run ctx_search(action=\"semantic\") again in ~30s, or ctx_index(action=\"build\") to wait for completion."
.to_string();
}
}
};
if index.doc_count == 0 {
return "No admitted text chunks available for local search.".to_string();
}
match mode.as_str() {
"bm25" => {
let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
if filter.is_active() {
results.retain(|x| filter.matches(&x.file_path));
}
results.truncate(top_k);
let degraded = mode_defaulted
&& !lean_ctx::core::policy::runtime::is_active()
&& dense_index_missing(root);
let header = if compact {
format!(
"semantic_search({},{top_k}) → {} results, {} chunks indexed\n",
if degraded {
"bm25,dense-not-built"
} else {
"bm25"
},
results.len(),
index.doc_count
)
} else {
format!(
"{}: \"{}\" ({} results from {} indexed chunks in {})\n",
if degraded {
"Lexical search (BM25 — dense index not built, \
run: lean-ctx index build-semantic)"
} else {
"Semantic search (BM25)"
},
truncate_query(query, 60),
results.len(),
index.doc_count,
lean_ctx::core::pathutil::strip_verbatim(root.to_path_buf()).display(),
)
};
format!("{header}{}", format_search_results(&results, compact))
}
"dense" => {
let out = dense_search_mode(query, root, &index, top_k, compact, &filter);
shrink_resident_after_embedding(root, index);
out
}
_ => {
let out = hybrid_search_mode(query, root, &index, top_k, compact, &filter);
shrink_resident_after_embedding(root, index);
out
}
}
}
fn shrink_resident_after_embedding(root: &Path, index: std::sync::Arc<BM25Index>) {
#[cfg(feature = "embeddings")]
{
drop(index);
if let Some(cache) = get_thread_cache() {
let freed = lean_ctx::core::bm25_cache::shrink_resident_to_snippet(&cache, root, 5);
if freed > 0 {
tracing::info!(
"[bm25_cache] reclaimed ~{:.1}MB of resident chunk bodies post-embedding",
freed as f64 / 1_048_576.0
);
}
}
}
#[cfg(not(feature = "embeddings"))]
{
let _ = (root, index);
}
}
pub fn search_hits(
query: &str,
path: &str,
top_k: usize,
mode: &str,
languages: Option<&[String]>,
path_glob: Option<&str>,
) -> Result<Vec<HybridResult>, String> {
let (root, _) = resolve_search_root(path)?;
lean_ctx::core::policy::runtime::with_project_source_view(&root.to_string_lossy(), || {
search_hits_inner(query, path, top_k, mode, languages, path_glob)
})?
}
fn search_hits_inner(
query: &str,
path: &str,
top_k: usize,
mode: &str,
languages: Option<&[String]>,
path_glob: Option<&str>,
) -> Result<Vec<HybridResult>, String> {
let (root_buf, subdir) = resolve_search_root(path)?;
let root = root_buf.as_path();
let filter = SearchFilter::new(languages, path_glob)
.map_err(|e| format!("invalid filter: {e}"))?
.with_subdir(subdir);
if lean_ctx::core::policy::runtime::is_active() && !mode.eq_ignore_ascii_case("bm25") {
return Err(admitted::UNSUPPORTED.into());
}
let index = if mode.eq_ignore_ascii_case("bm25") {
admitted::fresh_index(root, &filter, "ctx_search")?
} else {
BM25Index::load_or_build(root)
};
if index.doc_count == 0 {
return Ok(Vec::new());
}
let results = match mode.to_lowercase().as_str() {
"bm25" => bm25_hits(&index, query, top_k, &filter),
"dense" => {
#[cfg(feature = "embeddings")]
{
dense_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
}
#[cfg(not(feature = "embeddings"))]
{
return Err("dense mode requires the embeddings feature".to_string());
}
}
_ => {
#[cfg(feature = "embeddings")]
{
hybrid_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
}
#[cfg(not(feature = "embeddings"))]
{
bm25_hits(&index, query, top_k, &filter)
}
}
};
Ok(results)
}
fn bm25_hits(
index: &BM25Index,
query: &str,
top_k: usize,
filter: &SearchFilter,
) -> Vec<HybridResult> {
let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
if filter.is_active() {
results.retain(|x| filter.matches(&x.file_path));
}
results.truncate(top_k);
results
.into_iter()
.map(HybridResult::from_bm25_public)
.collect()
}
#[must_use]
pub fn handle_reindex(path: &str) -> String {
guarded_legacy(path, || handle_reindex_inner(path))
}
fn handle_reindex_inner(path: &str) -> String {
let (root_buf, _subdir) = match resolve_search_root(path) {
Ok(v) => v,
Err(e) => return format!("ERR: {e}"),
};
let root = root_buf.as_path();
let idx = BM25Index::build_from_directory(root);
let files = idx.files.len();
let chunks = idx.doc_count;
let _ = idx.save(root);
format!(
"Reindexed {}: {files} files, {chunks} chunks",
root.display()
)
}
#[must_use]
pub fn handle_reindex_artifacts(path: &str, workspace: bool) -> String {
guarded_legacy(path, || handle_reindex_artifacts_inner(path, workspace))
}
fn handle_reindex_artifacts_inner(path: &str, workspace: bool) -> String {
let (root_buf, _subdir) = match resolve_search_root(path) {
Ok(v) => v,
Err(e) => return format!("ERR: {e}"),
};
let root = root_buf.as_path();
let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
let mut warnings: Vec<String> = Vec::new();
if workspace {
let linked = lean_ctx::core::workspace_config::load_linked_projects(root);
warnings.extend(linked.warnings);
roots.extend(linked.roots);
}
let mut total_files = 0usize;
let mut total_chunks = 0usize;
for r in roots {
let (idx, w) = lean_ctx::core::artifact_index::rebuild_from_scratch(&r);
warnings.extend(w);
total_files += idx.files.len();
total_chunks += idx.doc_count;
}
if warnings.is_empty() {
format!("Reindexed artifacts: {total_files} files, {total_chunks} chunks")
} else {
format!(
"Reindexed artifacts: {total_files} files, {total_chunks} chunks ({} warning(s))",
warnings.len()
)
}
}
pub fn handle_find_related(
file_path: &str,
line: usize,
project_root: &str,
top_k: usize,
crp_mode: CrpMode,
) -> String {
guarded_legacy(project_root, || {
handle_find_related_inner(file_path, line, project_root, top_k, crp_mode)
})
}
fn guarded_legacy(path: &str, operation: impl FnOnce() -> String) -> String {
let (root, _) = match resolve_search_root(path) {
Ok(value) => value,
Err(error) => return format!("ERR: {error}"),
};
lean_ctx::core::policy::runtime::with_project_source_view(&root.to_string_lossy(), || {
if lean_ctx::core::policy::runtime::is_active() {
admitted::UNSUPPORTED.into()
} else {
operation()
}
})
.unwrap_or_else(|error| format!("ERR: {error}"))
}
fn handle_find_related_inner(
file_path: &str,
line: usize,
project_root: &str,
top_k: usize,
crp_mode: CrpMode,
) -> String {
let (root_buf, _subdir) = match resolve_search_root(project_root) {
Ok(v) => v,
Err(e) => return format!("ERR: {e}"),
};
let root = root_buf.as_path();
let index = BM25Index::load_or_build(root);
if index.doc_count == 0 {
return "ERR: empty index. Try action=reindex first.".to_string();
}
let source_chunk =
lean_ctx::core::bm25_index::source_gate::pinned(index.source_root.as_deref(), || {
let mut gate = index.source_gate();
index
.chunks
.iter()
.find(|c| c.file_path == file_path && c.start_line <= line && c.end_line >= line)
.filter(|chunk| {
lean_ctx::core::bm25_index::source_gate::chunk_served(&mut gate, chunk)
})
})
.flatten();
let Some(source_chunk) = source_chunk else {
return format!(
"ERR: no indexed chunk found at {file_path}:{line}. Try action=reindex first."
);
};
let query_text = source_chunk.content.clone();
let source_file = source_chunk.file_path.clone();
let source_start = source_chunk.start_line;
let compact = crp_mode != CrpMode::Off;
let results = find_related_internal(&query_text, root, &index, top_k + 5, compact);
let mut lines: Vec<String> = results
.into_iter()
.filter(|l| !l.contains(&format!("{source_file}:{source_start}-")))
.take(top_k)
.collect();
let header = if compact {
format!(
"find_related({file_path}:{line}) → {} results\n",
lines.len()
)
} else {
format!("Find related to {file_path}:{line} (semantic similarity)\n")
};
lines.insert(0, header);
lines.join("")
}
fn find_related_internal(
query: &str,
root: &Path,
index: &BM25Index,
top_k: usize,
compact: bool,
) -> Vec<String> {
let Ok(filter) = SearchFilter::new(None, None) else {
return vec!["ERR: filter init failed\n".to_string()];
};
let output = hybrid_search_mode(query, root, index, top_k, compact, &filter);
output.lines().map(|l| format!("{l}\n")).collect()
}
fn truncate_query(q: &str, max: usize) -> &str {
if q.len() <= max {
return q;
}
match q.char_indices().nth(max) {
Some((byte_idx, _)) => &q[..byte_idx],
None => q,
}
}
#[cfg(feature = "embeddings")]
pub fn load_engine_and_index_pub(
root: &Path,
) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
load_engine_and_index(root)
}
#[cfg(feature = "embeddings")]
pub fn ensure_embeddings_for_eval(
root: &Path,
index: &BM25Index,
engine: &EmbeddingEngine,
embed_idx: &mut EmbeddingIndex,
) -> Result<AlignedEmbeddings, String> {
ensure_embeddings(root, index, engine, embed_idx)
}
pub fn boost_with_splade_pub(
results: &mut [HybridResult],
splade: &[lean_ctx::core::splade_retrieval::SpladeResult],
weight: f64,
) {
boost_with_splade(results, splade, weight);
}
mod admitted;
pub(crate) use lean_ctx::core::bm25_store;
mod dense;
pub(crate) mod multi_root;
mod scope;
pub(crate) use bm25_store::*;
pub use bm25_store::{get_thread_cache, set_thread_cache};
pub(crate) use dense::*;
pub(crate) use multi_root::*;
pub(crate) use scope::*;
#[cfg(test)]
mod tests;