use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::config::SearchConfig;
use crate::types::SearchResult;
pub const DEFAULT_MAX_CONTENT_LINES: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputFormat {
Json,
Text,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(OutputFormat::Json),
"text" => Ok(OutputFormat::Text),
_ => Err(format!(
"Invalid output format: '{}'. Valid options are 'json' or 'text'",
s
)),
}
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Json => write!(f, "json"),
OutputFormat::Text => write!(f, "text"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JsonSearchResult {
pub file: String,
pub line_start: usize,
pub line_end: usize,
pub score: f32,
pub content: String,
pub rank: usize,
}
pub struct ResultFormatter {
format: OutputFormat,
max_content_lines: usize,
search_config: SearchConfig,
}
impl ResultFormatter {
pub fn new(format: OutputFormat, search_config: SearchConfig) -> Self {
Self {
format,
max_content_lines: DEFAULT_MAX_CONTENT_LINES,
search_config,
}
}
pub fn with_config(
format: OutputFormat,
max_content_lines: usize,
search_config: SearchConfig,
) -> Self {
Self {
format,
max_content_lines,
search_config,
}
}
pub fn print_results(&self, results: &[SearchResult], query: &str) -> Result<()> {
match self.format {
OutputFormat::Json => self.print_json_results(results),
OutputFormat::Text => self.print_text_results(results, query),
}
}
fn print_json_results(&self, results: &[SearchResult]) -> Result<()> {
for result in results {
let json_result = self.create_json_result(result);
let json_line = serde_json::to_string(&json_result)?;
println!("{}", json_line);
}
Ok(())
}
fn create_json_result(&self, result: &SearchResult) -> JsonSearchResult {
let content = if self.search_config.rehydrate_content {
result
.chunk
.chunk
.rehydrate_content()
.unwrap_or_else(|_| result.chunk.chunk.content.clone())
} else {
result.chunk.chunk.content.clone()
};
JsonSearchResult {
file: result
.chunk
.chunk
.source_location
.file_path
.to_string_lossy()
.into_owned(),
line_start: result.chunk.chunk.source_location.start_line,
line_end: result.chunk.chunk.source_location.end_line,
score: result.similarity,
content,
rank: result.rank,
}
}
fn print_text_results(&self, results: &[SearchResult], query: &str) -> Result<()> {
if results.is_empty() {
println!("No results found for query: '{}'", query);
println!("Try adjusting your search terms or lowering the similarity threshold.");
return Ok(());
}
println!("Found {} results for query: '{}'", results.len(), query);
println!();
for (i, result) in results.iter().enumerate() {
println!(
"{}. 📄 {} (similarity: {:.1}%)",
i + 1,
result.location_display(),
result.similarity * 100.0
);
let content = if self.search_config.rehydrate_content {
result
.chunk
.chunk
.rehydrate_content()
.unwrap_or_else(|_| result.chunk.chunk.content.clone())
} else {
result.chunk.chunk.content.clone()
};
let lines: Vec<&str> = content.lines().collect();
let max_lines = self.max_content_lines;
for (line_idx, line) in lines.iter().take(max_lines).enumerate() {
let line_number = result.chunk.chunk.source_location.start_line + line_idx;
println!(" {:4}│ {}", line_number, line.trim());
}
if lines.len() > max_lines {
println!(" ...│ ...");
}
println!(); }
println!("Search completed successfully");
Ok(())
}
pub fn print_no_results(&self, query: &str, threshold: Option<f32>) -> Result<()> {
match self.format {
OutputFormat::Json => {
Ok(())
}
OutputFormat::Text => {
println!("No results found for query: '{}'", query);
if let Some(threshold) = threshold {
println!(
"Try lowering the similarity threshold (currently {:.1}%)",
threshold * 100.0
);
}
println!("Or try different search terms that might better match your content.");
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ChunkId, ChunkIndexNum, ContentChunk, IndexedChunk, SearchResult, SourceLocation,
TokenCount,
};
use std::path::PathBuf;
fn create_test_search_result(similarity: f32, file_path: &str, content: &str) -> SearchResult {
let chunk = ContentChunk {
id: ChunkId::new("test-chunk"),
content: content.to_string(),
token_count: TokenCount::new(10),
source_location: SourceLocation {
file_path: PathBuf::from(file_path),
start_line: 42,
end_line: 44,
start_char: 0,
end_char: content.len(),
},
chunk_index: ChunkIndexNum::new(0),
total_chunks: 1,
};
let indexed_chunk = IndexedChunk {
chunk,
embedding: vec![0.1, 0.2, 0.3],
};
SearchResult::new(similarity, indexed_chunk, 0)
}
#[test]
fn test_output_format_from_str() {
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert!("invalid".parse::<OutputFormat>().is_err());
assert!("xml".parse::<OutputFormat>().is_err());
}
#[test]
fn test_output_format_display() {
assert_eq!(OutputFormat::Json.to_string(), "json");
assert_eq!(OutputFormat::Text.to_string(), "text");
}
#[test]
fn test_json_search_result_conversion() {
let result = create_test_search_result(
0.85,
"src/main.rs",
"fn main() {\n println!(\"Hello\");\n}",
);
let search_config = SearchConfig::default();
let formatter = ResultFormatter::new(OutputFormat::Json, search_config);
let json_result = formatter.create_json_result(&result);
assert_eq!(json_result.file, "src/main.rs");
assert_eq!(json_result.line_start, 42);
assert_eq!(json_result.line_end, 44);
assert_eq!(json_result.score, 0.85);
assert_eq!(
json_result.content,
"fn main() {\n println!(\"Hello\");\n}"
);
assert_eq!(json_result.rank, 0);
}
#[test]
fn test_json_serialization() {
let result = create_test_search_result(0.75, "test.rs", "test content");
let search_config = SearchConfig::default();
let formatter = ResultFormatter::new(OutputFormat::Json, search_config);
let json_result = formatter.create_json_result(&result);
let serialized = serde_json::to_string(&json_result).unwrap();
assert!(serialized.contains("\"file\":\"test.rs\""));
assert!(serialized.contains("\"score\":0.75"));
assert!(serialized.contains("\"content\":\"test content\""));
let deserialized: JsonSearchResult = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.file, json_result.file);
assert_eq!(deserialized.score, json_result.score);
}
#[test]
fn test_result_formatter_creation() {
let search_config = SearchConfig::default();
let formatter = ResultFormatter::new(OutputFormat::Json, search_config.clone());
assert_eq!(formatter.format, OutputFormat::Json);
let formatter = ResultFormatter::new(OutputFormat::Text, search_config);
assert_eq!(formatter.format, OutputFormat::Text);
}
#[test]
fn test_content_rehydration_enabled() {
let search_config = SearchConfig {
rehydrate_content: true,
..Default::default()
};
let result = create_test_search_result(0.85, "src/main.rs", "fn main() {}");
let formatter = ResultFormatter::new(OutputFormat::Json, search_config);
let json_result = formatter.create_json_result(&result);
assert!(json_result.content.contains("fn main() {}"));
}
#[test]
fn test_content_rehydration_disabled() {
let search_config = SearchConfig {
rehydrate_content: false,
..Default::default()
};
let result = create_test_search_result(0.85, "src/main.rs", "fn main() {}");
let formatter = ResultFormatter::new(OutputFormat::Json, search_config);
let json_result = formatter.create_json_result(&result);
assert!(json_result.content.contains("fn main() {}"));
}
}