use crate::search::executor_types::SearchSource;
use crate::search::types::SearchMode;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalSearchResults {
pub query: String,
pub results: Vec<ChunkSearchResult>,
pub metadata: SearchMetadata,
}
impl FinalSearchResults {
pub fn new(query: String, results: Vec<ChunkSearchResult>, metadata: SearchMetadata) -> Self {
Self {
query,
results,
metadata,
}
}
pub fn is_empty(&self) -> bool {
self.results.is_empty()
}
pub fn len(&self) -> usize {
self.results.len()
}
pub fn top_n(&self, n: usize) -> &[ChunkSearchResult] {
let end = n.min(self.results.len());
&self.results[..end]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkSearchResult {
pub chunk_id: i64,
pub file_id: i64,
pub relpath: String,
pub symbol_name: Option<String>,
pub kind: String,
pub start_line: i32,
pub end_line: i32,
pub preview: String,
pub score: f32,
pub source_scores: HashMap<SearchSource, f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<ConfidenceSignals>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<RelatedChunkResult>>,
}
impl ChunkSearchResult {
#[allow(clippy::too_many_arguments)]
pub fn new(
chunk_id: i64,
file_id: i64,
relpath: String,
symbol_name: Option<String>,
kind: String,
start_line: i32,
end_line: i32,
preview: String,
score: f32,
source_scores: HashMap<SearchSource, f32>,
) -> Self {
Self {
chunk_id,
file_id,
relpath,
symbol_name,
kind,
start_line,
end_line,
preview,
score,
source_scores,
confidence: None,
related: None,
}
}
pub fn line_range(&self) -> String {
format!("{}-{}", self.start_line, self.end_line)
}
pub fn line_count(&self) -> i32 {
self.end_line - self.start_line + 1
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryUnderstanding {
pub mode: SearchMode,
pub tokens: Vec<String>,
pub expanded_terms: Vec<String>,
pub filters: QueryFilters,
pub fusion_strategy: String,
pub timing: TimingBreakdown,
}
impl QueryUnderstanding {
pub fn from_query_data(
mode: SearchMode,
tokens: Vec<String>,
expanded_terms: Vec<String>,
filters: QueryFilters,
fusion_strategy: String,
timing: TimingBreakdown,
) -> Self {
Self {
mode,
tokens,
expanded_terms,
filters,
fusion_strategy,
timing,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryFilters {
pub repo_id: i64,
pub worktree_id: Option<i64>,
pub file_types: Vec<String>,
pub recency_threshold: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimingBreakdown {
pub query_processing_ms: f64,
pub search_execution_ms: f64,
pub score_fusion_ms: f64,
pub result_assembly_ms: f64,
pub total_ms: f64,
}
impl TimingBreakdown {
pub fn new(
query_processing_ms: f64,
search_execution_ms: f64,
score_fusion_ms: f64,
result_assembly_ms: f64,
) -> Self {
let total_ms =
query_processing_ms + search_execution_ms + score_fusion_ms + result_assembly_ms;
Self {
query_processing_ms,
search_execution_ms,
score_fusion_ms,
result_assembly_ms,
total_ms,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchMetadata {
pub query_processing: QueryProcessingDetails,
pub result_counts: HashMap<SearchSource, usize>,
pub timing: SearchTiming,
pub total_unique_chunks: usize,
pub returned_results: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub understanding: Option<QueryUnderstanding>,
}
impl SearchMetadata {
pub fn new(
query_processing: QueryProcessingDetails,
result_counts: HashMap<SearchSource, usize>,
timing: SearchTiming,
total_unique_chunks: usize,
returned_results: usize,
) -> Self {
Self {
query_processing,
result_counts,
timing,
total_unique_chunks,
returned_results,
understanding: None,
}
}
pub fn with_understanding(
query_processing: QueryProcessingDetails,
result_counts: HashMap<SearchSource, usize>,
timing: SearchTiming,
total_unique_chunks: usize,
returned_results: usize,
understanding: QueryUnderstanding,
) -> Self {
Self {
query_processing,
result_counts,
timing,
total_unique_chunks,
returned_results,
understanding: Some(understanding),
}
}
pub fn total_time_ms(&self) -> f64 {
self.timing.query_processing_ms
+ self.timing.search_execution_ms
+ self.timing.fusion_ms
+ self.timing.assembly_ms
}
pub fn met_performance_target(&self) -> bool {
self.total_time_ms() < 50.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryProcessingDetails {
pub original: String,
pub mode: SearchMode,
pub token_count: usize,
pub expanded_term_count: usize,
pub fts_query: String,
pub has_embedding: bool,
}
impl QueryProcessingDetails {
pub fn new(
original: String,
mode: SearchMode,
token_count: usize,
expanded_term_count: usize,
fts_query: String,
has_embedding: bool,
) -> Self {
Self {
original,
mode,
token_count,
expanded_term_count,
fts_query,
has_embedding,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchTiming {
pub query_processing_ms: f64,
pub search_execution_ms: f64,
pub fusion_ms: f64,
pub assembly_ms: f64,
pub reranking_ms: Option<f64>,
}
impl SearchTiming {
pub fn new(
query_processing_ms: f64,
search_execution_ms: f64,
fusion_ms: f64,
assembly_ms: f64,
) -> Self {
Self {
query_processing_ms,
search_execution_ms,
fusion_ms,
assembly_ms,
reranking_ms: None,
}
}
pub fn zero() -> Self {
Self {
query_processing_ms: 0.0,
search_execution_ms: 0.0,
fusion_ms: 0.0,
assembly_ms: 0.0,
reranking_ms: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOptions {
pub repo_id: i64,
pub worktree_id: Option<i64>,
pub limit: usize,
pub fusion_weights: Option<crate::search::fusion::FusionWeights>,
pub skip_vector: bool,
pub skip_graph: bool,
pub skip_signals: bool,
#[serde(default = "default_deduplicate")]
pub deduplicate: bool,
#[serde(default)]
pub file_types: Vec<String>,
#[serde(default)]
pub recency_threshold: Option<String>,
#[serde(default)]
pub include_confidence: bool,
#[serde(default)]
pub include_related: bool,
}
fn default_deduplicate() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfidenceSignals {
pub source_count: usize,
pub score_gap: f32,
pub is_exact_match: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedChunkResult {
pub chunk_id: i64,
pub relpath: String,
pub symbol_name: Option<String>,
pub kind: String,
pub start_line: i32,
pub end_line: i32,
pub preview: String,
pub depth: i32,
pub relevance: f32,
pub relationship_type: String,
}
impl SearchOptions {
pub fn new(repo_id: i64, worktree_id: Option<i64>, limit: usize) -> Self {
Self {
repo_id,
worktree_id,
limit,
fusion_weights: None,
skip_vector: false,
skip_graph: false,
skip_signals: false,
deduplicate: true,
file_types: vec![],
recency_threshold: None,
include_confidence: false,
include_related: false,
}
}
pub fn with_fusion_weights(mut self, weights: crate::search::fusion::FusionWeights) -> Self {
self.fusion_weights = Some(weights);
self
}
pub fn with_skip_vector(mut self, skip: bool) -> Self {
self.skip_vector = skip;
self
}
pub fn with_skip_graph(mut self, skip: bool) -> Self {
self.skip_graph = skip;
self
}
pub fn with_skip_signals(mut self, skip: bool) -> Self {
self.skip_signals = skip;
self
}
pub fn without_dedup(mut self) -> Self {
self.deduplicate = false;
self
}
pub fn with_deduplicate(mut self, deduplicate: bool) -> Self {
self.deduplicate = deduplicate;
self
}
pub fn get_fusion_weights(&self) -> crate::search::fusion::FusionWeights {
self.fusion_weights.clone().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_final_search_results_empty() {
let results = FinalSearchResults::new(
"test query".to_string(),
vec![],
SearchMetadata::new(
QueryProcessingDetails::new(
"test query".to_string(),
SearchMode::Auto,
2,
0,
"test & query".to_string(),
true,
),
HashMap::new(),
SearchTiming::zero(),
0,
0,
),
);
assert!(results.is_empty());
assert_eq!(results.len(), 0);
}
#[test]
fn test_chunk_search_result_line_range() {
let result = ChunkSearchResult::new(
1,
1,
"src/main.rs".to_string(),
Some("main".to_string()),
"function".to_string(),
10,
25,
"fn main() {".to_string(),
0.95,
HashMap::new(),
);
assert_eq!(result.line_range(), "10-25");
assert_eq!(result.line_count(), 16);
}
#[test]
fn test_search_metadata_total_time() {
let timing = SearchTiming::new(5.0, 30.0, 2.0, 8.0);
let metadata = SearchMetadata::new(
QueryProcessingDetails::new(
"test".to_string(),
SearchMode::Auto,
1,
0,
"test".to_string(),
true,
),
HashMap::new(),
timing,
50,
10,
);
assert_eq!(metadata.total_time_ms(), 45.0);
assert!(metadata.met_performance_target());
}
#[test]
fn test_search_metadata_performance_target_exceeded() {
let timing = SearchTiming::new(15.0, 40.0, 5.0, 10.0);
let metadata = SearchMetadata::new(
QueryProcessingDetails::new(
"test".to_string(),
SearchMode::Auto,
1,
0,
"test".to_string(),
true,
),
HashMap::new(),
timing,
50,
10,
);
assert_eq!(metadata.total_time_ms(), 70.0);
assert!(!metadata.met_performance_target());
}
#[test]
fn test_search_options_builder() {
let options = SearchOptions::new(1, Some(2), 10)
.with_skip_vector(true)
.with_skip_graph(false);
assert_eq!(options.repo_id, 1);
assert_eq!(options.worktree_id, Some(2));
assert_eq!(options.limit, 10);
assert!(options.skip_vector);
assert!(!options.skip_graph);
}
#[test]
fn test_search_options_default_weights() {
let options = SearchOptions::new(1, None, 10);
let weights = options.get_fusion_weights();
assert_eq!(weights.fts, 0.4);
assert_eq!(weights.vector, 0.35);
assert_eq!(weights.graph, 0.1);
assert_eq!(weights.recency, 0.1);
assert_eq!(weights.churn, 0.05);
}
#[test]
fn test_search_options_deduplicate_default() {
let options = SearchOptions::new(1, None, 10);
assert!(
options.deduplicate,
"Deduplication should be enabled by default"
);
}
#[test]
fn test_search_options_without_dedup() {
let options = SearchOptions::new(1, None, 10).without_dedup();
assert!(
!options.deduplicate,
"without_dedup should disable deduplication"
);
}
#[test]
fn test_search_options_with_deduplicate() {
let options = SearchOptions::new(1, None, 10)
.without_dedup()
.with_deduplicate(true);
assert!(
options.deduplicate,
"with_deduplicate(true) should enable deduplication"
);
let options = SearchOptions::new(1, None, 10).with_deduplicate(false);
assert!(
!options.deduplicate,
"with_deduplicate(false) should disable deduplication"
);
}
#[test]
fn test_timing_breakdown_total_calculation() {
let timing = TimingBreakdown::new(4.2, 35.8, 2.1, 6.4);
assert_eq!(timing.total_ms, 48.5);
assert_eq!(timing.query_processing_ms, 4.2);
assert_eq!(timing.search_execution_ms, 35.8);
assert_eq!(timing.score_fusion_ms, 2.1);
assert_eq!(timing.result_assembly_ms, 6.4);
}
#[test]
fn test_query_understanding_serialization() {
let understanding = QueryUnderstanding {
mode: SearchMode::Auto,
tokens: vec!["authenticate".to_string(), "user".to_string()],
expanded_terms: vec!["auth".to_string(), "login".to_string()],
filters: QueryFilters {
repo_id: 1,
worktree_id: Some(2),
file_types: vec![],
recency_threshold: None,
},
fusion_strategy: "reciprocal_rank_fusion".to_string(),
timing: TimingBreakdown::new(4.2, 35.8, 2.1, 6.4),
};
let json = serde_json::to_string(&understanding).unwrap();
assert!(json.contains("authenticate"));
assert!(json.contains("reciprocal_rank_fusion"));
assert!(json.contains("\"total_ms\":48.5"));
}
#[test]
fn test_optional_understanding_field_serialization() {
let metadata = SearchMetadata {
query_processing: QueryProcessingDetails::new(
"test query".to_string(),
SearchMode::Auto,
2,
0,
"test & query".to_string(),
true,
),
result_counts: HashMap::new(),
timing: SearchTiming::zero(),
total_unique_chunks: 0,
returned_results: 0,
understanding: None,
};
let json = serde_json::to_value(&metadata).unwrap();
assert!(json.get("understanding").is_none());
let metadata_with_understanding = SearchMetadata {
query_processing: QueryProcessingDetails::new(
"test query".to_string(),
SearchMode::Auto,
2,
0,
"test & query".to_string(),
true,
),
result_counts: HashMap::new(),
timing: SearchTiming::zero(),
total_unique_chunks: 0,
returned_results: 0,
understanding: Some(QueryUnderstanding {
mode: SearchMode::Code,
tokens: vec!["test".to_string()],
expanded_terms: vec![],
filters: QueryFilters {
repo_id: 1,
worktree_id: None,
file_types: vec![],
recency_threshold: None,
},
fusion_strategy: "basic_weighted".to_string(),
timing: TimingBreakdown::new(1.0, 2.0, 3.0, 4.0),
}),
};
let json = serde_json::to_value(&metadata_with_understanding).unwrap();
assert!(json.get("understanding").is_some());
let understanding = json.get("understanding").unwrap();
assert_eq!(understanding.get("mode").unwrap(), "code");
assert_eq!(
understanding.get("fusion_strategy").unwrap(),
"basic_weighted"
);
}
#[test]
fn test_query_filters_serialization() {
let filters = QueryFilters {
repo_id: 42,
worktree_id: Some(123),
file_types: vec!["ts".to_string(), "tsx".to_string()],
recency_threshold: Some("7 days".to_string()),
};
let json = serde_json::to_value(&filters).unwrap();
assert_eq!(json.get("repo_id").unwrap(), 42);
assert_eq!(json.get("worktree_id").unwrap(), 123);
let file_types = json.get("file_types").unwrap().as_array().unwrap();
assert_eq!(file_types.len(), 2);
assert_eq!(file_types[0], "ts");
assert_eq!(file_types[1], "tsx");
assert_eq!(json.get("recency_threshold").unwrap(), "7 days");
}
#[test]
fn test_query_understanding_from_query_data() {
let timing = TimingBreakdown::new(5.0, 10.0, 2.0, 3.0);
let filters = QueryFilters {
repo_id: 1,
worktree_id: None,
file_types: vec!["rs".to_string()],
recency_threshold: None,
};
let understanding = QueryUnderstanding::from_query_data(
SearchMode::Code,
vec!["search".to_string(), "query".to_string()],
vec!["find".to_string()],
filters,
"reciprocal_rank_fusion".to_string(),
timing,
);
assert_eq!(understanding.mode, SearchMode::Code);
assert_eq!(understanding.tokens.len(), 2);
assert_eq!(understanding.expanded_terms.len(), 1);
assert_eq!(understanding.fusion_strategy, "reciprocal_rank_fusion");
assert_eq!(understanding.timing.total_ms, 20.0);
}
}