use anyhow::{Context, Result};
use std::path::Path;
use tracing::{debug, info, warn};
use crate::filters::SearchFilter;
use crate::model_validation::{validate_instruction_compatibility, validate_model_selection};
use crate::output::{OutputFormat, ResultFormatter};
use crate::search_with_config;
const LARGE_RESULT_LIMIT_WARNING_THRESHOLD: usize = 1000;
#[derive(Debug, Clone)]
pub struct SearchCommandConfig {
pub query: String,
pub repo_path: String,
pub limit: usize,
pub threshold: Option<f32>,
pub output_format: OutputFormat,
pub filetype: Option<String>,
pub glob_pattern: Option<String>,
}
impl SearchCommandConfig {
pub fn new(
query: String,
repo_path: String,
limit: usize,
threshold: Option<f32>,
output_format: OutputFormat,
filetype: Option<String>,
glob_pattern: Option<String>,
) -> Self {
Self {
query,
repo_path,
limit,
threshold,
output_format,
filetype,
glob_pattern,
}
}
pub fn validate(&self) -> Result<()> {
crate::query::validate_query(&self.query)
.with_context(|| format!("Search query validation failed: '{}'", self.query))?;
let repo_path = Path::new(&self.repo_path);
if !repo_path.exists() {
return Err(anyhow::anyhow!("Repository path does not exist"))
.with_context(|| format!("Invalid repository path: {}", self.repo_path));
}
if let Some(threshold) = self.threshold {
if !(0.0..=1.0).contains(&threshold) {
return Err(anyhow::anyhow!("Threshold out of valid range")).with_context(|| {
format!("Threshold must be between 0.0 and 1.0, got: {}", threshold)
});
}
}
if self.limit == 0 {
return Err(anyhow::anyhow!("Invalid limit value"))
.with_context(|| format!("Limit must be greater than 0, got: {}", self.limit));
}
if self.limit > LARGE_RESULT_LIMIT_WARNING_THRESHOLD {
warn!(
"Large result limit specified ({}), this may impact performance",
self.limit
);
}
if let Some(ref filetype) = self.filetype {
crate::filters::normalize_file_extension(filetype)
.with_context(|| format!("File extension validation failed for '{}'", filetype))?;
}
if let Some(ref glob_pattern) = self.glob_pattern {
crate::filters::validate_glob_pattern(glob_pattern).with_context(|| {
format!("Glob pattern validation failed for '{}'", glob_pattern)
})?;
}
Ok(())
}
}
pub async fn execute_search_command(
config: SearchCommandConfig,
turboprop_config: &crate::config::TurboPropConfig,
) -> Result<()> {
info!("Starting search command execution");
debug!("Search config: {:?}", config);
config
.validate()
.context("Search configuration validation failed")?;
let model_info = validate_model_selection(&turboprop_config.embedding.model_name)
.await
.with_context(|| {
format!(
"Model validation failed for '{}'",
turboprop_config.embedding.model_name
)
})?;
validate_instruction_compatibility(
&model_info,
turboprop_config.current_instruction.as_deref(),
)
.with_context(|| "Instruction validation failed")?;
info!("Searching for: '{}'", config.query);
info!("Repository: {}", config.repo_path);
info!("Output format: {}", config.output_format);
info!("Result limit: {}", config.limit);
if let Some(threshold) = config.threshold {
info!("Similarity threshold: {:.1}%", threshold * 100.0);
}
if let Some(ref glob_pattern) = config.glob_pattern {
info!("Glob pattern filter: {}", glob_pattern);
}
let search_filter = SearchFilter::from_cli_args_with_config(
config.filetype.clone(),
config.glob_pattern.clone(),
turboprop_config,
);
if search_filter.has_active_filters() {
let filter_descriptions = search_filter.describe_filters();
info!("Active filters: {}", filter_descriptions.join(", "));
}
info!("Executing search query...");
let repo_path = Path::new(&config.repo_path);
let results = search_with_config(
&config.query,
repo_path,
Some(config.limit),
config.threshold,
)
.await
.context("Search execution failed")?;
debug!("Raw search returned {} results", results.len());
let filtered_results = search_filter
.apply_filters(results)
.context("Failed to apply result filters")?;
info!("Found {} results after filtering", filtered_results.len());
let formatter = ResultFormatter::new(config.output_format, turboprop_config.search.clone());
if filtered_results.is_empty() {
formatter
.print_no_results(&config.query, config.threshold)
.with_context(|| {
format!(
"Failed to format no-results output for query '{}'",
config.query
)
})?;
} else {
formatter
.print_results(&filtered_results, &config.query)
.with_context(|| {
format!(
"Failed to format {} search results for query '{}'",
filtered_results.len(),
config.query
)
})?;
}
info!("Search command completed successfully");
Ok(())
}
#[derive(Debug, Clone)]
pub struct SearchCliArgs {
pub query: String,
pub repo: std::path::PathBuf,
pub limit: usize,
pub threshold: Option<f32>,
pub output: String,
pub filetype: Option<String>,
pub filter: Option<String>,
}
impl SearchCliArgs {
pub fn new(
query: String,
repo: std::path::PathBuf,
limit: usize,
threshold: Option<f32>,
output: String,
filetype: Option<String>,
filter: Option<String>,
) -> Self {
Self {
query,
repo,
limit,
threshold,
output,
filetype,
filter,
}
}
}
pub async fn execute_search_command_cli(
args: SearchCliArgs,
turboprop_config: &crate::config::TurboPropConfig,
) -> Result<()> {
let output_format: OutputFormat = args
.output
.parse()
.map_err(|e| anyhow::anyhow!("{}", e))
.with_context(|| format!("Invalid output format: '{}'", args.output))?;
let config = SearchCommandConfig::new(
args.query,
args.repo.to_string_lossy().to_string(),
args.limit,
args.threshold,
output_format,
args.filetype,
args.filter,
);
execute_search_command(config, turboprop_config).await
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_search_command_config_validation() {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
Some(0.5),
OutputFormat::Json,
Some("rs".to_string()),
Some("*.rs".to_string()),
);
assert!(config.validate().is_ok());
let config = SearchCommandConfig::new(
"".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_err());
let config = SearchCommandConfig::new(
"test".to_string(),
".".to_string(),
10,
Some(1.5),
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_err());
let config = SearchCommandConfig::new(
"test".to_string(),
".".to_string(),
0,
None,
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_err());
}
#[test]
fn test_search_command_config_nonexistent_path() {
let config = SearchCommandConfig::new(
"test query".to_string(),
"/nonexistent/path".to_string(),
10,
None,
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_err());
}
#[test]
fn test_search_command_config_invalid_filetype() {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
Some("".to_string()), None,
);
assert!(config.validate().is_err());
}
#[tokio::test]
async fn test_search_command_with_temp_directory() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_string_lossy().to_string();
let config = SearchCommandConfig::new(
"test query".to_string(),
temp_path,
10,
None,
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_ok());
let turboprop_config = crate::config::TurboPropConfig::default();
let result = execute_search_command(config, &turboprop_config).await;
assert!(result.is_err());
}
#[test]
fn test_search_command_config_with_valid_glob_pattern() {
let valid_patterns = vec!["*.rs", "src/*.js", "**/*.py", "test_*.txt"];
for pattern in valid_patterns {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
None,
Some(pattern.to_string()),
);
assert!(
config.validate().is_ok(),
"Pattern '{}' should be valid",
pattern
);
}
}
#[test]
fn test_search_command_config_with_invalid_glob_pattern() {
let invalid_patterns = vec!["", " ", "[invalid"];
for pattern in invalid_patterns {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
None,
Some(pattern.to_string()),
);
assert!(
config.validate().is_err(),
"Pattern '{}' should be invalid",
pattern
);
}
}
#[test]
fn test_search_command_config_with_no_glob_pattern() {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
None,
None,
);
assert!(config.validate().is_ok());
}
#[test]
fn test_search_command_config_with_both_filetype_and_glob() {
let config = SearchCommandConfig::new(
"test query".to_string(),
".".to_string(),
10,
None,
OutputFormat::Json,
Some("rs".to_string()),
Some("src/*.rs".to_string()),
);
assert!(config.validate().is_ok());
}
}