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::ask_for_permissions::{PermissionCategory, PermissionRequest};
20use super::permission_registry::PermissionRegistry;
21use super::types::{
22 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
23};
24
25pub const GREP_TOOL_NAME: &str = "grep";
27
28pub 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
54pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum OutputMode {
114 FilesWithMatches,
116 Content,
118 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
132pub struct GrepTool {
134 permission_registry: Arc<PermissionRegistry>,
136 default_path: Option<PathBuf>,
138}
139
140impl GrepTool {
141 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
146 Self {
147 permission_registry,
148 default_path: None,
149 }
150 }
151
152 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 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 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 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 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 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 let permission_request = Self::build_permission_request(&search_path_str);
332
333 let already_granted = permission_registry
337 .is_granted(context.session_id, &permission_request)
338 .await;
339
340 if !already_granted {
341 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 let response = response_rx
359 .await
360 .map_err(|_| "Permission request was cancelled".to_string())?;
361
362 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 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 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 let type_extensions: Option<Vec<&str>> =
401 file_type.map(|t| Self::get_type_extensions(t));
402
403 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 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 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 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 if let Some(ref exts) = type_extensions {
449 if exts.is_empty() {
450 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 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
541fn 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) });
560
561 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
576fn 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 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
633fn 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 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 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 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 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 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 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 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 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 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}