1use 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
24pub const GREP_TOOL_NAME: &str = "grep";
26
27pub 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
53pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum OutputMode {
113 FilesWithMatches,
115 Content,
117 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
131pub struct GrepTool {
133 permission_registry: Arc<PermissionRegistry>,
135 default_path: Option<PathBuf>,
137}
138
139impl GrepTool {
140 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
145 Self {
146 permission_registry,
147 default_path: None,
148 }
149 }
150
151 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 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), PermissionLevel::Read,
175 format!("Search files in: {}", path.display()),
176 )
177 .with_reason(reason)
178 .with_tool(GREP_TOOL_NAME)
179 }
180
181 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 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 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 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 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 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 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 let type_extensions: Option<Vec<&str>> = file_type.map(Self::get_type_extensions);
366
367 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 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 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 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 if let Some(ref exts) = type_extensions {
413 if exts.is_empty() {
414 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 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 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 let permission_request =
513 Self::build_permission_request(&context.tool_use_id, &search_path_str);
514
515 Some(vec![permission_request])
516 }
517}
518
519fn 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) });
538
539 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
554fn 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 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
611fn 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 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 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 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 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 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 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 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 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 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}