use anyhow::Result;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use crate::config::Config;
use super::{SearchOptions, SearchResult, SearchStats};
pub struct SearchEngine {
config: Arc<Config>,
}
impl SearchEngine {
pub fn new(config: Config) -> Self {
Self {
config: Arc::new(config),
}
}
pub async fn search(
&self,
pattern: &str,
paths: &[PathBuf],
) -> Result<Vec<SearchResult>> {
let start_time = Instant::now();
if pattern.is_empty() {
anyhow::bail!("Search pattern cannot be empty");
}
let options = SearchOptions::new(pattern, self.config.clone())?;
info!("Starting search for pattern: '{}'", pattern);
info!("Search paths: {:?}", paths);
let search_paths = if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths.to_vec()
};
let (file_tx, file_rx) = mpsc::channel(1000);
let (result_tx, mut result_rx) = mpsc::channel(100);
let discovery_handle = {
let search_paths = search_paths.clone();
let file_tx = file_tx.clone();
tokio::spawn(async move {
for path in search_paths {
match super::walker::walk_simple(path) {
Ok(files) => {
for file_path in files {
if file_tx.send(file_path).await.is_err() {
break;
}
}
}
Err(e) => {
error!("File discovery failed: {}", e);
}
}
}
})
};
let file_rx = Arc::new(tokio::sync::Mutex::new(file_rx));
let searcher_handles: Vec<_> = (0..self.config.search.threads)
.map(|worker_id| {
let file_rx = file_rx.clone();
let options = options.clone();
let result_tx = result_tx.clone();
tokio::spawn(async move {
let mut files_processed = 0;
loop {
let file_path = {
let mut guard = file_rx.lock().await;
guard.recv().await
};
if let Some(file_path) = file_path {
files_processed += 1;
match super::search_file(&file_path, &options).await {
Ok(Some(result)) => {
if let Err(e) = result_tx.send(Ok(result)).await {
error!("Failed to send search result: {}", e);
break;
}
}
Ok(None) => {
}
Err(e) => {
warn!("Search failed for {}: {}", file_path.display(), e);
if let Err(e) = result_tx.send(Err(e)).await {
error!("Failed to send error result: {}", e);
break;
}
}
}
} else {
break;
}
}
info!("Worker {} processed {} files", worker_id, files_processed);
})
})
.collect();
drop(file_tx);
drop(result_tx);
let mut results = Vec::new();
let mut errors = Vec::new();
while let Some(result) = result_rx.recv().await {
match result {
Ok(search_result) => results.push(search_result),
Err(e) => errors.push(e),
}
}
let _ = discovery_handle.await;
for handle in searcher_handles {
let _ = handle.await;
}
let search_duration = start_time.elapsed();
info!(
"Search completed in {}ms: {} files with matches, {} total matches",
search_duration.as_millis(),
results.len(),
results.iter().map(|r| r.matches.len()).sum::<usize>()
);
if !errors.is_empty() {
warn!("Search completed with {} errors", errors.len());
}
#[cfg(feature = "ai")]
if self.should_add_ai_insights() {
self.add_ai_insights(&mut results).await?;
}
self.sort_results(&mut results);
Ok(results)
}
pub async fn interactive_search(&self) -> Result<Vec<SearchResult>> {
use dialoguer::{Input, Select, MultiSelect, Confirm};
println!("OpenGrep Interactive Search");
println!("==============================");
let pattern: String = Input::new()
.with_prompt("Enter search pattern")
.interact()?;
if pattern.is_empty() {
anyhow::bail!("Search pattern cannot be empty");
}
let search_type = Select::new()
.with_prompt("Search type")
.items(&["Literal", "Regular Expression", "AI-assisted"])
.default(0)
.interact()?;
let languages = if Confirm::new()
.with_prompt("Filter by programming language?")
.default(false)
.interact()?
{
let available_languages = [
"rust", "python", "javascript", "typescript", "go", "java",
"c", "cpp", "csharp", "ruby", "bash", "yaml", "json", "html", "css"
];
let selected = MultiSelect::new()
.with_prompt("Select languages")
.items(&available_languages)
.interact()?;
selected.into_iter().map(|i| available_languages[i].to_string()).collect()
} else {
vec![]
};
let paths: String = Input::new()
.with_prompt("Search paths (comma-separated, empty for current directory)")
.default(".".to_string())
.interact()?;
let search_paths: Vec<PathBuf> = if paths.trim().is_empty() || paths.trim() == "." {
vec![PathBuf::from(".")]
} else {
paths.split(',').map(|p| PathBuf::from(p.trim())).collect()
};
let show_ast_context = Confirm::new()
.with_prompt("Show AST context?")
.default(false)
.interact()?;
let mut config = (*self.config).clone();
config.search.regex = search_type == 1;
config.output.show_ast_context = show_ast_context;
if !languages.is_empty() {
info!("Filtering for languages: {:?}", languages);
}
println!("\nStarting search...\n");
let results = match search_type {
0 | 1 => {
let engine = SearchEngine::new(config);
engine.search(&pattern, &search_paths).await?
}
2 => {
#[cfg(feature = "ai")]
{
self.ai_assisted_search(&pattern, &search_paths).await?
}
#[cfg(not(feature = "ai"))]
{
println!("AI features not enabled. Using regular search.");
let engine = SearchEngine::new(config);
engine.search(&pattern, &search_paths).await?
}
}
_ => unreachable!(),
};
println!("\nSearch Summary:");
println!("==================");
println!("Files with matches: {}", results.len());
println!("Total matches: {}", results.iter().map(|r| r.matches.len()).sum::<usize>());
if !results.is_empty() {
println!("\nPress Enter to see detailed results...");
std::io::stdin().read_line(&mut String::new())?;
}
Ok(results)
}
#[cfg(feature = "ai")]
async fn ai_assisted_search(
&self,
query: &str,
paths: &[PathBuf],
) -> Result<Vec<SearchResult>> {
use crate::ai::AiService;
if let Some(ai_config) = &self.config.ai {
let ai_service = AiService::new(ai_config.clone())?;
println!("Asking AI for search suggestions...");
let patterns = ai_service.suggest_patterns(query, "").await?;
println!("AI suggested patterns:");
for (i, suggestion) in patterns.iter().enumerate() {
println!(" {}. {} - {}", i + 1, suggestion.pattern, suggestion.description);
}
let mut all_results = Vec::new();
for suggestion in patterns {
println!("\nSearching with pattern: {}", suggestion.pattern);
let results = self.search(&suggestion.pattern, paths).await?;
all_results.extend(results);
}
self.deduplicate_and_rank_results(&mut all_results);
Ok(all_results)
} else {
anyhow::bail!("AI configuration not available");
}
}
#[cfg(feature = "ai")]
async fn add_ai_insights(&self, results: &mut [SearchResult]) -> Result<()> {
use crate::ai::AiService;
if let Some(ai_config) = &self.config.ai {
let ai_service = AiService::new(ai_config.clone())?;
info!("Generating AI insights for {} files", results.len());
let semaphore = Arc::new(tokio::sync::Semaphore::new(5)); let mut handles = Vec::new();
for result in results.iter_mut() {
if !result.matches.is_empty() {
let permit = semaphore.clone().acquire_owned().await?;
let ai_service = ai_service.clone();
let result_clone = SearchResult {
path: result.path.clone(),
matches: result.matches.clone(),
metadata: result.metadata.clone(),
#[cfg(feature = "ai")]
ai_insights: result.ai_insights.clone(),
};
let handle = tokio::spawn(async move {
let _permit = permit; ai_service.generate_insights(&result_clone).await
});
handles.push(handle);
} else {
handles.push(tokio::spawn(async {
Ok(crate::search::AiInsights {
summary: "No matches found".to_string(),
explanation: None,
suggestions: vec![],
related_locations: vec![],
})
}));
}
}
for (result, handle) in results.iter_mut().zip(handles) {
match handle.await? {
Ok(insights) => {
result.ai_insights = Some(insights);
}
Err(e) => {
warn!("Failed to generate AI insights for {}: {}", result.path.display(), e);
}
}
}
}
Ok(())
}
#[cfg(feature = "ai")]
fn should_add_ai_insights(&self) -> bool {
self.config.ai
.as_ref()
.map(|ai| ai.enable_insights)
.unwrap_or(false)
}
fn sort_results(&self, results: &mut [SearchResult]) {
results.sort_by(|a, b| {
let avg_score_a = a.matches.iter().map(|m| m.score).sum::<f64>() / a.matches.len() as f64;
let avg_score_b = b.matches.iter().map(|m| m.score).sum::<f64>() / b.matches.len() as f64;
avg_score_b.partial_cmp(&avg_score_a)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.matches.len().cmp(&a.matches.len()))
.then_with(|| a.path.cmp(&b.path))
});
for result in results {
result.matches.sort_by(|a, b| {
b.score.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.line_number.cmp(&b.line_number))
});
}
}
fn deduplicate_and_rank_results(&self, results: &mut Vec<SearchResult>) {
use std::collections::HashMap;
let mut grouped: HashMap<PathBuf, SearchResult> = HashMap::new();
for result in results.drain(..) {
let path = result.path.clone();
match grouped.entry(path) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
entry.get_mut().matches.extend(result.matches);
entry.get_mut().matches.sort_by_key(|m| (m.line_number, m.column_range.start));
entry.get_mut().matches.dedup_by(|a, b| {
a.line_number == b.line_number &&
a.column_range == b.column_range
});
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(result);
}
}
}
*results = grouped.into_values().collect();
self.sort_results(results);
}
pub fn generate_stats(&self, results: &[SearchResult], duration: std::time::Duration) -> SearchStats {
let total_matches = results.iter().map(|r| r.matches.len()).sum();
let files_searched = results.iter().map(|_r| 1).sum(); let files_matched = results.len();
let mut language_stats = std::collections::HashMap::new();
for result in results {
if let Some(lang) = &result.metadata.language {
*language_stats.entry(lang.clone()).or_insert(0) += 1;
}
}
let total_bytes: u64 = results.iter().map(|r| r.metadata.size).sum();
let _bytes_per_second = if duration.as_secs_f64() > 0.0 {
total_bytes as f64 / duration.as_secs_f64()
} else {
0.0
};
SearchStats {
files_searched,
files_matched,
total_matches,
files_skipped: 0, duration,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::fs;
#[tokio::test]
async fn test_search_engine_creation() {
let config = Config::default();
let engine = SearchEngine::new(config);
assert!(engine.config.search.threads > 0);
}
#[tokio::test]
async fn test_basic_search() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {\n println!(\"Hello, world!\");\n}").await.unwrap();
let config = Config::default();
let engine = SearchEngine::new(config);
let results = engine.search("main", &[temp_dir.path().to_path_buf()]).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].matches.len(), 1);
assert_eq!(results[0].matches[0].line_number, 1);
}
#[tokio::test]
async fn test_empty_pattern() {
let config = Config::default();
let engine = SearchEngine::new(config);
let result = engine.search("", &[PathBuf::from(".")]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_nonexistent_path() {
let config = Config::default();
let engine = SearchEngine::new(config);
let results = engine.search("test", &[PathBuf::from("/nonexistent/path")]).await.unwrap();
assert!(results.is_empty());
}
#[test]
fn test_sort_results() {
let config = Config::default();
let engine = SearchEngine::new(config);
let mut results = vec![
SearchResult {
path: PathBuf::from("file1.rs"),
matches: vec![
crate::search::Match {
line_number: 1,
column_range: 0..4,
line_text: "test".to_string(),
before_context: vec![],
after_context: vec![],
ast_context: None,
score: 0.5,
}
],
metadata: crate::search::FileMetadata {
size: 100,
language: Some("rust".to_string()),
encoding: "UTF-8".to_string(),
modified: std::time::SystemTime::now(),
},
#[cfg(feature = "ai")]
ai_insights: None,
},
SearchResult {
path: PathBuf::from("file2.rs"),
matches: vec![
crate::search::Match {
line_number: 1,
column_range: 0..4,
line_text: "test".to_string(),
before_context: vec![],
after_context: vec![],
ast_context: None,
score: 0.8,
}
],
metadata: crate::search::FileMetadata {
size: 200,
language: Some("rust".to_string()),
encoding: "UTF-8".to_string(),
modified: std::time::SystemTime::now(),
},
#[cfg(feature = "ai")]
ai_insights: None,
},
];
engine.sort_results(&mut results);
assert_eq!(results[0].path, PathBuf::from("file2.rs"));
assert_eq!(results[1].path, PathBuf::from("file1.rs"));
}
}