Skip to main content

agent_core_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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
20use super::types::{
21    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
22};
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(|| {
253                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
254                });
255
256            let search_path_str = search_path.to_string_lossy().to_string();
257
258            // Verify path exists
259            if !search_path.exists() {
260                return Err(format!(
261                    "Search path does not exist: {}",
262                    search_path_str
263                ));
264            }
265
266            let glob_pattern = input.get("glob").and_then(|v| v.as_str());
267            let file_type = input.get("type").and_then(|v| v.as_str());
268
269            let output_mode = input
270                .get("output_mode")
271                .and_then(|v| v.as_str())
272                .map(OutputMode::from_str)
273                .unwrap_or(OutputMode::FilesWithMatches);
274
275            let case_insensitive = input
276                .get("-i")
277                .and_then(|v| v.as_bool())
278                .unwrap_or(false);
279
280            let show_line_numbers = input
281                .get("-n")
282                .and_then(|v| v.as_bool())
283                .unwrap_or(true);
284
285            let context_after = input
286                .get("-A")
287                .and_then(|v| v.as_i64())
288                .map(|v| v.max(0) as usize)
289                .unwrap_or(0);
290
291            let context_before = input
292                .get("-B")
293                .and_then(|v| v.as_i64())
294                .map(|v| v.max(0) as usize)
295                .unwrap_or(0);
296
297            let context_lines = input
298                .get("-C")
299                .and_then(|v| v.as_i64())
300                .map(|v| v.max(0) as usize)
301                .unwrap_or(0);
302
303            // -C overrides -A and -B
304            let (context_before, context_after) = if context_lines > 0 {
305                (context_lines, context_lines)
306            } else {
307                (context_before, context_after)
308            };
309
310            let multiline = input
311                .get("multiline")
312                .and_then(|v| v.as_bool())
313                .unwrap_or(false);
314
315            let limit = input
316                .get("limit")
317                .and_then(|v| v.as_i64())
318                .map(|v| v.max(1) as usize)
319                .unwrap_or(1000);
320
321            // ─────────────────────────────────────────────────────────────
322            // Step 2: Request permission if not pre-approved by batch executor
323            // ─────────────────────────────────────────────────────────────
324            if !context.permissions_pre_approved {
325                let permission_request = Self::build_permission_request(&context.tool_use_id, &search_path_str);
326
327                let response_rx = permission_registry
328                    .request_permission(context.session_id, permission_request, context.turn_id.clone())
329                    .await
330                    .map_err(|e| format!("Failed to request permission: {}", e))?;
331
332                let response = response_rx
333                    .await
334                    .map_err(|_| "Permission request was cancelled".to_string())?;
335
336                if !response.granted {
337                    let reason = response
338                        .message
339                        .unwrap_or_else(|| "Permission denied by user".to_string());
340                    return Err(format!(
341                        "Permission denied to search '{}': {}",
342                        search_path_str, reason
343                    ));
344                }
345            }
346
347            // ─────────────────────────────────────────────────────────────
348            // Step 3: Build regex matcher
349            // ─────────────────────────────────────────────────────────────
350            let matcher = RegexMatcherBuilder::new()
351                .case_insensitive(case_insensitive)
352                .multi_line(multiline)
353                .dot_matches_new_line(multiline)
354                .build(pattern)
355                .map_err(|e| format!("Invalid regex pattern: {}", e))?;
356
357            // ─────────────────────────────────────────────────────────────
358            // Step 8: Build file glob filter if provided
359            // ─────────────────────────────────────────────────────────────
360            let glob_matcher: Option<GlobMatcher> = if let Some(glob_str) = glob_pattern {
361                Some(
362                    Glob::new(glob_str)
363                        .map_err(|e| format!("Invalid glob pattern: {}", e))?
364                        .compile_matcher(),
365                )
366            } else {
367                None
368            };
369
370            // Get file type extensions if provided
371            let type_extensions: Option<Vec<&str>> =
372                file_type.map(|t| Self::get_type_extensions(t));
373
374            // ─────────────────────────────────────────────────────────────
375            // Step 9: Build searcher with context options
376            // ─────────────────────────────────────────────────────────────
377            let mut searcher_builder = SearcherBuilder::new();
378            searcher_builder
379                .binary_detection(BinaryDetection::quit(0))
380                .line_number(show_line_numbers);
381
382            if context_before > 0 || context_after > 0 {
383                searcher_builder
384                    .before_context(context_before)
385                    .after_context(context_after);
386            }
387
388            let mut searcher = searcher_builder.build();
389
390            // ─────────────────────────────────────────────────────────────
391            // Step 10: Collect files to search
392            // ─────────────────────────────────────────────────────────────
393            let files: Vec<PathBuf> = if search_path.is_file() {
394                vec![search_path.clone()]
395            } else {
396                let search_path_clone = search_path.clone();
397                WalkDir::new(&search_path)
398                    .follow_links(false)
399                    .into_iter()
400                    .filter_entry(move |e| {
401                        // Skip hidden directories, but not the root search path itself
402                        let is_root = e.path() == search_path_clone;
403                        is_root || !e.file_name().to_string_lossy().starts_with('.')
404                    })
405                    .filter_map(|e| e.ok())
406                    .filter(|e| e.file_type().is_file())
407                    .filter(|e| {
408                        let path = e.path();
409
410                        // Apply glob filter
411                        if let Some(ref gm) = glob_matcher {
412                            let relative = path.strip_prefix(&search_path).unwrap_or(path);
413                            if !gm.is_match(relative) {
414                                return false;
415                            }
416                        }
417
418                        // Apply type filter
419                        if let Some(ref exts) = type_extensions {
420                            if exts.is_empty() {
421                                // Unknown type, don't filter
422                                return true;
423                            }
424                            if let Some(ext) = path.extension() {
425                                let ext_str = ext.to_string_lossy().to_lowercase();
426                                if !exts.iter().any(|e| *e == ext_str) {
427                                    return false;
428                                }
429                            } else {
430                                return false;
431                            }
432                        }
433
434                        true
435                    })
436                    .map(|e| e.path().to_path_buf())
437                    .collect()
438            };
439
440            // ─────────────────────────────────────────────────────────────
441            // Step 11: Execute search based on output mode
442            // ─────────────────────────────────────────────────────────────
443            match output_mode {
444                OutputMode::FilesWithMatches => {
445                    search_files_with_matches(&mut searcher, &matcher, &files, limit)
446                }
447                OutputMode::Content => search_content(
448                    &mut searcher,
449                    &matcher,
450                    &files,
451                    show_line_numbers,
452                    limit,
453                ),
454                OutputMode::Count => search_count(&mut searcher, &matcher, &files, limit),
455            }
456        })
457    }
458
459    fn display_config(&self) -> DisplayConfig {
460        DisplayConfig {
461            display_name: "Grep".to_string(),
462            display_title: Box::new(|input| {
463                input
464                    .get("pattern")
465                    .and_then(|v| v.as_str())
466                    .map(|p| {
467                        if p.len() > 30 {
468                            format!("{}...", &p[..30])
469                        } else {
470                            p.to_string()
471                        }
472                    })
473                    .unwrap_or_default()
474            }),
475            display_content: Box::new(|_input, result| {
476                let lines: Vec<&str> = result.lines().take(30).collect();
477                let total_lines = result.lines().count();
478
479                DisplayResult {
480                    content: lines.join("\n"),
481                    content_type: ResultContentType::PlainText,
482                    is_truncated: total_lines > 30,
483                    full_length: total_lines,
484                }
485            }),
486        }
487    }
488
489    fn compact_summary(
490        &self,
491        input: &HashMap<String, serde_json::Value>,
492        result: &str,
493    ) -> String {
494        let pattern = input
495            .get("pattern")
496            .and_then(|v| v.as_str())
497            .map(|p| {
498                if p.len() > 20 {
499                    format!("{}...", &p[..20])
500                } else {
501                    p.to_string()
502                }
503            })
504            .unwrap_or_else(|| "?".to_string());
505
506        let match_count = result.lines().filter(|line| !line.is_empty()).count();
507
508        format!("[Grep: '{}' ({} matches)]", pattern, match_count)
509    }
510
511    fn required_permissions(
512        &self,
513        context: &ToolContext,
514        input: &HashMap<String, serde_json::Value>,
515    ) -> Option<Vec<PermissionRequest>> {
516        // Extract the path from input or use default_path
517        let search_path = input
518            .get("path")
519            .and_then(|v| v.as_str())
520            .map(PathBuf::from)
521            .or_else(|| self.default_path.clone())
522            .unwrap_or_else(|| {
523                std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
524            });
525
526        let search_path_str = search_path.to_string_lossy().to_string();
527
528        // Build the permission request using the existing helper method
529        let permission_request = Self::build_permission_request(&context.tool_use_id, &search_path_str);
530
531        Some(vec![permission_request])
532    }
533}
534
535/// Search for files containing matches (files_with_matches mode).
536fn search_files_with_matches<M: Matcher>(
537    searcher: &mut Searcher,
538    matcher: &M,
539    files: &[PathBuf],
540    limit: usize,
541) -> Result<String, String> {
542    let mut matching_files = Vec::new();
543
544    for file in files {
545        if matching_files.len() >= limit {
546            break;
547        }
548
549        let mut found = false;
550        let sink = grep_searcher::sinks::UTF8(|_line_num, _line| {
551            found = true;
552            Ok(false) // Stop after first match
553        });
554
555        // Ignore errors for individual files (binary files, permission issues, etc.)
556        let _ = searcher.search_path(matcher, file, sink);
557
558        if found {
559            matching_files.push(file.display().to_string());
560        }
561    }
562
563    if matching_files.is_empty() {
564        Ok("No matches found".to_string())
565    } else {
566        Ok(matching_files.join("\n"))
567    }
568}
569
570/// Search for matching content (content mode).
571fn search_content<M: Matcher>(
572    searcher: &mut Searcher,
573    matcher: &M,
574    files: &[PathBuf],
575    show_line_numbers: bool,
576    limit: usize,
577) -> Result<String, String> {
578    let mut output = String::new();
579    let mut total_matches = 0;
580
581    for file in files {
582        if total_matches >= limit {
583            break;
584        }
585
586        let mut file_output = String::new();
587        let mut file_matches = 0;
588        let file_path = file.clone();
589
590        let sink = grep_searcher::sinks::UTF8(|line_num, line| {
591            if total_matches + file_matches >= limit {
592                return Ok(false);
593            }
594
595            if show_line_numbers {
596                file_output.push_str(&format!(
597                    "{}:{}: {}",
598                    file_path.display(),
599                    line_num,
600                    line.trim_end()
601                ));
602            } else {
603                file_output.push_str(&format!("{}: {}", file_path.display(), line.trim_end()));
604            }
605            file_output.push('\n');
606            file_matches += 1;
607
608            Ok(true)
609        });
610
611        // Ignore errors for individual files
612        let _ = searcher.search_path(matcher, file, sink);
613
614        if file_matches > 0 {
615            output.push_str(&file_output);
616            total_matches += file_matches;
617        }
618    }
619
620    if output.is_empty() {
621        Ok("No matches found".to_string())
622    } else {
623        Ok(output.trim_end().to_string())
624    }
625}
626
627/// Search and count matches per file (count mode).
628fn search_count<M: Matcher>(
629    searcher: &mut Searcher,
630    matcher: &M,
631    files: &[PathBuf],
632    limit: usize,
633) -> Result<String, String> {
634    let mut results = Vec::new();
635
636    for file in files {
637        if results.len() >= limit {
638            break;
639        }
640
641        let mut count = 0u64;
642        let sink = grep_searcher::sinks::UTF8(|_line_num, _line| {
643            count += 1;
644            Ok(true)
645        });
646
647        // Ignore errors for individual files
648        let _ = searcher.search_path(matcher, file, sink);
649
650        if count > 0 {
651            results.push(format!("{}:{}", file.display(), count));
652        }
653    }
654
655    if results.is_empty() {
656        Ok("No matches found".to_string())
657    } else {
658        Ok(results.join("\n"))
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::controller::PermissionPanelResponse;
666    use crate::controller::types::ControllerEvent;
667    use crate::permissions::GrantTarget;
668    use std::fs;
669    use tempfile::TempDir;
670    use tokio::sync::mpsc;
671
672    /// Helper to create a permission registry for testing.
673    fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
674        let (tx, rx) = mpsc::channel(16);
675        let registry = Arc::new(PermissionRegistry::new(tx));
676        (registry, rx)
677    }
678
679    fn grant_once() -> PermissionPanelResponse {
680        PermissionPanelResponse { granted: true, grant: None, message: None }
681    }
682
683    fn deny(reason: &str) -> PermissionPanelResponse {
684        PermissionPanelResponse { granted: false, grant: None, message: Some(reason.to_string()) }
685    }
686
687    fn setup_test_files() -> TempDir {
688        let temp = TempDir::new().unwrap();
689
690        fs::write(
691            temp.path().join("test.rs"),
692            r#"fn main() {
693    let error = "something wrong";
694    println!("Error: {}", error);
695}
696"#,
697        )
698        .unwrap();
699
700        fs::write(
701            temp.path().join("lib.rs"),
702            r#"pub fn handle_error(e: Error) {
703    eprintln!("Error occurred: {}", e);
704}
705"#,
706        )
707        .unwrap();
708
709        fs::write(
710            temp.path().join("test.js"),
711            r#"function handleError(err) {
712    console.error("Error:", err);
713}
714"#,
715        )
716        .unwrap();
717
718        temp
719    }
720
721    #[tokio::test]
722    async fn test_simple_search_with_permission() {
723        let temp = setup_test_files();
724        let (registry, mut event_rx) = create_test_registry();
725        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
726
727        let mut input = HashMap::new();
728        input.insert(
729            "pattern".to_string(),
730            serde_json::Value::String("error".to_string()),
731        );
732        input.insert("-i".to_string(), serde_json::Value::Bool(true));
733
734        let context = ToolContext {
735            session_id: 1,
736            tool_use_id: "test-grep-1".to_string(),
737            turn_id: None,
738            permissions_pre_approved: false,
739        };
740
741        // Grant permission in background
742        let registry_clone = registry.clone();
743        tokio::spawn(async move {
744            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
745                event_rx.recv().await
746            {
747                registry_clone
748                    .respond_to_request(&tool_use_id, grant_once())
749                    .await
750                    .unwrap();
751            }
752        });
753
754        let result = tool.execute(context, input).await;
755        assert!(result.is_ok());
756        let output = result.unwrap();
757        // Should find matches in multiple files
758        assert!(output.contains("test.rs") || output.contains("lib.rs"));
759    }
760
761    #[tokio::test]
762    async fn test_search_permission_denied() {
763        let temp = setup_test_files();
764        let (registry, mut event_rx) = create_test_registry();
765        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
766
767        let mut input = HashMap::new();
768        input.insert(
769            "pattern".to_string(),
770            serde_json::Value::String("error".to_string()),
771        );
772
773        let context = ToolContext {
774            session_id: 1,
775            tool_use_id: "test-grep-denied".to_string(),
776            turn_id: None,
777            permissions_pre_approved: false,
778        };
779
780        // Deny permission
781        let registry_clone = registry.clone();
782        tokio::spawn(async move {
783            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
784                event_rx.recv().await
785            {
786                registry_clone
787                    .respond_to_request(
788                        &tool_use_id,
789                        deny("Access denied"),
790                    )
791                    .await
792                    .unwrap();
793            }
794        });
795
796        let result = tool.execute(context, input).await;
797        assert!(result.is_err());
798        assert!(result.unwrap_err().contains("Permission denied"));
799    }
800
801    #[tokio::test]
802    async fn test_content_mode() {
803        let temp = setup_test_files();
804        let (registry, mut event_rx) = create_test_registry();
805        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
806
807        let mut input = HashMap::new();
808        input.insert(
809            "pattern".to_string(),
810            serde_json::Value::String("Error".to_string()),
811        );
812        input.insert(
813            "output_mode".to_string(),
814            serde_json::Value::String("content".to_string()),
815        );
816
817        let context = ToolContext {
818            session_id: 1,
819            tool_use_id: "test-grep-content".to_string(),
820            turn_id: None,
821            permissions_pre_approved: false,
822        };
823
824        let registry_clone = registry.clone();
825        tokio::spawn(async move {
826            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
827                event_rx.recv().await
828            {
829                registry_clone
830                    .respond_to_request(&tool_use_id, grant_once())
831                    .await
832                    .unwrap();
833            }
834        });
835
836        let result = tool.execute(context, input).await;
837        assert!(result.is_ok());
838        let output = result.unwrap();
839        // Content mode should include line content
840        assert!(output.contains("Error"));
841    }
842
843    #[tokio::test]
844    async fn test_count_mode() {
845        let temp = setup_test_files();
846        let (registry, mut event_rx) = create_test_registry();
847        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
848
849        let mut input = HashMap::new();
850        input.insert(
851            "pattern".to_string(),
852            serde_json::Value::String("Error".to_string()),
853        );
854        input.insert(
855            "output_mode".to_string(),
856            serde_json::Value::String("count".to_string()),
857        );
858
859        let context = ToolContext {
860            session_id: 1,
861            tool_use_id: "test-grep-count".to_string(),
862            turn_id: None,
863            permissions_pre_approved: false,
864        };
865
866        let registry_clone = registry.clone();
867        tokio::spawn(async move {
868            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
869                event_rx.recv().await
870            {
871                registry_clone
872                    .respond_to_request(&tool_use_id, grant_once())
873                    .await
874                    .unwrap();
875            }
876        });
877
878        let result = tool.execute(context, input).await;
879        assert!(result.is_ok());
880        let output = result.unwrap();
881        // Count mode should include file paths with counts
882        assert!(output.contains(":"));
883    }
884
885    #[tokio::test]
886    async fn test_type_filter() {
887        let temp = setup_test_files();
888        let (registry, mut event_rx) = create_test_registry();
889        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
890
891        let mut input = HashMap::new();
892        input.insert(
893            "pattern".to_string(),
894            serde_json::Value::String("function".to_string()),
895        );
896        input.insert(
897            "type".to_string(),
898            serde_json::Value::String("js".to_string()),
899        );
900
901        let context = ToolContext {
902            session_id: 1,
903            tool_use_id: "test-grep-type".to_string(),
904            turn_id: None,
905            permissions_pre_approved: false,
906        };
907
908        let registry_clone = registry.clone();
909        tokio::spawn(async move {
910            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
911                event_rx.recv().await
912            {
913                registry_clone
914                    .respond_to_request(&tool_use_id, grant_once())
915                    .await
916                    .unwrap();
917            }
918        });
919
920        let result = tool.execute(context, input).await;
921        assert!(result.is_ok());
922        let output = result.unwrap();
923        // Should only find matches in .js files
924        assert!(output.contains("test.js"));
925        assert!(!output.contains(".rs"));
926    }
927
928    #[tokio::test]
929    async fn test_invalid_pattern() {
930        let temp = setup_test_files();
931        let (registry, mut event_rx) = create_test_registry();
932        let tool = GrepTool::with_default_path(registry.clone(), temp.path().to_path_buf());
933
934        let mut input = HashMap::new();
935        // Invalid regex pattern (unbalanced parentheses)
936        input.insert(
937            "pattern".to_string(),
938            serde_json::Value::String("(invalid".to_string()),
939        );
940
941        let context = ToolContext {
942            session_id: 1,
943            tool_use_id: "test-grep-invalid".to_string(),
944            turn_id: None,
945            permissions_pre_approved: false,
946        };
947
948        let registry_clone = registry.clone();
949        tokio::spawn(async move {
950            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
951                event_rx.recv().await
952            {
953                registry_clone
954                    .respond_to_request(&tool_use_id, grant_once())
955                    .await
956                    .unwrap();
957            }
958        });
959
960        let result = tool.execute(context, input).await;
961        assert!(result.is_err());
962        assert!(result.unwrap_err().contains("Invalid regex pattern"));
963    }
964
965    #[tokio::test]
966    async fn test_missing_pattern() {
967        let (registry, _event_rx) = create_test_registry();
968        let tool = GrepTool::new(registry);
969
970        let input = HashMap::new();
971
972        let context = ToolContext {
973            session_id: 1,
974            tool_use_id: "test".to_string(),
975            turn_id: None,
976            permissions_pre_approved: false,
977        };
978
979        let result = tool.execute(context, input).await;
980        assert!(result.is_err());
981        assert!(result.unwrap_err().contains("Missing required 'pattern'"));
982    }
983
984    #[tokio::test]
985    async fn test_nonexistent_path() {
986        let (registry, _event_rx) = create_test_registry();
987        let tool = GrepTool::new(registry);
988
989        let mut input = HashMap::new();
990        input.insert(
991            "pattern".to_string(),
992            serde_json::Value::String("test".to_string()),
993        );
994        input.insert(
995            "path".to_string(),
996            serde_json::Value::String("/nonexistent/path".to_string()),
997        );
998
999        let context = ToolContext {
1000            session_id: 1,
1001            tool_use_id: "test".to_string(),
1002            turn_id: None,
1003            permissions_pre_approved: false,
1004        };
1005
1006        let result = tool.execute(context, input).await;
1007        assert!(result.is_err());
1008        assert!(result.unwrap_err().contains("does not exist"));
1009    }
1010
1011    #[test]
1012    fn test_compact_summary() {
1013        let (registry, _event_rx) = create_test_registry();
1014        let tool = GrepTool::new(registry);
1015
1016        let mut input = HashMap::new();
1017        input.insert(
1018            "pattern".to_string(),
1019            serde_json::Value::String("impl.*Trait".to_string()),
1020        );
1021
1022        let result = "file1.rs\nfile2.rs\nfile3.rs";
1023        let summary = tool.compact_summary(&input, result);
1024        assert_eq!(summary, "[Grep: 'impl.*Trait' (3 matches)]");
1025    }
1026
1027    #[test]
1028    fn test_compact_summary_long_pattern() {
1029        let (registry, _event_rx) = create_test_registry();
1030        let tool = GrepTool::new(registry);
1031
1032        let mut input = HashMap::new();
1033        input.insert(
1034            "pattern".to_string(),
1035            serde_json::Value::String(
1036                "this_is_a_very_long_pattern_that_should_be_truncated".to_string(),
1037            ),
1038        );
1039
1040        let result = "file1.rs";
1041        let summary = tool.compact_summary(&input, result);
1042        assert!(summary.contains("..."));
1043        assert!(summary.len() < 100);
1044    }
1045
1046    #[test]
1047    fn test_build_permission_request() {
1048        let request = GrepTool::build_permission_request("test-tool-id", "/path/to/src");
1049
1050        assert_eq!(request.description, "Search files in: /path/to/src");
1051        assert_eq!(
1052            request.reason,
1053            Some("Search file contents using grep".to_string())
1054        );
1055        assert_eq!(
1056            request.target,
1057            GrantTarget::path("/path/to/src", true)
1058        );
1059        assert_eq!(request.required_level, PermissionLevel::Read);
1060    }
1061
1062    #[test]
1063    fn test_get_type_extensions() {
1064        assert_eq!(
1065            GrepTool::get_type_extensions("rust"),
1066            vec!["rs"]
1067        );
1068        assert_eq!(
1069            GrepTool::get_type_extensions("js"),
1070            vec!["js", "mjs", "cjs"]
1071        );
1072        assert_eq!(
1073            GrepTool::get_type_extensions("py"),
1074            vec!["py", "pyi"]
1075        );
1076        assert!(GrepTool::get_type_extensions("unknown").is_empty());
1077    }
1078}