use super::agent_classifier::{AgentRoute, classify_agent_query, extract_symbol};
use super::agent_formatter::{
DEFAULT_BUDGET, FormatStyle, append_disambiguation_block, format_code_blocks,
format_deep_results, format_graph_results, format_search_results,
};
use crate::index::architecture::budget_for_chunk_count;
use crate::search::SearchQuery;
use crate::search::deep as deep_search_module;
use crate::search::hybrid::HybridSearcher;
use crate::server::handler::{
AdaptiveGraphWalk, AdaptiveOptions, compute_confidence, deep_result_to_response,
graph_walk_from_store_adaptive, search_result_to_item,
};
use crate::server::protocol::{
AgentMetrics, AgentRequest, AgentResponse, GraphWalkResponse, SearchResponse, SearchResultItem,
};
use std::path::{Path, PathBuf};
#[cfg(feature = "llm")]
use std::sync::Arc;
#[cfg(feature = "llm")]
const LLM_CLASSIFY_TIMEOUT_DEFAULT_MS: u64 = 8_000;
#[cfg(feature = "llm")]
fn llm_classify_timeout() -> std::time::Duration {
std::env::var("SEMANTEX_LLM_CLASSIFY_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map_or_else(
|| std::time::Duration::from_millis(LLM_CLASSIFY_TIMEOUT_DEFAULT_MS),
std::time::Duration::from_millis,
)
}
const ARCH_TINY_REPO_THRESHOLD: u64 = 500;
const FANOUT_BREADTH_MIN_CHUNKS: u64 = 5000;
pub(crate) struct HandlerResult {
formatted: String,
fallback_used: bool,
result_count: usize,
hits: Vec<crate::server::protocol::SearchResultItem>,
disambiguation: Option<Vec<crate::server::protocol::DisambigSuggestion>>,
}
#[derive(Clone, Copy)]
enum SearchVariant {
Semantic { is_fallback: bool },
Analytical { full_code: bool },
Exhaustive,
}
impl SearchVariant {
fn max_results(self) -> usize {
match self {
Self::Semantic { .. } => 10,
Self::Analytical { .. } => 5,
Self::Exhaustive => 20,
}
}
fn budget_multiplier(self) -> usize {
match self {
Self::Exhaustive => 3,
_ => 1,
}
}
fn code_only(self) -> bool {
matches!(self, Self::Analytical { .. })
}
fn use_dense(self) -> bool {
matches!(self, Self::Semantic { .. })
}
}
pub struct AgentPipeline<'a> {
searcher: &'a HybridSearcher,
project_root: PathBuf,
#[cfg(feature = "llm")]
pub(crate) llm: Option<Arc<dyn crate::llm::LlmCapability>>,
#[cfg(feature = "llm")]
runtime: Option<Arc<tokio::runtime::Runtime>>,
}
impl<'a> AgentPipeline<'a> {
pub fn new(searcher: &'a HybridSearcher, project_root: PathBuf) -> Self {
Self {
searcher,
project_root,
#[cfg(feature = "llm")]
llm: None,
#[cfg(feature = "llm")]
runtime: None,
}
}
#[cfg(feature = "llm")]
pub fn with_llm(mut self, llm: Option<Arc<dyn crate::llm::LlmCapability>>) -> Self {
self.llm = llm;
self
}
#[cfg(feature = "llm")]
pub fn with_runtime(mut self, rt: Option<Arc<tokio::runtime::Runtime>>) -> Self {
self.runtime = rt;
self
}
fn search_semantic(&self, query: &SearchQuery) -> anyhow::Result<crate::search::SearchOutput> {
#[cfg(feature = "llm")]
{
if let Some(llm) = self.llm.clone() {
if let Some(ref rt) = self.runtime {
return rt.block_on(self.searcher.search_with_hyde(query, llm));
}
tracing::warn!(
"AgentPipeline has an LLM but no shared runtime; \
building a per-call runtime for HyDE. \
Wire a runtime via with_runtime() to avoid this overhead."
);
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => {
return rt.block_on(self.searcher.search_with_hyde(query, llm));
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to build tokio runtime for HyDE; falling back to base search"
);
}
}
}
}
self.searcher.search(query)
}
pub fn handle(&self, request: &AgentRequest) -> AgentResponse {
self.handle_inner(request).0
}
pub fn handle_with_hits(
&self,
request: &AgentRequest,
) -> (
AgentResponse,
Vec<crate::server::protocol::SearchResultItem>,
) {
self.handle_inner(request)
}
fn handle_inner(
&self,
request: &AgentRequest,
) -> (
AgentResponse,
Vec<crate::server::protocol::SearchResultItem>,
) {
let start = std::time::Instant::now();
let classify_start = std::time::Instant::now();
let route = if let Some(r) = request.route {
r
} else {
self.classify_route_with_llm_fallback(&request.query)
};
let classify_us = classify_start.elapsed().as_micros() as u64;
let budget = request.budget.unwrap_or(DEFAULT_BUDGET);
let search_start = std::time::Instant::now();
let result = match route {
AgentRoute::FilePattern => self.handle_file_pattern(&request.query),
AgentRoute::Regex => self.handle_regex(&request.query, budget),
AgentRoute::ExactSymbol => self.handle_exact_symbol(&request.query, budget),
AgentRoute::Structural => self.handle_structural(&request.query, budget),
AgentRoute::Deep => self.handle_deep(&request.query, budget, false),
AgentRoute::Analytical => {
self.handle_analytical(&request.query, budget, request.full_code)
}
AgentRoute::Exhaustive => self.handle_exhaustive(&request.query, budget),
AgentRoute::Semantic => self.handle_semantic(&request.query, budget, false),
AgentRoute::Architecture => self.handle_architecture(&request.query, budget),
AgentRoute::FeaturePlanning => self.handle_feature_planning(&request.query, budget),
};
let search_ms = search_start.elapsed().as_millis() as u64;
let format_start = std::time::Instant::now();
let formatted = format!("[route: {route}]\n\n{}", result.formatted);
let format_ms = format_start.elapsed().as_millis() as u64;
let hits = result.hits;
let response = AgentResponse {
route,
formatted,
metrics: AgentMetrics {
classify_us,
search_ms,
format_ms,
total_ms: start.elapsed().as_millis() as u64,
fallback_used: result.fallback_used,
result_count: result.result_count,
},
disambiguation: result.disambiguation,
};
(response, hits)
}
#[allow(clippy::unused_self)]
fn classify_route_with_llm_fallback(&self, query: &str) -> AgentRoute {
#[cfg(feature = "llm")]
{
if let Some(ref llm) = self.llm {
let timeout = llm_classify_timeout();
let llm_ref = llm.as_ref();
if let Some(ref rt) = self.runtime {
let result = rt.block_on(async {
tokio::time::timeout(timeout, llm_ref.classify_route(query)).await
});
match result {
Ok(Ok(r)) => {
tracing::debug!(
model = llm.label(),
route = ?r,
"LLM classified route"
);
return r;
}
Ok(Err(e)) => {
tracing::warn!(
error = %e,
"LLM classifier failed, falling back to keyword"
);
}
Err(_timeout) => {
tracing::info!(
"LLM classifier timed out (>{}ms), falling back to keyword",
timeout.as_millis()
);
}
}
} else {
tracing::warn!(
"AgentPipeline has an LLM but no shared runtime; \
building a per-call runtime for classify. \
Wire a runtime via with_runtime() to avoid this overhead."
);
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => {
let result = rt.block_on(async {
tokio::time::timeout(timeout, llm_ref.classify_route(query)).await
});
match result {
Ok(Ok(r)) => {
tracing::debug!(
model = llm.label(),
route = ?r,
"LLM classified route (per-call runtime)"
);
return r;
}
Ok(Err(e)) => {
tracing::warn!(
error = %e,
"LLM classifier failed, falling back to keyword"
);
}
Err(_timeout) => {
tracing::info!(
"LLM classifier timed out (>{}ms), falling back to keyword",
timeout.as_millis()
);
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to build tokio runtime for LLM classify, falling back to keyword"
);
}
}
}
}
}
classify_agent_query(query)
}
fn handle_semantic(&self, query: &str, budget: usize, is_fallback: bool) -> HandlerResult {
self.handle_search(query, budget, SearchVariant::Semantic { is_fallback })
}
fn handle_deep(&self, query: &str, budget: usize, is_fallback: bool) -> HandlerResult {
match deep_search_module::deep_search(self.searcher, query, 20, true) {
Ok(result) if !result.answer.is_empty() => {
let total = result.sources.len();
let hits = if is_fallback {
Vec::new()
} else {
result
.sources
.iter()
.enumerate()
.map(|(rank, s)| deep_source_to_hit(s, rank, total))
.collect()
};
let resp = deep_result_to_response(result);
let count = resp.sources.len();
HandlerResult {
formatted: format_deep_results(&resp, budget),
fallback_used: is_fallback,
result_count: count,
hits,
disambiguation: None,
}
}
_ => {
if is_fallback {
HandlerResult {
formatted: format!("No results found for: {query}"),
fallback_used: true,
result_count: 0,
hits: Vec::new(),
disambiguation: None,
}
} else {
self.handle_semantic(query, budget, true)
}
}
}
}
fn handle_exact_symbol(&self, query: &str, budget: usize) -> HandlerResult {
let symbol = query.trim_matches(|c| c == '`' || c == '"' || c == '\'');
let sq = SearchQuery::new(symbol).max_results(5);
if let Ok(output) = self.searcher.search(&sq)
&& !output.results.is_empty()
{
let items: Vec<SearchResultItem> = output
.results
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
let confidence = compute_confidence(&items);
let disambig = disambiguation_from_results(&output.results);
let count = items.len();
let hits = items.clone();
let resp = SearchResponse {
results: items,
duration_ms: output.metrics.total_ms,
dense_count: output.metrics.dense_count,
sparse_count: output.metrics.sparse_count,
fused_count: output.metrics.fused_count,
metrics: None,
confidence: Some(confidence),
disambiguation: disambig.clone(),
};
let mut formatted = format_search_results(&resp, FormatStyle::Default, budget);
if let Some(suggestions) = &disambig {
append_disambiguation_block(&mut formatted, suggestions);
}
return HandlerResult {
formatted,
fallback_used: false,
result_count: count,
hits,
disambiguation: disambig,
};
}
let sq = SearchQuery::new(symbol).grep_mode().max_results(10);
match self.searcher.search(&sq) {
Ok(output) if !output.results.is_empty() => {
let items: Vec<SearchResultItem> = output
.results
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
let count = items.len();
let hits = items.clone();
let resp = SearchResponse {
results: items,
duration_ms: output.metrics.total_ms,
dense_count: 0,
sparse_count: output.metrics.sparse_count,
fused_count: output.metrics.fused_count,
metrics: None,
confidence: Some("medium".into()),
disambiguation: None,
};
HandlerResult {
formatted: format_search_results(&resp, FormatStyle::Default, budget),
fallback_used: true,
result_count: count,
hits,
disambiguation: None,
}
}
_ => HandlerResult {
formatted: format!("Symbol not found: {symbol}"),
fallback_used: true,
result_count: 0,
hits: Vec::new(),
disambiguation: None,
},
}
}
fn handle_structural(&self, query: &str, budget: usize) -> HandlerResult {
if let Some(symbol) = extract_symbol(query) {
let walk: AdaptiveGraphWalk = self.searcher.with_store(|store| {
graph_walk_from_store_adaptive(store, &symbol, AdaptiveOptions::default())
.unwrap_or_else(|_| AdaptiveGraphWalk {
response: GraphWalkResponse {
target: vec![],
callers: vec![],
callees: vec![],
type_refs: vec![],
hierarchy: vec![],
},
callers_summary: None,
callees_summary: None,
})
});
let resp = &walk.response;
let total = resp.target.len()
+ resp.callers.len()
+ resp.callees.len()
+ resp.type_refs.len()
+ resp.hierarchy.len()
+ usize::from(walk.callers_summary.is_some())
+ usize::from(walk.callees_summary.is_some());
if total > 0 {
let mut formatted = format_graph_results(resp);
if let Some(s) = &walk.callers_summary {
if !formatted.ends_with('\n') {
formatted.push('\n');
}
formatted.push_str("\nCallers (collapsed):\n ");
formatted.push_str(s);
formatted.push('\n');
}
if let Some(s) = &walk.callees_summary {
if !formatted.ends_with('\n') {
formatted.push('\n');
}
formatted.push_str("\nCallees (collapsed):\n ");
formatted.push_str(s);
formatted.push('\n');
}
let hits: Vec<SearchResultItem> = resp
.target
.iter()
.chain(resp.callers.iter())
.chain(resp.callees.iter())
.chain(resp.type_refs.iter())
.chain(resp.hierarchy.iter())
.cloned()
.collect();
return HandlerResult {
formatted,
fallback_used: false,
result_count: total,
hits,
disambiguation: None,
};
}
}
self.handle_deep(query, budget, true)
}
fn handle_analytical(&self, query: &str, budget: usize, full_code: bool) -> HandlerResult {
self.handle_search(query, budget, SearchVariant::Analytical { full_code })
}
fn handle_exhaustive(&self, query: &str, budget: usize) -> HandlerResult {
if !exhaustive_fanout_enabled() {
return self.handle_search(query, budget, SearchVariant::Exhaustive);
}
self.handle_exhaustive_fanout(query, budget)
}
fn handle_exhaustive_fanout(&self, query: &str, budget: usize) -> HandlerResult {
let facets = enumeration_facets(query);
if facets.len() <= 1 {
return self.handle_search(query, budget, SearchVariant::Exhaustive);
}
let chunk_count = self.searcher.with_store(|s| s.chunk_count().unwrap_or(0));
let full_breadth = fanout_full_breadth(chunk_count, fanout_breadth_threshold());
let mut all_facet_results: Vec<Vec<crate::types::SearchResult>> = Vec::new();
for facet in &facets {
let base = SearchQuery::new(facet).max_results(20);
let sq = if full_breadth {
base.disable_adaptive()
} else {
base
};
if let Ok(output) = self.searcher.search(&sq) {
all_facet_results.push(output.results);
}
}
let merged = union_diversify(all_facet_results, 3, 40);
if merged.is_empty() {
return self.handle_search(query, budget, SearchVariant::Exhaustive);
}
let effective_budget = budget * SearchVariant::Exhaustive.budget_multiplier();
let items: Vec<SearchResultItem> = merged
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
let disambig = disambiguation_from_results(&merged);
let confidence = compute_confidence(&items);
let count = items.len();
let hits = items.clone();
let resp = SearchResponse {
results: items,
duration_ms: 0,
dense_count: 0,
sparse_count: 0,
fused_count: merged.len(),
metrics: None,
confidence: Some(confidence),
disambiguation: disambig.clone(),
};
let mut formatted = format_search_results(&resp, FormatStyle::Default, effective_budget);
if let Some(suggestions) = &disambig {
append_disambiguation_block(&mut formatted, suggestions);
}
HandlerResult {
formatted,
fallback_used: false,
result_count: count,
hits,
disambiguation: disambig,
}
}
fn handle_search(&self, query: &str, budget: usize, variant: SearchVariant) -> HandlerResult {
let effective_budget = budget * variant.budget_multiplier();
let sq = {
let base = SearchQuery::new(query).max_results(variant.max_results());
if variant.code_only() {
base.code_only()
} else {
base
}
};
let search_result = if variant.use_dense() {
self.search_semantic(&sq)
} else {
self.searcher.search(&sq)
};
match search_result {
Ok(output) if !output.results.is_empty() => {
let items: Vec<SearchResultItem> = output
.results
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
let disambig = disambiguation_from_results(&output.results);
let count = items.len();
let hits = items.clone();
if let SearchVariant::Analytical { full_code: true } = variant {
let code_contents: Vec<String> = items
.iter()
.map(|i| i.content.clone().unwrap_or_default())
.collect();
let mut formatted =
format_code_blocks(&items, &code_contents, effective_budget);
if formatted == "No code blocks to display." {
return self.handle_deep(query, effective_budget, true);
}
if let Some(suggestions) = &disambig {
append_disambiguation_block(&mut formatted, suggestions);
}
return HandlerResult {
formatted,
fallback_used: false,
result_count: count,
hits,
disambiguation: disambig,
};
}
let fallback_used_on_hit = match variant {
SearchVariant::Semantic { is_fallback } => is_fallback,
_ => false,
};
let confidence = compute_confidence(&items);
let resp = SearchResponse {
results: items,
duration_ms: output.metrics.total_ms,
dense_count: output.metrics.dense_count,
sparse_count: output.metrics.sparse_count,
fused_count: output.metrics.fused_count,
metrics: None,
confidence: Some(confidence),
disambiguation: disambig.clone(),
};
let mut formatted =
format_search_results(&resp, FormatStyle::Default, effective_budget);
if let Some(suggestions) = &disambig {
append_disambiguation_block(&mut formatted, suggestions);
}
HandlerResult {
formatted,
fallback_used: fallback_used_on_hit,
result_count: count,
hits,
disambiguation: disambig,
}
}
_ => {
if let SearchVariant::Semantic { is_fallback } = variant {
if is_fallback {
return HandlerResult {
formatted: format!("No results found for: {query}"),
fallback_used: true,
result_count: 0,
hits: Vec::new(),
disambiguation: None,
};
}
match deep_search_module::deep_search(self.searcher, query, 20, true) {
Ok(result) if !result.answer.is_empty() => {
let resp = deep_result_to_response(result);
let count = resp.sources.len();
return HandlerResult {
formatted: format_deep_results(&resp, effective_budget),
fallback_used: true,
result_count: count,
hits: Vec::new(),
disambiguation: None,
};
}
_ => {
return HandlerResult {
formatted: format!("No results found for: {query}"),
fallback_used: false,
result_count: 0,
hits: Vec::new(),
disambiguation: None,
};
}
}
}
self.handle_deep(query, effective_budget, true)
}
}
}
fn handle_regex(&self, query: &str, budget: usize) -> HandlerResult {
let sq = SearchQuery::new(query).grep_mode().max_results(20);
let (items, duration_ms, sparse_count, fused_count) = match self.searcher.search(&sq) {
Ok(output) => {
let items: Vec<SearchResultItem> = output
.results
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
(
items,
output.metrics.total_ms,
output.metrics.sparse_count,
output.metrics.fused_count,
)
}
Err(_) => (vec![], 0u64, 0usize, 0usize),
};
let hits = items.clone();
let resp = SearchResponse {
results: items.clone(),
duration_ms,
dense_count: 0,
sparse_count,
fused_count,
metrics: None,
confidence: None,
disambiguation: None,
};
HandlerResult {
formatted: format_search_results(&resp, FormatStyle::Grep, budget),
fallback_used: false,
result_count: items.len(),
hits,
disambiguation: None,
}
}
fn handle_file_pattern(&self, query: &str) -> HandlerResult {
glob_files(&self.project_root, query)
}
fn handle_architecture(&self, query: &str, budget: usize) -> HandlerResult {
use crate::index::architecture::build_arch_overview;
let db_path = self.project_root.join(".semantex").join("chunks.db");
let chunk_count = self.searcher.with_store(|s| s.chunk_count().unwrap_or(0));
let mut arch_budget = budget_for_chunk_count(chunk_count as usize);
if chunk_count < ARCH_TINY_REPO_THRESHOLD {
arch_budget.god_nodes = arch_budget.god_nodes.min(5);
arch_budget.communities = 0;
arch_budget.boundaries = 0;
}
let Ok(overview) = self
.searcher
.with_store(|store| build_arch_overview(store, &db_path, Some(arch_budget)))
else {
return self.handle_deep(query, budget, true);
};
if overview.god_nodes.is_empty()
&& overview.communities.is_empty()
&& overview.boundaries.is_empty()
{
return self.handle_deep(query, budget, true);
}
let mut out = String::with_capacity(2048);
let _ = std::fmt::Write::write_fmt(&mut out, format_args!("# Architectural overview\n\n"));
if !overview.god_nodes.is_empty() {
out.push_str("## Top central symbols (PageRank-ranked)\n\n");
for (i, g) in overview.god_nodes.iter().enumerate() {
let role = g
.semantic_role
.as_deref()
.map(|r| format!(" [{r}]"))
.unwrap_or_default();
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!(
"{:>2}. `{}` ({} L{}-{}){} — centrality {:.4}\n",
i + 1,
g.symbol,
g.file,
g.start_line,
g.end_line,
role,
g.centrality
),
);
}
out.push('\n');
}
if !overview.communities.is_empty() {
out.push_str("## Communities (clusters via call-graph BFS)\n\n");
for c in &overview.communities {
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("### {} — {} members\n", c.label, c.size),
);
if !c.entry_points.is_empty() {
out.push_str("Entry points: ");
let mut first = true;
for ep in &c.entry_points {
if !first {
out.push_str(", ");
}
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!(
"`{}` ({}:{}-{})",
ep.symbol, ep.file, ep.start_line, ep.end_line
),
);
first = false;
}
out.push_str("\n\n");
}
if !c.member_files.is_empty() {
out.push_str("Sample files:\n");
for f in &c.member_files {
let _ = std::fmt::Write::write_fmt(&mut out, format_args!(" - {f}\n"));
}
out.push('\n');
}
}
}
if !overview.boundaries.is_empty() {
out.push_str("## Cross-directory coupling (top import-edge counts)\n\n");
for b in &overview.boundaries {
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("- `{}` → `{}` — {} edges\n", b.from, b.to, b.edge_count),
);
}
out.push('\n');
}
if out.len() > budget * 3 {
let cut = out.floor_char_boundary(budget * 3);
out.truncate(cut);
out.push_str("\n[truncated to fit response budget]\n");
}
let total =
overview.god_nodes.len() + overview.communities.len() + overview.boundaries.len();
HandlerResult {
formatted: out,
fallback_used: false,
result_count: total,
hits: Vec::new(),
disambiguation: None,
}
}
fn handle_feature_planning(&self, query: &str, budget: usize) -> HandlerResult {
use super::planner::{Plan, PlanStepKind};
let Ok(plan) = Plan::new(query, super::agent_classifier::AgentRoute::FeaturePlanning)
else {
return self.handle_deep(query, budget, true);
};
let runner = |step: &super::planner::PlanStep,
_remaining: std::time::Duration|
-> anyhow::Result<String> {
match step.kind {
PlanStepKind::Architecture => {
Ok(self.handle_architecture(&step.query, budget).formatted)
}
PlanStepKind::ConventionLookup
| PlanStepKind::ImpactedFiles
| PlanStepKind::ExampleSites
| PlanStepKind::Synthesize => {
let sq = SearchQuery::new(&step.query).max_results(8);
match self.searcher.search(&sq) {
Ok(output) if !output.results.is_empty() => {
let items: Vec<SearchResultItem> = output
.results
.iter()
.map(|r| search_result_to_item(r, true))
.collect();
let resp = SearchResponse {
results: items,
duration_ms: output.metrics.total_ms,
dense_count: output.metrics.dense_count,
sparse_count: output.metrics.sparse_count,
fused_count: output.metrics.fused_count,
metrics: None,
confidence: Some("medium".into()),
disambiguation: None,
};
Ok(format_search_results(&resp, FormatStyle::Default, budget))
}
_ => Ok(String::new()),
}
}
}
};
match plan.execute(runner) {
Ok(result) => HandlerResult {
formatted: result.merged,
fallback_used: false,
result_count: result
.per_step
.iter()
.filter(|s| !s.output.is_empty())
.count(),
hits: Vec::new(),
disambiguation: None,
},
Err(_) => self.handle_deep(query, budget, true),
}
}
}
fn deep_source_to_hit(
source: &crate::search::deep::DeepSource,
rank: usize,
total: usize,
) -> crate::server::protocol::SearchResultItem {
let score = if total == 0 {
0.0
} else {
1.0 - (rank as f32 / total as f32) * 0.5
};
crate::server::protocol::SearchResultItem {
file: source.file.clone(),
start_line: source.start_line,
end_line: source.end_line,
score,
source: "deep".to_string(),
chunk_type: source.kind.clone().unwrap_or_default(),
name: source.name.clone(),
language: None,
content: if source.content.is_empty() {
None
} else {
Some(source.content.clone())
},
kind: source.kind.clone(),
summary: None,
}
}
const DISAMBIG_MAX_SUGGESTIONS: usize = 3;
pub(crate) fn disambiguation_from_results(
results: &[crate::types::SearchResult],
) -> Option<Vec<crate::server::protocol::DisambigSuggestion>> {
use crate::server::protocol::DisambigSuggestion;
use crate::types::Confidence;
let top = results.first()?;
if top.confidence != Confidence::Ambiguous {
return None;
}
let mut seen_names: Vec<String> = Vec::with_capacity(DISAMBIG_MAX_SUGGESTIONS + 1);
if let Some(name) = top.chunk.symbol_name() {
seen_names.push(name.to_string());
}
let mut out: Vec<DisambigSuggestion> = Vec::with_capacity(DISAMBIG_MAX_SUGGESTIONS);
for r in results.iter().skip(1) {
if out.len() >= DISAMBIG_MAX_SUGGESTIONS {
break;
}
let Some(name) = r.chunk.symbol_name() else {
continue;
};
if seen_names.iter().any(|n| n == name) {
continue;
}
seen_names.push(name.to_string());
out.push(DisambigSuggestion {
name: name.to_string(),
path: r.chunk.file_path.display().to_string(),
line: r.chunk.start_line as usize,
});
}
if out.is_empty() { None } else { Some(out) }
}
pub(crate) fn glob_files(root: &Path, pattern: &str) -> HandlerResult {
use ignore::WalkBuilder;
let matcher = globset::GlobBuilder::new(pattern)
.case_insensitive(false)
.build()
.and_then(|g| {
let mut b = globset::GlobSetBuilder::new();
b.add(g);
b.build()
});
let Ok(glob_set) = matcher else {
return HandlerResult {
formatted: format!("Invalid glob pattern: {pattern}"),
fallback_used: false,
result_count: 0,
hits: Vec::new(),
disambiguation: None,
};
};
let mut files: Vec<String> = Vec::new();
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(false)
.build();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let should_skip = path.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some(".git" | ".semantex" | "node_modules" | "target")
)
});
if should_skip {
continue;
}
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
if glob_set.is_match(rel) {
files.push(rel.to_string_lossy().replace('\\', "/"));
}
}
files.sort();
let total = files.len();
let hits: Vec<crate::server::protocol::SearchResultItem> = files
.iter()
.take(50)
.map(|f| crate::server::protocol::SearchResultItem {
file: f.clone(),
start_line: 0,
end_line: 0,
score: 0.0,
source: "FilePattern".to_string(),
chunk_type: "File".to_string(),
name: None,
language: None,
content: None,
kind: None,
summary: None,
})
.collect();
let mut lines: Vec<String> = files.into_iter().take(50).collect();
if total > 50 {
lines.push(format!("... and {} more files", total - 50));
}
HandlerResult {
formatted: lines.join("\n"),
fallback_used: false,
result_count: total,
hits,
disambiguation: None,
}
}
fn enumeration_facets(query: &str) -> Vec<String> {
const SENTINEL: &str = "\u{0}";
const STOP: &[&str] = &[
"the",
"a",
"an",
"this",
"that",
"these",
"those",
"it",
"its",
"they",
"them",
"their",
"there",
"where",
"when",
"what",
"which",
"who",
"is",
"are",
"was",
"were",
"be",
"been",
"does",
"do",
"did",
"done",
"project",
"repo",
"repository",
"codebase",
"supports",
"supported",
"support",
"defined",
"define",
"defines",
"exist",
"exists",
"existing",
"used",
"use",
"uses",
"using",
"in",
"on",
"of",
"for",
"to",
"and",
"or",
"all",
"every",
"any",
"each",
"with",
"that's",
"here",
];
const MARKERS: &[&str] = &[
"list all",
"list every",
"find all",
"find every",
"show all",
"show every",
"what are all",
"where are all",
"enumerate all",
"enumerate every",
"enumerate ",
];
let lower = query.to_lowercase();
let mut best: Option<(usize, usize)> = None; for m in MARKERS {
if let Some(pos) = lower.find(m) {
match best {
Some((bp, _)) if pos >= bp => {}
_ => best = Some((pos, m.len())),
}
}
}
let remainder: &str = match best {
Some((pos, len)) => &lower[pos + len..],
None => &lower,
};
let mut work = remainder.to_string();
for delim in [", and ", ", or ", " and ", " or ", "; ", ", ", ";", ","] {
work = work.replace(delim, SENTINEL);
}
let mut fragments: Vec<String> = Vec::new();
for raw in work.split(SENTINEL) {
let mut frag = raw.trim();
if let Some(rest) = frag.strip_prefix("and ") {
frag = rest;
} else if let Some(rest) = frag.strip_prefix("or ") {
frag = rest;
}
let frag = frag.trim().trim_end_matches('.').trim();
if frag.len() < 3 {
continue;
}
let all_stop = frag.split_whitespace().all(|w| {
let cleaned: String = w
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
cleaned.is_empty() || STOP.contains(&cleaned.as_str())
});
if all_stop {
continue;
}
fragments.push(frag.to_string());
}
let mut facets: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for frag in fragments {
let key = frag.to_lowercase();
if seen.insert(key) {
facets.push(frag);
if facets.len() == 6 {
break;
}
}
}
let query_lower = query.to_lowercase();
if !facets.iter().any(|f| f.to_lowercase() == query_lower) {
facets.push(query.to_string());
}
if facets.len() == 1 {
return vec![query.to_string()];
}
facets
}
fn union_diversify(
result_lists: Vec<Vec<crate::types::SearchResult>>,
per_file_cap: usize,
total_cap: usize,
) -> Vec<crate::types::SearchResult> {
use std::collections::HashMap;
use std::path::PathBuf;
let mut best: HashMap<(PathBuf, u32), crate::types::SearchResult> = HashMap::new();
for list in result_lists {
for r in list {
let key = (r.chunk.file_path.clone(), r.chunk.start_line);
match best.get(&key) {
Some(existing) if existing.score >= r.score => {}
_ => {
best.insert(key, r);
}
}
}
}
let mut deduped: Vec<crate::types::SearchResult> = best.into_values().collect();
deduped.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.chunk.file_path.cmp(&b.chunk.file_path))
.then_with(|| a.chunk.start_line.cmp(&b.chunk.start_line))
});
let mut per_file: HashMap<PathBuf, usize> = HashMap::new();
let mut admitted: Vec<crate::types::SearchResult> =
Vec::with_capacity(total_cap.min(deduped.len()));
for r in deduped {
if admitted.len() >= total_cap {
break;
}
let count = per_file.entry(r.chunk.file_path.clone()).or_insert(0);
if *count >= per_file_cap {
continue;
}
*count += 1;
admitted.push(r);
}
admitted
}
fn fanout_flag_from(value: Option<&str>) -> bool {
match value {
Some(v) => !(v == "0" || v.eq_ignore_ascii_case("false")),
None => true,
}
}
fn exhaustive_fanout_enabled() -> bool {
fanout_flag_from(std::env::var("SEMANTEX_EXHAUSTIVE_FANOUT").ok().as_deref())
}
fn fanout_full_breadth(chunk_count: u64, threshold: u64) -> bool {
chunk_count >= threshold
}
fn fanout_breadth_threshold() -> u64 {
std::env::var("SEMANTEX_FANOUT_BREADTH_MIN_CHUNKS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(FANOUT_BREADTH_MIN_CHUNKS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SemantexConfig;
use crate::index::storage::ChunkStore;
use crate::search::hybrid::HybridSearcher;
use crate::search::sparse_search::SparseIndex;
use crate::types::{AstNodeKind, Chunk, ChunkType};
use tempfile::TempDir;
fn build_populated_searcher() -> (TempDir, HybridSearcher) {
let dir = TempDir::new().expect("tempdir");
let semantex_dir = dir.path().join(".semantex");
std::fs::create_dir_all(&semantex_dir).unwrap();
let db_path = semantex_dir.join("chunks.db");
let chunk_ids: Vec<u64> = {
let store = ChunkStore::open(&db_path).expect("create chunk store");
let chunks = [
(
"auth/login.rs",
"authenticateUser",
"fn authenticateUser(creds: Credentials) -> Session { /* login */ }",
),
(
"auth/logout.rs",
"endSession",
"fn endSession(session: Session) { /* logout */ }",
),
];
chunks
.iter()
.map(|(file, name, content)| {
let chunk = Chunk {
id: 0,
file_path: std::path::PathBuf::from(file),
start_line: 1,
end_line: 3,
content: (*content).to_string(),
chunk_type: ChunkType::AstNode {
name: (*name).to_string(),
kind: AstNodeKind::Function,
language: "rust".into(),
structured_meta: None,
},
};
store.insert_chunk(&chunk, 0, 0).expect("insert chunk")
})
.collect()
};
{
let index = SparseIndex::create(&semantex_dir.join("sparse"), true).expect("create");
let mut writer = index.writer().expect("writer");
writer
.add_document(
chunk_ids[0],
"fn authenticateUser creds Credentials Session login authentication",
"auth/login.rs",
)
.expect("add 0");
writer
.add_document(
chunk_ids[1],
"fn endSession session Session logout authentication",
"auth/logout.rs",
)
.expect("add 1");
writer.commit().expect("commit");
}
let config = SemantexConfig::default();
let searcher = HybridSearcher::open_sparse_only(&semantex_dir, &config)
.expect("open sparse-only searcher");
(dir, searcher)
}
#[test]
fn handle_with_hits_returns_structured_hits_for_item_route() {
let (_dir, searcher) = build_populated_searcher();
let pipeline = AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/a2-hits-test"));
let (resp, hits) = pipeline.handle_with_hits(&AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Semantic),
budget: Some(12_000),
full_code: false,
});
assert!(!resp.formatted.is_empty());
assert!(
!hits.is_empty(),
"Semantic route must surface structured hits"
);
assert!(hits.iter().all(|h| !h.file.is_empty()));
}
#[test]
fn handle_with_hits_returns_hits_for_direct_deep_route() {
let (_dir, searcher) = build_populated_searcher();
let pipeline =
AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/deep-hits-test"));
let (resp, hits) = pipeline.handle_with_hits(&AgentRequest {
query: "how does authentication work".into(),
route: Some(AgentRoute::Deep),
budget: Some(12_000),
full_code: false,
});
assert_eq!(resp.route, AgentRoute::Deep);
assert!(!resp.formatted.is_empty());
assert!(
!hits.is_empty(),
"a directly-requested Deep route must yield structured hits \
(include_history depends on this)"
);
assert!(hits.iter().all(|h| !h.file.is_empty()));
}
#[test]
fn deep_source_to_hit_maps_provenance_fields() {
let source = crate::search::deep::DeepSource {
file: "auth/login.rs".to_string(),
start_line: 3,
end_line: 17,
name: Some("authenticateUser".to_string()),
kind: Some("function".to_string()),
content: "fn authenticateUser() {}".to_string(),
also_matched_via: Vec::new(),
};
let hit = deep_source_to_hit(&source, 0, 4);
assert_eq!(hit.file, "auth/login.rs");
assert_eq!(hit.start_line, 3);
assert_eq!(hit.end_line, 17);
assert_eq!(hit.source, "deep");
assert_eq!(hit.name.as_deref(), Some("authenticateUser"));
assert_eq!(hit.kind.as_deref(), Some("function"));
assert_eq!(hit.chunk_type, "function");
assert_eq!(hit.content.as_deref(), Some("fn authenticateUser() {}"));
assert!(hit.summary.is_none());
}
#[test]
fn deep_source_to_hit_score_descends_by_rank_never_hits_zero() {
let source = crate::search::deep::DeepSource {
file: "a.rs".to_string(),
start_line: 1,
end_line: 2,
name: None,
kind: None,
content: "fn a() {}".to_string(),
also_matched_via: Vec::new(),
};
let first = deep_source_to_hit(&source, 0, 4);
let last = deep_source_to_hit(&source, 3, 4);
assert!(first.score > last.score);
assert!(last.score > 0.0);
}
#[test]
fn deep_source_to_hit_empty_content_maps_to_none() {
let source = crate::search::deep::DeepSource {
file: "a.rs".to_string(),
start_line: 1,
end_line: 2,
name: None,
kind: None,
content: String::new(),
also_matched_via: Vec::new(),
};
let hit = deep_source_to_hit(&source, 0, 1);
assert!(hit.content.is_none());
}
#[test]
fn disable_adaptive_returns_at_least_as_many_results() {
let (_dir, searcher) = build_populated_searcher();
let base = searcher
.search(&SearchQuery::new("authentication").max_results(20))
.expect("base search");
let wide = searcher
.search(
&SearchQuery::new("authentication")
.max_results(20)
.disable_adaptive(),
)
.expect("disable_adaptive search");
assert!(
wide.results.len() >= base.results.len(),
"disable_adaptive must not return fewer results than adaptive \
(wide={}, base={})",
wide.results.len(),
base.results.len()
);
}
#[test]
fn handle_agent_hits_returns_structured_hits_response() {
use crate::server::handler::Handler;
use crate::server::protocol::{Request, Response};
use std::sync::atomic::AtomicU64;
let (_dir, searcher) = build_populated_searcher();
let count = AtomicU64::new(0);
let handler = Handler::new(
&searcher,
&count,
std::path::PathBuf::from("/tmp/agent-hits-handler"),
);
let resp = handler.handle(
Request::AgentHits(AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Semantic),
budget: Some(12_000),
full_code: false,
}),
std::time::Instant::now(),
);
let Response::AgentHits(ah) = resp else {
panic!("expected Response::AgentHits");
};
assert_eq!(ah.route, AgentRoute::Semantic);
assert!(!ah.hits.is_empty(), "forced semantic route must yield hits");
assert!(ah.hits.iter().all(|h| !h.file.is_empty()));
}
#[test]
fn handle_matches_handle_with_hits_response() {
let (_dir, searcher) = build_populated_searcher();
let pipeline = AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/a2-parity"));
let req = AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Semantic),
budget: Some(12_000),
full_code: false,
};
let wire = pipeline.handle(&req);
let (with_hits, _hits) = pipeline.handle_with_hits(&req);
assert_eq!(wire.route, with_hits.route);
assert!(wire.formatted.starts_with("[route: semantic]"));
assert!(with_hits.formatted.starts_with("[route: semantic]"));
assert_eq!(wire.metrics.result_count, with_hits.metrics.result_count);
assert_eq!(wire.metrics.fallback_used, with_hits.metrics.fallback_used);
assert_eq!(wire.disambiguation, with_hits.disambiguation);
}
#[test]
fn test_dispatch_routes_file_pattern() {
use super::super::agent_classifier::{AgentRoute, classify_agent_query};
assert_eq!(classify_agent_query("*.rs"), AgentRoute::FilePattern);
}
#[test]
fn test_handle_file_pattern_finds_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("foo.rs"), "").unwrap();
std::fs::write(dir.path().join("bar.rs"), "").unwrap();
std::fs::write(dir.path().join("baz.py"), "").unwrap();
let result = glob_files(dir.path(), "*.rs");
assert!(result.formatted.contains("foo.rs"));
assert!(result.formatted.contains("bar.rs"));
assert!(!result.formatted.contains("baz.py"));
assert_eq!(result.result_count, 2);
}
#[test]
fn file_pattern_route_surfaces_repo_relative_hits() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("foo.rs"), "").unwrap();
std::fs::write(dir.path().join("sub/bar.rs"), "").unwrap();
std::fs::write(dir.path().join("baz.py"), "").unwrap();
let result = glob_files(dir.path(), "**/*.rs");
let files: Vec<&str> = result.hits.iter().map(|h| h.file.as_str()).collect();
assert!(files.contains(&"foo.rs"), "files: {files:?}");
assert!(files.contains(&"sub/bar.rs"), "files: {files:?}");
assert!(!files.iter().any(|f| f.contains("baz.py")));
assert!(
result.hits.iter().all(|h| !h.file.starts_with('/')),
"hits must be repo-relative, got {files:?}"
);
}
#[test]
fn test_handle_file_pattern_excludes_git() {
let dir = tempfile::tempdir().unwrap();
let git = dir.path().join(".git");
std::fs::create_dir(&git).unwrap();
std::fs::write(git.join("config"), "").unwrap();
std::fs::write(dir.path().join("main.rs"), "").unwrap();
let result = glob_files(dir.path(), "*");
assert!(result.formatted.contains("main.rs"));
assert!(!result.formatted.contains(".git"));
}
#[test]
fn test_handle_file_pattern_caps_at_50() {
let dir = tempfile::tempdir().unwrap();
for i in 0..60 {
std::fs::write(dir.path().join(format!("file{i:03}.rs")), "").unwrap();
}
let result = glob_files(dir.path(), "*.rs");
assert!(result.formatted.contains("... and 10 more files"));
assert_eq!(result.result_count, 60);
}
#[test]
fn search_variant_config_is_locked() {
let sem = SearchVariant::Semantic { is_fallback: false };
assert_eq!(sem.max_results(), 10, "Semantic max_results");
assert_eq!(sem.budget_multiplier(), 1, "Semantic budget_multiplier");
assert!(!sem.code_only(), "Semantic must NOT set code_only");
assert!(sem.use_dense(), "Semantic must use dense (HyDE) path");
let sem_fb = SearchVariant::Semantic { is_fallback: true };
assert!(
sem_fb.use_dense(),
"Semantic fallback must still use dense path"
);
let ana = SearchVariant::Analytical { full_code: false };
assert_eq!(ana.max_results(), 5, "Analytical max_results");
assert_eq!(ana.budget_multiplier(), 1, "Analytical budget_multiplier");
assert!(ana.code_only(), "Analytical must set code_only");
assert!(!ana.use_dense(), "Analytical must use hybrid path");
let ana_fc = SearchVariant::Analytical { full_code: true };
assert_eq!(ana_fc.max_results(), 5);
assert!(ana_fc.code_only());
let exh = SearchVariant::Exhaustive;
assert_eq!(exh.max_results(), 20, "Exhaustive max_results");
assert_eq!(
exh.budget_multiplier(),
3,
"Exhaustive budget_multiplier (×3)"
);
assert!(!exh.code_only(), "Exhaustive must NOT set code_only");
assert!(!exh.use_dense(), "Exhaustive must use hybrid path");
}
#[test]
fn exhaustive_route_uses_budget_multiplier_and_max20() {
let (_dir, searcher) = build_populated_searcher();
let pipeline =
AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/variant-exh-test"));
let (resp, _hits) = pipeline.handle_with_hits(&AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Exhaustive),
budget: Some(12_000),
full_code: false,
});
assert!(resp.formatted.contains("[route: exhaustive]"));
assert!(
resp.metrics.result_count <= 20,
"Exhaustive result_count {} exceeds max 20",
resp.metrics.result_count
);
}
#[test]
fn analytical_route_uses_max5_and_code_only() {
let (_dir, searcher) = build_populated_searcher();
let pipeline =
AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/variant-ana-test"));
let (resp, _hits) = pipeline.handle_with_hits(&AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Analytical),
budget: Some(12_000),
full_code: false,
});
assert!(resp.formatted.contains("[route: analytical]"));
assert!(
resp.metrics.result_count <= 5,
"Analytical result_count {} exceeds max 5",
resp.metrics.result_count
);
}
#[test]
fn semantic_route_emits_correct_route_header() {
let (_dir, searcher) = build_populated_searcher();
let pipeline =
AgentPipeline::new(&searcher, std::path::PathBuf::from("/tmp/variant-sem-test"));
let (resp, _hits) = pipeline.handle_with_hits(&AgentRequest {
query: "authentication".into(),
route: Some(AgentRoute::Semantic),
budget: Some(12_000),
full_code: false,
});
assert!(
resp.formatted.starts_with("[route: semantic]"),
"Semantic must emit [route: semantic] header, got: {}",
&resp.formatted[..resp.formatted.len().min(80)]
);
}
#[test]
fn enumeration_facets_splits_multi_item() {
let q = "List all configuration options, environment variables, and CLI flags this project supports and where they are defined.";
let facets = enumeration_facets(q);
assert!(
facets
.iter()
.any(|f| f.to_lowercase().contains("configuration options")),
"missing 'configuration options' facet: {facets:?}"
);
assert!(
facets
.iter()
.any(|f| f.to_lowercase().contains("environment variables")),
"missing 'environment variables' facet: {facets:?}"
);
assert!(
facets
.iter()
.any(|f| f.to_lowercase().contains("cli flags")),
"missing 'cli flags' facet: {facets:?}"
);
assert!(
facets.iter().any(|f| f == q),
"missing exact original query facet: {facets:?}"
);
}
#[test]
fn enumeration_facets_drops_stopword_fragments() {
let q = "List all configuration options, environment variables, and CLI flags this project supports and where they are defined.";
let facets = enumeration_facets(q);
assert!(
!facets
.iter()
.any(|f| f.trim().to_lowercase() == "where they are defined"),
"all-stopword fragment was not dropped: {facets:?}"
);
}
#[test]
fn enumeration_facets_single_concept_passthrough() {
let q = "list all environment variables";
let facets = enumeration_facets(q);
assert!(facets.len() <= 2, "too many facets: {facets:?}");
assert!(
facets
.iter()
.all(|f| f.to_lowercase().contains("environment variables")),
"junk fragment present: {facets:?}"
);
}
#[test]
fn enumeration_facets_caps() {
let q = "find all a1, b2, c3, d4, e5, f6, g7, h8";
let facets = enumeration_facets(q);
assert!(facets.len() <= 7, "facets not capped: {facets:?}");
}
#[test]
fn enumeration_facets_no_marker_returns_query() {
let q = "how does auth work";
let facets = enumeration_facets(q);
assert_eq!(facets, vec!["how does auth work".to_string()]);
}
#[test]
fn enumeration_facets_unicode_no_panic() {
let f1 = enumeration_facets("İlist allé, foo");
assert!(!f1.is_empty(), "unicode marker query produced empty facets");
let f2 = enumeration_facets("list all café options, naïve config");
assert!(
!f2.is_empty(),
"unicode multi-item query produced empty facets"
);
}
fn mk_result(file: &str, line: u32, score: f32) -> crate::types::SearchResult {
crate::types::SearchResult {
chunk: Chunk {
id: 0,
file_path: std::path::PathBuf::from(file),
start_line: line,
end_line: line + 10,
content: format!("// {file}:{line}"),
chunk_type: ChunkType::AstNode {
name: format!("sym_{line}"),
kind: AstNodeKind::Function,
language: "rust".into(),
structured_meta: None,
},
},
score,
source: crate::types::SearchSource::Hybrid,
score_dense: 0.0,
score_sparse: 0.0,
score_exact: 0.0,
confidence: crate::types::Confidence::Inferred,
confidence_score: 0.5,
}
}
#[test]
fn union_diversify_dedups_by_file_line_keeping_higher_score() {
let list_a = vec![mk_result("a.rs", 10, 0.50), mk_result("b.rs", 20, 0.90)];
let list_b = vec![mk_result("a.rs", 10, 0.80), mk_result("c.rs", 30, 0.70)];
let merged = union_diversify(vec![list_a, list_b], 3, 40);
let a10: Vec<&crate::types::SearchResult> = merged
.iter()
.filter(|r| {
r.chunk.file_path == std::path::Path::new("a.rs") && r.chunk.start_line == 10
})
.collect();
assert_eq!(
a10.len(),
1,
"duplicate (file,line) not deduped: {merged:?}"
);
assert!(
(a10[0].score - 0.80).abs() < 1e-6,
"dedup must keep higher score, got {}",
a10[0].score
);
assert_eq!(merged.len(), 3, "expected 3 unique results: {merged:?}");
let scores: Vec<f32> = merged.iter().map(|r| r.score).collect();
assert!(
scores.windows(2).all(|w| w[0] >= w[1]),
"results not sorted by score desc: {scores:?}"
);
assert!((scores[0] - 0.90).abs() < 1e-6);
assert!((scores[1] - 0.80).abs() < 1e-6);
assert!((scores[2] - 0.70).abs() < 1e-6);
}
#[test]
fn union_diversify_caps_per_file() {
let list = vec![
mk_result("same.rs", 1, 0.99),
mk_result("same.rs", 2, 0.98),
mk_result("same.rs", 3, 0.97),
mk_result("same.rs", 4, 0.96),
mk_result("same.rs", 5, 0.95),
mk_result("same.rs", 6, 0.94),
];
let merged = union_diversify(vec![list], 2, 40);
let from_same = merged
.iter()
.filter(|r| r.chunk.file_path == std::path::Path::new("same.rs"))
.count();
assert_eq!(from_same, 2, "per_file_cap not enforced: {merged:?}");
assert_eq!(merged.len(), 2);
}
#[test]
fn union_diversify_caps_total() {
let list: Vec<crate::types::SearchResult> = (0..30)
.map(|i| mk_result(&format!("f{i}.rs"), 1, 1.0 - (i as f32) * 0.01))
.collect();
let merged = union_diversify(vec![list], 3, 5);
assert!(
merged.len() <= 5,
"total_cap not enforced: {} > 5",
merged.len()
);
assert_eq!(merged.len(), 5);
}
#[test]
fn fanout_full_breadth_gates_on_corpus_size() {
assert!(
!fanout_full_breadth(2230, 5000),
"gin (2230) is small → no breadth bypass"
);
assert!(
!fanout_full_breadth(1616, 5000),
"flask (1616) is small → no breadth bypass"
);
assert!(
fanout_full_breadth(7809, 5000),
"platform (7809) is large → full breadth"
);
assert!(
fanout_full_breadth(21705, 5000),
"CopilotKit (21705) is large → full breadth"
);
assert!(
fanout_full_breadth(5000, 5000),
"exactly at threshold (>=) → full breadth"
);
assert!(
!fanout_full_breadth(4999, 5000),
"just below threshold → no breadth bypass"
);
}
#[test]
fn exhaustive_fanout_enabled_by_default() {
assert!(
fanout_flag_from(None),
"unset must be ON (shipped default is now on)"
);
assert!(fanout_flag_from(Some("")), "empty must be ON (default)");
assert!(fanout_flag_from(Some("1")), "\"1\" must be ON");
assert!(fanout_flag_from(Some("true")), "\"true\" must be ON");
assert!(!fanout_flag_from(Some("0")), "\"0\" must be OFF");
assert!(!fanout_flag_from(Some("false")), "\"false\" must be OFF");
assert!(!fanout_flag_from(Some("FALSE")), "\"FALSE\" must be OFF");
}
}
#[cfg(all(feature = "llm", test))]
mod llm_tests {
use super::*;
use crate::llm::LlmCapability;
use crate::search::agent_classifier::AgentRoute;
#[test]
fn classify_uses_injected_runtime_not_per_call() {
let rt = Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build shared test runtime"),
);
let mock: Arc<dyn LlmCapability> = Arc::new(MockLlm {
route: AgentRoute::Structural,
fail: false,
});
let llm_ref = mock.as_ref();
let timeout = llm_classify_timeout();
let result = rt.block_on(async {
tokio::time::timeout(timeout, llm_ref.classify_route("hello world")).await
});
let route = match result {
Ok(Ok(r)) => r,
_ => classify_agent_query("hello world"),
};
assert_eq!(
route,
AgentRoute::Structural,
"shared-runtime path must return mock's route"
);
}
struct MockLlm {
route: AgentRoute,
fail: bool,
}
#[async_trait::async_trait]
impl LlmCapability for MockLlm {
async fn classify_route(&self, _query: &str) -> anyhow::Result<AgentRoute> {
if self.fail {
anyhow::bail!("mock LLM classify error");
}
Ok(self.route)
}
async fn synthesize_hyde_doc(&self, _query: &str) -> anyhow::Result<String> {
if self.fail {
anyhow::bail!("mock LLM synthesize error");
}
Ok("fn handle_request(req: &Request) -> Response { /* mock */ }".to_string())
}
fn label(&self) -> &str {
"mock-llm"
}
}
#[test]
fn llm_classifier_overrides_keyword_default() {
let pipeline_wrapper = LlmClassifyHarness::new(AgentRoute::Structural, false);
let route = pipeline_wrapper.classify("hello world");
assert_eq!(route, AgentRoute::Structural);
}
#[test]
fn llm_classifier_falls_back_on_error() {
let pipeline_wrapper =
LlmClassifyHarness::new(AgentRoute::Structural, true );
let route = pipeline_wrapper.classify("hello world");
assert_eq!(route, AgentRoute::Semantic);
}
struct LlmClassifyHarness {
llm: Arc<dyn LlmCapability>,
}
impl LlmClassifyHarness {
fn new(route: AgentRoute, fail: bool) -> Self {
let mock = MockLlm { route, fail };
Self {
llm: Arc::new(mock),
}
}
fn classify(&self, query: &str) -> AgentRoute {
let llm_ref = self.llm.as_ref();
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => {
let timeout = llm_classify_timeout();
let result = rt.block_on(async {
tokio::time::timeout(timeout, llm_ref.classify_route(query)).await
});
match result {
Ok(Ok(r)) => r,
Ok(Err(_e)) => classify_agent_query(query),
Err(_timeout) => classify_agent_query(query),
}
}
Err(_) => classify_agent_query(query),
}
}
}
}
#[cfg(test)]
mod disambig_tests {
use super::*;
use crate::types::{AstNodeKind, Chunk, ChunkType, Confidence, SearchResult, SearchSource};
use std::path::PathBuf;
fn mk_result(
name: &str,
file: &str,
line: u32,
score: f32,
confidence: Confidence,
) -> SearchResult {
SearchResult {
chunk: Chunk {
id: 0,
file_path: PathBuf::from(file),
start_line: line,
end_line: line + 10,
content: format!("fn {name}() {{}}"),
chunk_type: ChunkType::AstNode {
name: name.into(),
kind: AstNodeKind::Function,
language: "rust".into(),
structured_meta: None,
},
},
score,
source: SearchSource::Hybrid,
score_dense: 0.0,
score_sparse: 0.0,
score_exact: 0.0,
confidence,
confidence_score: 0.5,
}
}
#[test]
fn ambiguous_top_produces_three_suggestions() {
let results = vec![
mk_result(
"userAuthHandler",
"auth/users.rs",
42,
0.92,
Confidence::Ambiguous,
),
mk_result(
"tokenAuthHandler",
"auth/tokens.rs",
18,
0.91,
Confidence::Inferred,
),
mk_result(
"sessionAuth",
"sessions/handler.rs",
107,
0.90,
Confidence::Inferred,
),
mk_result(
"authMiddleware",
"api/middleware.rs",
12,
0.88,
Confidence::Inferred,
),
];
let disambig = disambiguation_from_results(&results).expect("must populate");
assert_eq!(disambig.len(), 3);
assert_eq!(disambig[0].name, "tokenAuthHandler");
assert_eq!(disambig[0].path, "auth/tokens.rs");
assert_eq!(disambig[0].line, 18);
assert_eq!(disambig[1].name, "sessionAuth");
assert_eq!(disambig[2].name, "authMiddleware");
}
#[test]
fn extracted_top_produces_no_suggestions() {
let results = vec![
mk_result(
"authHandler",
"auth/main.rs",
1,
0.99,
Confidence::Extracted,
),
mk_result(
"tokenAuthHandler",
"auth/tokens.rs",
18,
0.50,
Confidence::Inferred,
),
mk_result(
"sessionAuth",
"sessions/handler.rs",
107,
0.40,
Confidence::Inferred,
),
];
let disambig = disambiguation_from_results(&results);
assert!(
disambig.is_none(),
"Extracted top must not populate disambig"
);
}
#[test]
fn inferred_top_produces_no_suggestions() {
let results = vec![mk_result(
"lonely",
"src/m.rs",
1,
0.5,
Confidence::Inferred,
)];
assert!(disambiguation_from_results(&results).is_none());
}
#[test]
fn runners_up_with_duplicate_names_are_skipped() {
let results = vec![
mk_result("commonName", "a/x.rs", 10, 0.92, Confidence::Ambiguous),
mk_result("commonName", "b/x.rs", 20, 0.91, Confidence::Inferred),
mk_result("uniqueName", "c/x.rs", 30, 0.90, Confidence::Inferred),
mk_result("uniqueName", "d/x.rs", 40, 0.89, Confidence::Inferred),
mk_result("otherName", "e/x.rs", 50, 0.88, Confidence::Inferred),
];
let disambig = disambiguation_from_results(&results).expect("must populate");
assert_eq!(disambig.len(), 2);
assert_eq!(disambig[0].name, "uniqueName");
assert_eq!(disambig[0].path, "c/x.rs");
assert_eq!(disambig[1].name, "otherName");
}
#[test]
fn empty_results_produce_no_suggestions() {
assert!(disambiguation_from_results(&[]).is_none());
}
#[test]
fn runners_up_without_symbol_names_are_skipped() {
let no_name_result = SearchResult {
chunk: Chunk {
id: 0,
file_path: PathBuf::from("text.md"),
start_line: 1,
end_line: 1,
content: "lorem ipsum".into(),
chunk_type: ChunkType::TextWindow { window_index: 0 },
},
score: 0.91,
source: SearchSource::Sparse,
score_dense: 0.0,
score_sparse: 0.0,
score_exact: 0.0,
confidence: Confidence::Inferred,
confidence_score: 0.5,
};
let results = vec![
mk_result("topResult", "a/x.rs", 1, 0.92, Confidence::Ambiguous),
no_name_result,
mk_result("realRunner", "b/x.rs", 30, 0.89, Confidence::Inferred),
];
let disambig = disambiguation_from_results(&results).expect("must populate");
assert_eq!(disambig.len(), 1);
assert_eq!(disambig[0].name, "realRunner");
}
}