Skip to main content

agent_air_runtime/controller/tools/
grep.rs

1//! Grep tool implementation using ripgrep for high-performance file searching.
2//!
3//! This tool allows the LLM to search file contents using regex patterns.
4//! It integrates with the PermissionRegistry to require user approval
5//! before performing read operations on directories.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::path::{Path, PathBuf};
10use std::pin::Pin;
11use std::sync::Arc;
12
13use globset::{Glob, GlobMatcher};
14use grep_matcher::Matcher;
15use grep_regex::RegexMatcherBuilder;
16use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder};
17use walkdir::WalkDir;
18
19use super::types::{
20    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
21};
22use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
23
24/// Grep tool name constant.
25pub const GREP_TOOL_NAME: &str = "grep";
26
27/// Grep tool description constant.
28pub const GREP_TOOL_DESCRIPTION: &str = r#"A powerful search tool built on ripgrep for searching file contents.
29
30Usage:
31- Supports full regex syntax (e.g., "log.*Error", "function\s+\w+")
32- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
33- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
34- Pattern syntax uses ripgrep - literal braces need escaping
35
36Output Modes:
37- "files_with_matches" (default): Returns only file paths that contain matches
38- "content": Returns matching lines with optional context
39- "count": Returns match count per file
40
41Context Options (only with output_mode: "content"):
42- -A: Lines after each match
43- -B: Lines before each match
44- -C: Lines before and after (context)
45- -n: Show line numbers (default: true)
46
47Examples:
48- Search for "TODO" in all files: pattern="TODO"
49- Search in Rust files only: pattern="impl.*Trait", type="rust"
50- Search with context: pattern="error", output_mode="content", -C=3
51- Case insensitive: pattern="error", -i=true"#;
52
53/// Grep tool JSON schema constant.
54pub const GREP_TOOL_SCHEMA: &str = r#"{
55    "type": "object",
56    "properties": {
57        "pattern": {
58            "type": "string",
59            "description": "The regular expression pattern to search for"
60        },
61        "path": {
62            "type": "string",
63            "description": "File or directory to search in. Defaults to current directory."
64        },
65        "glob": {
66            "type": "string",
67            "description": "Glob pattern to filter files (e.g., '*.js', '*.{ts,tsx}')"
68        },
69        "type": {
70            "type": "string",
71            "description": "File type to search (e.g., 'js', 'py', 'rust', 'go'). More efficient than glob for standard types."
72        },
73        "output_mode": {
74            "type": "string",
75            "enum": ["files_with_matches", "content", "count"],
76            "description": "Output mode. Defaults to 'files_with_matches'."
77        },
78        "-i": {
79            "type": "boolean",
80            "description": "Case insensitive search. Defaults to false."
81        },
82        "-n": {
83            "type": "boolean",
84            "description": "Show line numbers (content mode only). Defaults to true."
85        },
86        "-A": {
87            "type": "integer",
88            "description": "Lines to show after each match (content mode only)."
89        },
90        "-B": {
91            "type": "integer",
92            "description": "Lines to show before each match (content mode only)."
93        },
94        "-C": {
95            "type": "integer",
96            "description": "Lines to show before and after each match (content mode only)."
97        },
98        "multiline": {
99            "type": "boolean",
100            "description": "Enable multiline mode where . matches newlines. Defaults to false."
101        },
102        "limit": {
103            "type": "integer",
104            "description": "Maximum number of results to return. Defaults to 1000."
105        }
106    },
107    "required": ["pattern"]
108}"#;
109
110/// Output mode for grep results.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum OutputMode {
113    /// Return only file paths that contain matches.
114    FilesWithMatches,
115    /// Return matching lines with optional context.
116    Content,
117    /// Return match count per file.
118    Count,
119}
120
121impl OutputMode {
122    fn from_str(s: &str) -> Self {
123        match s {
124            "content" => OutputMode::Content,
125            "count" => OutputMode::Count,
126            _ => OutputMode::FilesWithMatches,
127        }
128    }
129}
130
131/// Tool that searches file contents using ripgrep with permission checks.
132pub struct GrepTool {
133    /// Reference to the permission registry for requesting read permissions.
134    permission_registry: Arc<PermissionRegistry>,
135    /// Default search path if none provided.
136    default_path: Option<PathBuf>,
137}
138
139impl GrepTool {
140    /// Create a new GrepTool with the given permission registry.
141    ///
142    /// # Arguments
143    /// * `permission_registry` - The registry used to request and cache permissions.
144    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
145        Self {
146            permission_registry,
147            default_path: None,
148        }
149    }
150
151    /// Create a new GrepTool with a default search path.
152    ///
153    /// # Arguments
154    /// * `permission_registry` - The registry used to request and cache permissions.
155    /// * `default_path` - The default directory to search if no path is provided.
156    pub fn with_default_path(
157        permission_registry: Arc<PermissionRegistry>,
158        default_path: PathBuf,
159    ) -> Self {
160        Self {
161            permission_registry,
162            default_path: Some(default_path),
163        }
164    }
165
166    /// Builds a permission request for searching files in a path.
167    fn build_permission_request(tool_use_id: &str, search_path: &str) -> PermissionRequest {
168        let path = Path::new(search_path);
169        let reason = "Search file contents using grep";
170
171        PermissionRequest::new(
172            tool_use_id,
173            GrantTarget::path(path, true), // recursive for grep
174            PermissionLevel::Read,
175            format!("Search files in: {}", path.display()),
176        )
177        .with_reason(reason)
178        .with_tool(GREP_TOOL_NAME)
179    }
180
181    /// Get file type extensions for a given type name.
182    fn get_type_extensions(file_type: &str) -> Vec<&'static str> {
183        match file_type {
184            "js" | "javascript" => vec!["js", "mjs", "cjs"],
185            "ts" | "typescript" => vec!["ts", "mts", "cts"],
186            "tsx" => vec!["tsx"],
187            "jsx" => vec!["jsx"],
188            "py" | "python" => vec!["py", "pyi"],
189            "rust" | "rs" => vec!["rs"],
190            "go" => vec!["go"],
191            "java" => vec!["java"],
192            "c" => vec!["c", "h"],
193            "cpp" | "c++" => vec!["cpp", "cc", "cxx", "hpp", "hh", "hxx"],
194            "rb" | "ruby" => vec!["rb"],
195            "php" => vec!["php"],
196            "swift" => vec!["swift"],
197            "kotlin" | "kt" => vec!["kt", "kts"],
198            "scala" => vec!["scala"],
199            "md" | "markdown" => vec!["md", "markdown"],
200            "json" => vec!["json"],
201            "yaml" | "yml" => vec!["yaml", "yml"],
202            "toml" => vec!["toml"],
203            "xml" => vec!["xml"],
204            "html" => vec!["html", "htm"],
205            "css" => vec!["css"],
206            "sql" => vec!["sql"],
207            "sh" | "bash" => vec!["sh", "bash"],
208            _ => vec![],
209        }
210    }
211}
212
213impl Executable for GrepTool {
214    fn name(&self) -> &str {
215        GREP_TOOL_NAME
216    }
217
218    fn description(&self) -> &str {
219        GREP_TOOL_DESCRIPTION
220    }
221
222    fn input_schema(&self) -> &str {
223        GREP_TOOL_SCHEMA
224    }
225
226    fn tool_type(&self) -> ToolType {
227        ToolType::FileRead
228    }
229
230    fn execute(
231        &self,
232        context: ToolContext,
233        input: HashMap<String, serde_json::Value>,
234    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
235        let permission_registry = self.permission_registry.clone();
236        let default_path = self.default_path.clone();
237
238        Box::pin(async move {
239            // ─────────────────────────────────────────────────────────────
240            // Step 1: Extract and validate parameters
241            // ─────────────────────────────────────────────────────────────
242            let pattern = input
243                .get("pattern")
244                .and_then(|v| v.as_str())
245                .ok_or_else(|| "Missing required 'pattern' parameter".to_string())?;
246
247            let search_path = input
248                .get("path")
249                .and_then(|v| v.as_str())
250                .map(PathBuf::from)
251                .or(default_path)
252                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
253
254            let search_path_str = search_path.to_string_lossy().to_string();
255
256            // Verify path exists
257            if !search_path.exists() {
258                return Err(format!("Search path does not exist: {}", search_path_str));
259            }
260
261            let glob_pattern = input.get("glob").and_then(|v| v.as_str());
262            let file_type = input.get("type").and_then(|v| v.as_str());
263
264            let output_mode = input
265                .get("output_mode")
266                .and_then(|v| v.as_str())
267                .map(OutputMode::from_str)
268                .unwrap_or(OutputMode::FilesWithMatches);
269
270            let case_insensitive = input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
271
272            let show_line_numbers = input.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
273
274            let context_after = input
275                .get("-A")
276                .and_then(|v| v.as_i64())
277                .map(|v| v.max(0) as usize)
278                .unwrap_or(0);
279
280            let context_before = input
281                .get("-B")
282                .and_then(|v| v.as_i64())
283                .map(|v| v.max(0) as usize)
284                .unwrap_or(0);
285
286            let context_lines = input
287                .get("-C")
288                .and_then(|v| v.as_i64())
289                .map(|v| v.max(0) as usize)
290                .unwrap_or(0);
291
292            // -C overrides -A and -B
293            let (context_before, context_after) = if context_lines > 0 {
294                (context_lines, context_lines)
295            } else {
296                (context_before, context_after)
297            };
298
299            let multiline = input
300                .get("multiline")
301                .and_then(|v| v.as_bool())
302                .unwrap_or(false);
303
304            let limit = input
305                .get("limit")
306                .and_then(|v| v.as_i64())
307                .map(|v| v.max(1) as usize)
308                .unwrap_or(1000);
309
310            // ─────────────────────────────────────────────────────────────
311            // Step 2: Request permission if not pre-approved by batch executor
312            // ─────────────────────────────────────────────────────────────
313            if !context.permissions_pre_approved {
314                let permission_request =
315                    Self::build_permission_request(&context.tool_use_id, &search_path_str);
316
317                let response_rx = permission_registry
318                    .request_permission(
319                        context.session_id,
320                        permission_request,
321                        context.turn_id.clone(),
322                    )
323                    .await
324                    .map_err(|e| format!("Failed to request permission: {}", e))?;
325
326                let response = response_rx
327                    .await
328                    .map_err(|_| "Permission request was cancelled".to_string())?;
329
330                if !response.granted {
331                    let reason = response
332                        .message
333                        .unwrap_or_else(|| "Permission denied by user".to_string());
334                    return Err(format!(
335                        "Permission denied to search '{}': {}",
336                        search_path_str, reason
337                    ));
338                }
339            }
340
341            // ─────────────────────────────────────────────────────────────
342            // Step 3: Build regex matcher
343            // ─────────────────────────────────────────────────────────────
344            let matcher = RegexMatcherBuilder::new()
345                .case_insensitive(case_insensitive)
346                .multi_line(multiline)
347                .dot_matches_new_line(multiline)
348                .build(pattern)
349                .map_err(|e| format!("Invalid regex pattern: {}", e))?;
350
351            // ─────────────────────────────────────────────────────────────
352            // Step 8: Build file glob filter if provided
353            // ─────────────────────────────────────────────────────────────
354            let glob_matcher: Option<GlobMatcher> = if let Some(glob_str) = glob_pattern {
355                Some(
356                    Glob::new(glob_str)
357                        .map_err(|e| format!("Invalid glob pattern: {}", e))?
358                        .compile_matcher(),
359                )
360            } else {
361                None
362            };
363
364            // Get file type extensions if provided
365            let type_extensions: Option<Vec<&str>> = file_type.map(Self::get_type_extensions);
366
367            // ─────────────────────────────────────────────────────────────
368            // Step 9: Build searcher with context options
369            // ─────────────────────────────────────────────────────────────
370            let mut searcher_builder = SearcherBuilder::new();
371            searcher_builder
372                .binary_detection(BinaryDetection::quit(0))
373                .line_number(show_line_numbers);
374
375            if context_before > 0 || context_after > 0 {
376                searcher_builder
377                    .before_context(context_before)
378                    .after_context(context_after);
379            }
380
381            let mut searcher = searcher_builder.build();
382
383            // ─────────────────────────────────────────────────────────────
384            // Step 10: Collect files to search
385            // ─────────────────────────────────────────────────────────────
386            let files: Vec<PathBuf> = if search_path.is_file() {
387                vec![search_path.clone()]
388            } else {
389                let search_path_clone = search_path.clone();
390                WalkDir::new(&search_path)
391                    .follow_links(false)
392                    .into_iter()
393                    .filter_entry(move |e| {
394                        // Skip hidden directories, but not the root search path itself
395                        let is_root = e.path() == search_path_clone;
396                        is_root || !e.file_name().to_string_lossy().starts_with('.')
397                    })
398                    .filter_map(|e| e.ok())
399                    .filter(|e| e.file_type().is_file())
400                    .filter(|e| {
401                        let path = e.path();
402
403                        // Apply glob filter
404                        if let Some(ref gm) = glob_matcher {
405                            let relative = path.strip_prefix(&search_path).unwrap_or(path);
406                            if !gm.is_match(relative) {
407                                return false;
408                            }
409                        }
410
411                        // Apply type filter
412                        if let Some(ref exts) = type_extensions {
413                            if exts.is_empty() {
414                                // Unknown type, don't filter
415                                return true;
416                            }
417                            if let Some(ext) = path.extension() {
418                                let ext_str = ext.to_string_lossy().to_lowercase();
419                                if !exts.iter().any(|e| *e == ext_str) {
420                                    return false;
421                                }
422                            } else {
423                                return false;
424                            }
425                        }
426
427                        true
428                    })
429                    .map(|e| e.path().to_path_buf())
430                    .collect()
431            };
432
433            // ─────────────────────────────────────────────────────────────
434            // Step 11: Execute search based on output mode
435            // ─────────────────────────────────────────────────────────────
436            match output_mode {
437                OutputMode::FilesWithMatches => {
438                    search_files_with_matches(&mut searcher, &matcher, &files, limit)
439                }
440                OutputMode::Content => {
441                    search_content(&mut searcher, &matcher, &files, show_line_numbers, limit)
442                }
443                OutputMode::Count => search_count(&mut searcher, &matcher, &files, limit),
444            }
445        })
446    }
447
448    fn display_config(&self) -> DisplayConfig {
449        DisplayConfig {
450            display_name: "Grep".to_string(),
451            display_title: Box::new(|input| {
452                input
453                    .get("pattern")
454                    .and_then(|v| v.as_str())
455                    .map(|p| {
456                        if p.len() > 30 {
457                            format!("{}...", &p[..30])
458                        } else {
459                            p.to_string()
460                        }
461                    })
462                    .unwrap_or_default()
463            }),
464            display_content: Box::new(|_input, result| {
465                let lines: Vec<&str> = result.lines().take(30).collect();
466                let total_lines = result.lines().count();
467
468                DisplayResult {
469                    content: lines.join("\n"),
470                    content_type: ResultContentType::PlainText,
471                    is_truncated: total_lines > 30,
472                    full_length: total_lines,
473                }
474            }),
475        }
476    }
477
478    fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, result: &str) -> String {
479        let pattern = input
480            .get("pattern")
481            .and_then(|v| v.as_str())
482            .map(|p| {
483                if p.len() > 20 {
484                    format!("{}...", &p[..20])
485                } else {
486                    p.to_string()
487                }
488            })
489            .unwrap_or_else(|| "?".to_string());
490
491        let match_count = result.lines().filter(|line| !line.is_empty()).count();
492
493        format!("[Grep: '{}' ({} matches)]", pattern, match_count)
494    }
495
496    fn required_permissions(
497        &self,
498        context: &ToolContext,
499        input: &HashMap<String, serde_json::Value>,
500    ) -> Option<Vec<PermissionRequest>> {
501        // Extract the path from input or use default_path
502        let search_path = input
503            .get("path")
504            .and_then(|v| v.as_str())
505            .map(PathBuf::from)
506            .or_else(|| self.default_path.clone())
507            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
508
509        let search_path_str = search_path.to_string_lossy().to_string();
510
511        // Build the permission request using the existing helper method
512        let permission_request =
513            Self::build_permission_request(&context.tool_use_id, &search_path_str);
514
515        Some(vec![permission_request])
516    }
517}
518
519/// Search for files containing matches (files_with_matches mode).
520fn search_files_with_matches<M: Matcher>(
521    searcher: &mut Searcher,
522    matcher: &M,
523    files: &[PathBuf],
524    limit: usize,
525) -> Result<String, String> {
526    let mut matching_files = Vec::new();
527
528    for file in files {
529        if matching_files.len() >= limit {
530            break;
531        }
532
533        let mut found = false;
534        let sink = grep_searcher::sinks::UTF8(|_line_num, _line| {
535            found = true;
536            Ok(false) // Stop after first match
537        });
538
539        // Ignore errors for individual files (binary files, permission issues, etc.)
540        let _ = searcher.search_path(matcher, file, sink);
541
542        if found {
543            matching_files.push(file.display().to_string());
544        }
545    }
546
547    if matching_files.is_empty() {
548        Ok("No matches found".to_string())
549    } else {
550        Ok(matching_files.join("\n"))
551    }
552}
553
554/// Search for matching content (content mode).
555fn search_content<M: Matcher>(
556    searcher: &mut Searcher,
557    matcher: &M,
558    files: &[PathBuf],
559    show_line_numbers: bool,
560    limit: usize,
561) -> Result<String, String> {
562    let mut output = String::new();
563    let mut total_matches = 0;
564
565    for file in files {
566        if total_matches >= limit {
567            break;
568        }
569
570        let mut file_output = String::new();
571        let mut file_matches = 0;
572        let file_path = file.clone();
573
574        let sink = grep_searcher::sinks::UTF8(|line_num, line| {
575            if total_matches + file_matches >= limit {
576                return Ok(false);
577            }
578
579            if show_line_numbers {
580                file_output.push_str(&format!(
581                    "{}:{}: {}",
582                    file_path.display(),
583                    line_num,
584                    line.trim_end()
585                ));
586            } else {
587                file_output.push_str(&format!("{}: {}", file_path.display(), line.trim_end()));
588            }
589            file_output.push('\n');
590            file_matches += 1;
591
592            Ok(true)
593        });
594
595        // Ignore errors for individual files
596        let _ = searcher.search_path(matcher, file, sink);
597
598        if file_matches > 0 {
599            output.push_str(&file_output);
600            total_matches += file_matches;
601        }
602    }
603
604    if output.is_empty() {
605        Ok("No matches found".to_string())
606    } else {
607        Ok(output.trim_end().to_string())
608    }
609}
610
611/// Search and count matches per file (count mode).
612fn search_count<M: Matcher>(
613    searcher: &mut Searcher,
614    matcher: &M,
615    files: &[PathBuf],
616    limit: usize,
617) -> Result<String, String> {
618    let mut results = Vec::new();
619
620    for file in files {
621        if results.len() >= limit {
622            break;
623        }
624
625        let mut count = 0u64;
626        let sink = grep_searcher::sinks::UTF8(|_line_num, _line| {
627            count += 1;
628            Ok(true)
629        });
630
631        // Ignore errors for individual files
632        let _ = searcher.search_path(matcher, file, sink);
633
634        if count > 0 {
635            results.push(format!("{}:{}", file.display(), count));
636        }
637    }
638
639    if results.is_empty() {
640        Ok("No matches found".to_string())
641    } else {
642        Ok(results.join("\n"))
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use crate::controller::PermissionPanelResponse;
650    use crate::controller::types::ControllerEvent;
651    use crate::permissions::GrantTarget;
652    use std::fs;
653    use tempfile::TempDir;
654    use tokio::sync::mpsc;
655
656    /// Helper to create a permission registry for testing.
657    fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
658        let (tx, rx) = mpsc::channel(16);
659        let registry = Arc::new(PermissionRegistry::new(tx));
660        (registry, rx)
661    }
662
663    fn grant_once() -> PermissionPanelResponse {
664        PermissionPanelResponse {
665            granted: true,
666            grant: None,
667            message: None,
668        }
669    }
670
671    fn deny(reason: &str) -> PermissionPanelResponse {
672        PermissionPanelResponse {
673            granted: false,
674            grant: None,
675            message: Some(reason.to_string()),
676        }
677    }
678
679    fn setup_test_files() -> TempDir {
680        let temp = TempDir::new().unwrap();
681
682        fs::write(
683            temp.path().join("test.rs"),
684            r#"fn main() {
685    let error = "something wrong";
686    println!("Error: {}", error);
687}
688"#,
689        )
690        .unwrap();
691
692        fs::write(
693            temp.path().join("lib.rs"),
694            r#"pub fn handle_error(e: Error) {
695    eprintln!("Error occurred: {}", e);
696}
697"#,
698        )
699        .unwrap();
700
701        fs::write(
702            temp.path().join("test.js"),
703            r#"function handleError(err) {
704    console.error("Error:", err);
705}
706"#,
707        )
708        .unwrap();
709
710        temp
711    }
712
713    #[tokio::test]
714    async fn test_simple_search_with_permission() {
715        let temp = setup_test_files();
716        let (registry, mut event_rx) = create_test_registry();
717        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
718
719        let mut input = HashMap::new();
720        input.insert(
721            "pattern".to_string(),
722            serde_json::Value::String("error".to_string()),
723        );
724        input.insert("-i".to_string(), serde_json::Value::Bool(true));
725
726        let context = ToolContext {
727            session_id: 1,
728            tool_use_id: "test-grep-1".to_string(),
729            turn_id: None,
730            permissions_pre_approved: false,
731        };
732
733        // Grant permission in background
734        let registry_clone = registry.clone();
735        tokio::spawn(async move {
736            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
737                event_rx.recv().await
738            {
739                registry_clone
740                    .respond_to_request(&tool_use_id, grant_once())
741                    .await
742                    .unwrap();
743            }
744        });
745
746        let result = tool.execute(context, input).await;
747        assert!(result.is_ok());
748        let output = result.unwrap();
749        // Should find matches in multiple files
750        assert!(output.contains("test.rs") || output.contains("lib.rs"));
751    }
752
753    #[tokio::test]
754    async fn test_search_permission_denied() {
755        let temp = setup_test_files();
756        let (registry, mut event_rx) = create_test_registry();
757        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
758
759        let mut input = HashMap::new();
760        input.insert(
761            "pattern".to_string(),
762            serde_json::Value::String("error".to_string()),
763        );
764
765        let context = ToolContext {
766            session_id: 1,
767            tool_use_id: "test-grep-denied".to_string(),
768            turn_id: None,
769            permissions_pre_approved: false,
770        };
771
772        // Deny permission
773        let registry_clone = registry.clone();
774        tokio::spawn(async move {
775            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
776                event_rx.recv().await
777            {
778                registry_clone
779                    .respond_to_request(&tool_use_id, deny("Access denied"))
780                    .await
781                    .unwrap();
782            }
783        });
784
785        let result = tool.execute(context, input).await;
786        assert!(result.is_err());
787        assert!(result.unwrap_err().contains("Permission denied"));
788    }
789
790    #[tokio::test]
791    async fn test_content_mode() {
792        let temp = setup_test_files();
793        let (registry, mut event_rx) = create_test_registry();
794        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
795
796        let mut input = HashMap::new();
797        input.insert(
798            "pattern".to_string(),
799            serde_json::Value::String("Error".to_string()),
800        );
801        input.insert(
802            "output_mode".to_string(),
803            serde_json::Value::String("content".to_string()),
804        );
805
806        let context = ToolContext {
807            session_id: 1,
808            tool_use_id: "test-grep-content".to_string(),
809            turn_id: None,
810            permissions_pre_approved: false,
811        };
812
813        let registry_clone = registry.clone();
814        tokio::spawn(async move {
815            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
816                event_rx.recv().await
817            {
818                registry_clone
819                    .respond_to_request(&tool_use_id, grant_once())
820                    .await
821                    .unwrap();
822            }
823        });
824
825        let result = tool.execute(context, input).await;
826        assert!(result.is_ok());
827        let output = result.unwrap();
828        // Content mode should include line content
829        assert!(output.contains("Error"));
830    }
831
832    #[tokio::test]
833    async fn test_count_mode() {
834        let temp = setup_test_files();
835        let (registry, mut event_rx) = create_test_registry();
836        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
837
838        let mut input = HashMap::new();
839        input.insert(
840            "pattern".to_string(),
841            serde_json::Value::String("Error".to_string()),
842        );
843        input.insert(
844            "output_mode".to_string(),
845            serde_json::Value::String("count".to_string()),
846        );
847
848        let context = ToolContext {
849            session_id: 1,
850            tool_use_id: "test-grep-count".to_string(),
851            turn_id: None,
852            permissions_pre_approved: false,
853        };
854
855        let registry_clone = registry.clone();
856        tokio::spawn(async move {
857            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
858                event_rx.recv().await
859            {
860                registry_clone
861                    .respond_to_request(&tool_use_id, grant_once())
862                    .await
863                    .unwrap();
864            }
865        });
866
867        let result = tool.execute(context, input).await;
868        assert!(result.is_ok());
869        let output = result.unwrap();
870        // Count mode should include file paths with counts
871        assert!(output.contains(":"));
872    }
873
874    #[tokio::test]
875    async fn test_type_filter() {
876        let temp = setup_test_files();
877        let (registry, mut event_rx) = create_test_registry();
878        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
879
880        let mut input = HashMap::new();
881        input.insert(
882            "pattern".to_string(),
883            serde_json::Value::String("function".to_string()),
884        );
885        input.insert(
886            "type".to_string(),
887            serde_json::Value::String("js".to_string()),
888        );
889
890        let context = ToolContext {
891            session_id: 1,
892            tool_use_id: "test-grep-type".to_string(),
893            turn_id: None,
894            permissions_pre_approved: false,
895        };
896
897        let registry_clone = registry.clone();
898        tokio::spawn(async move {
899            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
900                event_rx.recv().await
901            {
902                registry_clone
903                    .respond_to_request(&tool_use_id, grant_once())
904                    .await
905                    .unwrap();
906            }
907        });
908
909        let result = tool.execute(context, input).await;
910        assert!(result.is_ok());
911        let output = result.unwrap();
912        // Should only find matches in .js files
913        assert!(output.contains("test.js"));
914        assert!(!output.contains(".rs"));
915    }
916
917    #[tokio::test]
918    async fn test_invalid_pattern() {
919        let temp = setup_test_files();
920        let (registry, mut event_rx) = create_test_registry();
921        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
922
923        let mut input = HashMap::new();
924        // Invalid regex pattern (unbalanced parentheses)
925        input.insert(
926            "pattern".to_string(),
927            serde_json::Value::String("(invalid".to_string()),
928        );
929
930        let context = ToolContext {
931            session_id: 1,
932            tool_use_id: "test-grep-invalid".to_string(),
933            turn_id: None,
934            permissions_pre_approved: false,
935        };
936
937        let registry_clone = registry.clone();
938        tokio::spawn(async move {
939            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
940                event_rx.recv().await
941            {
942                registry_clone
943                    .respond_to_request(&tool_use_id, grant_once())
944                    .await
945                    .unwrap();
946            }
947        });
948
949        let result = tool.execute(context, input).await;
950        assert!(result.is_err());
951        assert!(result.unwrap_err().contains("Invalid regex pattern"));
952    }
953
954    #[tokio::test]
955    async fn test_missing_pattern() {
956        let (registry, _event_rx) = create_test_registry();
957        let tool = GrepTool::new(registry);
958
959        let input = HashMap::new();
960
961        let context = ToolContext {
962            session_id: 1,
963            tool_use_id: "test".to_string(),
964            turn_id: None,
965            permissions_pre_approved: false,
966        };
967
968        let result = tool.execute(context, input).await;
969        assert!(result.is_err());
970        assert!(result.unwrap_err().contains("Missing required 'pattern'"));
971    }
972
973    #[tokio::test]
974    async fn test_nonexistent_path() {
975        let (registry, _event_rx) = create_test_registry();
976        let tool = GrepTool::new(registry);
977
978        let mut input = HashMap::new();
979        input.insert(
980            "pattern".to_string(),
981            serde_json::Value::String("test".to_string()),
982        );
983        input.insert(
984            "path".to_string(),
985            serde_json::Value::String("/nonexistent/path".to_string()),
986        );
987
988        let context = ToolContext {
989            session_id: 1,
990            tool_use_id: "test".to_string(),
991            turn_id: None,
992            permissions_pre_approved: false,
993        };
994
995        let result = tool.execute(context, input).await;
996        assert!(result.is_err());
997        assert!(result.unwrap_err().contains("does not exist"));
998    }
999
1000    #[test]
1001    fn test_compact_summary() {
1002        let (registry, _event_rx) = create_test_registry();
1003        let tool = GrepTool::new(registry);
1004
1005        let mut input = HashMap::new();
1006        input.insert(
1007            "pattern".to_string(),
1008            serde_json::Value::String("impl.*Trait".to_string()),
1009        );
1010
1011        let result = "file1.rs\nfile2.rs\nfile3.rs";
1012        let summary = tool.compact_summary(&input, result);
1013        assert_eq!(summary, "[Grep: 'impl.*Trait' (3 matches)]");
1014    }
1015
1016    #[test]
1017    fn test_compact_summary_long_pattern() {
1018        let (registry, _event_rx) = create_test_registry();
1019        let tool = GrepTool::new(registry);
1020
1021        let mut input = HashMap::new();
1022        input.insert(
1023            "pattern".to_string(),
1024            serde_json::Value::String(
1025                "this_is_a_very_long_pattern_that_should_be_truncated".to_string(),
1026            ),
1027        );
1028
1029        let result = "file1.rs";
1030        let summary = tool.compact_summary(&input, result);
1031        assert!(summary.contains("..."));
1032        assert!(summary.len() < 100);
1033    }
1034
1035    #[test]
1036    fn test_build_permission_request() {
1037        let request = GrepTool::build_permission_request("test-tool-id", "/path/to/src");
1038
1039        assert_eq!(request.description, "Search files in: /path/to/src");
1040        assert_eq!(
1041            request.reason,
1042            Some("Search file contents using grep".to_string())
1043        );
1044        assert_eq!(request.target, GrantTarget::path("/path/to/src", true));
1045        assert_eq!(request.required_level, PermissionLevel::Read);
1046    }
1047
1048    #[test]
1049    fn test_get_type_extensions() {
1050        assert_eq!(GrepTool::get_type_extensions("rust"), vec!["rs"]);
1051        assert_eq!(
1052            GrepTool::get_type_extensions("js"),
1053            vec!["js", "mjs", "cjs"]
1054        );
1055        assert_eq!(GrepTool::get_type_extensions("py"), vec!["py", "pyi"]);
1056        assert!(GrepTool::get_type_extensions("unknown").is_empty());
1057    }
1058}