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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
20use super::types::{
21 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
22};
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(|| {
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 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 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 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 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 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 let type_extensions: Option<Vec<&str>> =
372 file_type.map(|t| Self::get_type_extensions(t));
373
374 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 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 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 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 if let Some(ref exts) = type_extensions {
420 if exts.is_empty() {
421 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 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 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 let permission_request = Self::build_permission_request(&context.tool_use_id, &search_path_str);
530
531 Some(vec![permission_request])
532 }
533}
534
535fn 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) });
554
555 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
570fn 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 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
627fn 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 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 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 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 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 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 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 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 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 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}