Skip to main content

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