vtcode_core/tools/
search.rs

1//! Search tool implementation with multiple modes
2
3use super::traits::{CacheableTool, ModeTool, Tool};
4use crate::config::constants::tools;
5use crate::tools::grep_search::{GrepSearchInput, GrepSearchManager};
6use anyhow::{Result, anyhow};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12/// Unified search tool with multiple modes
13#[derive(Clone)]
14pub struct SearchTool {
15    workspace_root: PathBuf,
16    grep_search: Arc<GrepSearchManager>,
17}
18
19impl SearchTool {
20    pub fn new(workspace_root: PathBuf, grep_search: Arc<GrepSearchManager>) -> Self {
21        Self {
22            workspace_root,
23            grep_search,
24        }
25    }
26
27    /// Execute exact search mode
28    async fn execute_exact(&self, args: Value) -> Result<Value> {
29        let pattern = args
30            .get("pattern")
31            .and_then(|p| p.as_str())
32            .ok_or_else(|| anyhow!("Error: Missing 'pattern'. Example: grep_search({{\"pattern\": \"TODO|FIXME\", \"path\": \"src\"}})"))?;
33
34        let input = GrepSearchInput {
35            pattern: pattern.to_string(),
36            path: args
37                .get("path")
38                .and_then(|p| p.as_str())
39                .unwrap_or(".")
40                .to_string(),
41            max_results: Some(
42                args.get("max_results")
43                    .and_then(|m| m.as_u64())
44                    .unwrap_or(100) as usize,
45            ),
46            case_sensitive: Some(
47                args.get("case_sensitive")
48                    .and_then(|c| c.as_bool())
49                    .unwrap_or(true),
50            ),
51            literal: Some(false),
52            glob_pattern: None,
53            context_lines: Some(0),
54            include_hidden: Some(false),
55        };
56
57        let result = self.grep_search.perform_search(input.clone()).await?;
58
59        // Response formatting
60        let concise = args
61            .get("response_format")
62            .and_then(|v| v.as_str())
63            .map(|s| s.eq_ignore_ascii_case("concise"))
64            .unwrap_or(true);
65
66        let mut body = if concise {
67            let concise_matches = transform_matches_to_concise(&result.matches);
68            json!({
69                "success": true,
70                "matches": concise_matches,
71                "mode": "exact",
72                "response_format": "concise"
73            })
74        } else {
75            json!({
76                "success": true,
77                "matches": result.matches,
78                "mode": "exact",
79                "response_format": "detailed"
80            })
81        };
82
83        if let Some(max) = input.max_results {
84            // Heuristic: if we hit the cap, hint pagination/filtering
85            if let Some(arr) = body.get("matches").and_then(|m| m.as_array()) {
86                if arr.len() >= max {
87                    body["message"] = json!(format!(
88                        "Showing {} results (limit). Narrow your query or use more specific patterns to reduce tokens.",
89                        max
90                    ));
91                }
92            }
93        }
94        Ok(body)
95    }
96
97    /// Execute fuzzy search mode
98    async fn execute_fuzzy(&self, args: Value) -> Result<Value> {
99        let pattern = args
100            .get("pattern")
101            .and_then(|p| p.as_str())
102            .ok_or_else(|| anyhow!("Error: Missing 'pattern'. Example: grep_search({{\"mode\": \"fuzzy\", \"pattern\": \"todo\", \"path\": \"src\"}})"))?;
103
104        let input = GrepSearchInput {
105            pattern: pattern.to_string(),
106            path: args
107                .get("path")
108                .and_then(|p| p.as_str())
109                .unwrap_or(".")
110                .to_string(),
111            max_results: Some(
112                args.get("max_results")
113                    .and_then(|m| m.as_u64())
114                    .unwrap_or(100) as usize,
115            ),
116            case_sensitive: Some(
117                args.get("case_sensitive")
118                    .and_then(|c| c.as_bool())
119                    .unwrap_or(false), // Default to case-insensitive for fuzzy search
120            ),
121            literal: Some(false),
122            glob_pattern: None,
123            context_lines: Some(0),
124            include_hidden: Some(false),
125        };
126
127        let result = self.grep_search.perform_search(input.clone()).await?;
128
129        // Response formatting
130        let concise = args
131            .get("response_format")
132            .and_then(|v| v.as_str())
133            .map(|s| s.eq_ignore_ascii_case("concise"))
134            .unwrap_or(true);
135
136        let mut body = if concise {
137            let concise_matches = transform_matches_to_concise(&result.matches);
138            json!({
139                "success": true,
140                "matches": concise_matches,
141                "mode": "fuzzy",
142                "case_sensitive": false,
143                "response_format": "concise"
144            })
145        } else {
146            json!({
147                "success": true,
148                "matches": result.matches,
149                "mode": "fuzzy",
150                "case_sensitive": false,
151                "response_format": "detailed"
152            })
153        };
154
155        if let Some(max) = input.max_results {
156            // Heuristic: if we hit the cap, hint pagination/filtering
157            if let Some(arr) = body.get("matches").and_then(|m| m.as_array()) {
158                if arr.len() >= max {
159                    body["message"] = json!(format!(
160                        "Showing {} results (limit). Narrow your query or use more specific patterns to reduce tokens.",
161                        max
162                    ));
163                }
164            }
165        }
166        Ok(body)
167    }
168
169    /// Execute multi-pattern search mode
170    async fn execute_multi(&self, args: Value) -> Result<Value> {
171        let args_obj = args
172            .as_object()
173            .ok_or_else(|| anyhow!("Error: Invalid 'multi' arguments. Required: {{ patterns: string[] }}. Optional: {{ logic: 'AND'|'OR' }}. Example: grep_search({{\"mode\": \"multi\", \"patterns\": [\"fn \\w+\", \"use \\w+\"], \"logic\": \"AND\"}})"))?;
174
175        let patterns = args_obj
176            .get("patterns")
177            .and_then(|p| p.as_array())
178            .ok_or_else(|| anyhow!("Missing patterns array for multi mode"))?;
179
180        let logic = args_obj
181            .get("logic")
182            .and_then(|l| l.as_str())
183            .unwrap_or("AND");
184
185        let mut all_results = Vec::new();
186
187        // Execute search for each pattern
188        for pattern in patterns {
189            if let Some(pattern_str) = pattern.as_str() {
190                let mut pattern_args = args.clone();
191                if let Some(obj) = pattern_args.as_object_mut() {
192                    obj.insert("pattern".to_string(), json!(pattern_str));
193                }
194
195                match self.execute_exact(pattern_args).await {
196                    Ok(result) => {
197                        if let Some(matches) = result.get("matches").and_then(|m| m.as_array()) {
198                            all_results.extend(matches.clone());
199                        }
200                    }
201                    Err(_) => continue, // Skip failed patterns
202                }
203            }
204        }
205
206        // Apply logic (AND/OR) to combine results
207        let final_results = if logic == "AND" {
208            self.apply_and_logic(all_results, patterns.len())
209        } else {
210            self.apply_or_logic(all_results)
211        };
212
213        Ok(json!({
214            "success": true,
215            "matches": final_results,
216            "mode": "multi",
217            "logic": logic,
218            "pattern_count": patterns.len()
219        }))
220    }
221
222    /// Execute similarity search mode
223    async fn execute_similarity(&self, args: Value) -> Result<Value> {
224        let args_obj = args
225            .as_object()
226            .ok_or_else(|| anyhow!("Error: Invalid 'similarity' arguments. Required: {{ reference_file: string }}. Optional: {{ content_type: 'structure'|'imports'|'functions'|'all' }}. Example: grep_search({{\"mode\": \"similarity\", \"reference_file\": \"src/lib.rs\", \"content_type\": \"functions\"}})"))?;
227
228        let reference_file = args_obj
229            .get("reference_file")
230            .and_then(|f| f.as_str())
231            .ok_or_else(|| anyhow!("Error: Missing 'reference_file'. Example: grep_search({{\"mode\": \"similarity\", \"reference_file\": \"src/main.rs\"}})"))?;
232
233        let content_type = args_obj
234            .get("content_type")
235            .and_then(|c| c.as_str())
236            .unwrap_or("all");
237
238        // Read reference file to extract patterns
239        let ref_path = self.workspace_root.join(reference_file);
240        let ref_content = tokio::fs::read_to_string(&ref_path).await.map_err(|e| {
241            anyhow!(
242                "Error: Failed to read reference file '{}': {}",
243                reference_file,
244                e
245            )
246        })?;
247
248        // Extract patterns based on content type
249        let patterns = self.extract_similarity_patterns(&ref_content, content_type)?;
250
251        // Execute multi-pattern search with OR logic
252        let mut search_args = args.clone();
253        if let Some(obj) = search_args.as_object_mut() {
254            obj.insert("patterns".to_string(), json!(patterns));
255            obj.insert("logic".to_string(), json!("OR"));
256        }
257
258        self.execute_multi(search_args).await
259    }
260
261    /// Apply AND logic to search results
262    fn apply_and_logic(&self, results: Vec<Value>, pattern_count: usize) -> Vec<Value> {
263        use std::collections::HashMap;
264
265        let mut file_matches: HashMap<String, Vec<Value>> = HashMap::new();
266
267        // Group matches by file
268        for result in results {
269            if let Some(path) = result.get("path").and_then(|p| p.as_str()) {
270                file_matches
271                    .entry(path.to_string())
272                    .or_default()
273                    .push(result);
274            }
275        }
276
277        // Only include files that have matches for all patterns
278        file_matches
279            .into_iter()
280            .filter(|(_, matches)| matches.len() >= pattern_count)
281            .flat_map(|(_, matches)| matches)
282            .collect()
283    }
284
285    /// Apply OR logic to search results (remove duplicates)
286    fn apply_or_logic(&self, results: Vec<Value>) -> Vec<Value> {
287        use std::collections::HashSet;
288
289        let mut seen = HashSet::new();
290        let mut unique_results = Vec::new();
291
292        for result in results {
293            let key = format!(
294                "{}:{}:{}",
295                result.get("path").and_then(|p| p.as_str()).unwrap_or(""),
296                result
297                    .get("line_number")
298                    .and_then(|l| l.as_u64())
299                    .unwrap_or(0),
300                result.get("column").and_then(|c| c.as_u64()).unwrap_or(0)
301            );
302
303            if seen.insert(key) {
304                unique_results.push(result);
305            }
306        }
307
308        unique_results
309    }
310
311    /// Extract patterns for similarity search
312    fn extract_similarity_patterns(
313        &self,
314        content: &str,
315        content_type: &str,
316    ) -> Result<Vec<String>> {
317        let mut patterns = Vec::new();
318
319        match content_type {
320            "functions" => {
321                // Extract function signatures
322                for line in content.lines() {
323                    if line.trim_start().starts_with("fn ")
324                        || line.trim_start().starts_with("pub fn ")
325                    {
326                        if let Some(name) = self.extract_function_name(line) {
327                            patterns.push(format!("fn {}", name));
328                        }
329                    }
330                }
331            }
332            "imports" => {
333                // Extract import statements
334                for line in content.lines() {
335                    if line.trim_start().starts_with("use ") {
336                        patterns.push(line.trim().to_string());
337                    }
338                }
339            }
340            "structure" => {
341                // Extract struct/enum definitions
342                for line in content.lines() {
343                    let trimmed = line.trim_start();
344                    if trimmed.starts_with("struct ") || trimmed.starts_with("enum ") {
345                        patterns.push(
346                            trimmed
347                                .split_whitespace()
348                                .take(2)
349                                .collect::<Vec<_>>()
350                                .join(" "),
351                        );
352                    }
353                }
354            }
355            _ => {
356                // Extract all significant keywords
357                patterns.extend(self.extract_keywords(content));
358            }
359        }
360
361        if patterns.is_empty() {
362            return Err(anyhow!(
363                "No patterns extracted from reference file. Try content_type='all' or provide a different reference_file."
364            ));
365        }
366
367        Ok(patterns)
368    }
369
370    /// Extract function name from function definition
371    fn extract_function_name(&self, line: &str) -> Option<String> {
372        let parts: Vec<&str> = line.split_whitespace().collect();
373        for (i, part) in parts.iter().enumerate() {
374            if *part == "fn" && i + 1 < parts.len() {
375                let name = parts[i + 1];
376                if let Some(paren_pos) = name.find('(') {
377                    return Some(name[..paren_pos].to_string());
378                }
379                return Some(name.to_string());
380            }
381        }
382        None
383    }
384
385    /// Extract keywords from content
386    fn extract_keywords(&self, content: &str) -> Vec<String> {
387        let keywords = ["fn ", "struct ", "enum ", "impl ", "trait ", "use ", "mod "];
388        let mut patterns = Vec::new();
389
390        for line in content.lines() {
391            for keyword in &keywords {
392                if line.contains(keyword) {
393                    patterns.push(keyword.trim().to_string());
394                }
395            }
396        }
397
398        patterns.sort();
399        patterns.dedup();
400        patterns
401    }
402}
403
404#[async_trait]
405impl Tool for SearchTool {
406    async fn execute(&self, args: Value) -> Result<Value> {
407        let args_clone = args.clone();
408        let mode = args_clone
409            .get("mode")
410            .and_then(|m| m.as_str())
411            .unwrap_or("exact");
412
413        self.execute_mode(mode, args).await
414    }
415
416    fn name(&self) -> &'static str {
417        tools::GREP_SEARCH
418    }
419
420    fn description(&self) -> &'static str {
421        "Enhanced unified search tool with multiple modes: exact (default), fuzzy, multi-pattern, and similarity search"
422    }
423}
424
425#[async_trait]
426impl ModeTool for SearchTool {
427    fn supported_modes(&self) -> Vec<&'static str> {
428        vec!["exact", "fuzzy", "multi", "similarity"]
429    }
430
431    async fn execute_mode(&self, mode: &str, args: Value) -> Result<Value> {
432        match mode {
433            "exact" => self.execute_exact(args).await,
434            "fuzzy" => self.execute_fuzzy(args).await,
435            "multi" => self.execute_multi(args).await,
436            "similarity" => self.execute_similarity(args).await,
437            _ => Err(anyhow!("Unsupported search mode: {}", mode)),
438        }
439    }
440}
441
442#[async_trait]
443impl CacheableTool for SearchTool {
444    fn cache_key(&self, args: &Value) -> String {
445        format!(
446            "search:{}:{}",
447            args.get("pattern").and_then(|p| p.as_str()).unwrap_or(""),
448            args.get("mode").and_then(|m| m.as_str()).unwrap_or("exact")
449        )
450    }
451
452    fn should_cache(&self, args: &Value) -> bool {
453        // Cache exact and fuzzy searches, but not multi/similarity (too dynamic)
454        let mode = args.get("mode").and_then(|m| m.as_str()).unwrap_or("exact");
455        matches!(mode, "exact" | "fuzzy")
456    }
457}
458
459/// Transform ripgrep JSON event stream into a concise, agent-friendly structure
460/// keeping only meaningful context for downstream actions.
461pub(crate) fn transform_matches_to_concise(events: &[Value]) -> Vec<Value> {
462    let mut out = Vec::new();
463    for ev in events {
464        if ev.get("type").and_then(|t| t.as_str()) != Some("match") {
465            continue;
466        }
467        if let Some(data) = ev.get("data") {
468            let path = data
469                .get("path")
470                .and_then(|p| p.get("text"))
471                .and_then(|t| t.as_str())
472                .unwrap_or("");
473            let line = data
474                .get("line_number")
475                .and_then(|n| n.as_u64())
476                .unwrap_or(0);
477            let preview = data
478                .get("lines")
479                .and_then(|l| l.get("text"))
480                .and_then(|t| t.as_str())
481                .unwrap_or("")
482                .trim_end_matches(['\r', '\n']);
483
484            out.push(json!({
485                "path": path,
486                "line_number": line,
487                "text": preview,
488            }));
489        }
490    }
491    out
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_transform_matches_to_concise() {
500        let raw = vec![
501            json!({
502                "type": "match",
503                "data": {
504                    "path": {"text": "src/main.rs"},
505                    "line_number": 10,
506                    "lines": {"text": "fn main() {}\n"}
507                }
508            }),
509            json!({"type": "begin"}),
510        ];
511        let concise = transform_matches_to_concise(&raw);
512        assert_eq!(concise.len(), 1);
513        assert_eq!(concise[0]["path"], "src/main.rs");
514        assert_eq!(concise[0]["line_number"], 10);
515        assert_eq!(concise[0]["text"], "fn main() {}");
516    }
517}