1use async_trait::async_trait;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::io::{BufRead, BufReader};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use crate::tools::base::{PermissionCheckResult, Tool};
16use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
17use crate::tools::error::ToolError;
18
19use super::{
20 format_search_results, truncate_results, SearchResult, DEFAULT_MAX_CONTEXT_LINES,
21 DEFAULT_MAX_RESULTS, MAX_OUTPUT_SIZE,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum GrepOutputMode {
30 #[default]
32 Content,
33 FilesWithMatches,
35 Count,
37}
38
39impl GrepOutputMode {
40 pub fn parse(s: &str) -> Option<Self> {
42 match s.to_lowercase().as_str() {
43 "content" => Some(Self::Content),
44 "files_with_matches" | "files" | "l" => Some(Self::FilesWithMatches),
45 "count" | "c" => Some(Self::Count),
46 _ => None,
47 }
48 }
49}
50
51pub struct GrepTool {
62 max_results: usize,
64 max_context_lines: usize,
66 use_ripgrep: bool,
68}
69
70impl Default for GrepTool {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl GrepTool {
77 pub fn new() -> Self {
79 Self {
80 max_results: DEFAULT_MAX_RESULTS,
81 max_context_lines: DEFAULT_MAX_CONTEXT_LINES,
82 use_ripgrep: true,
83 }
84 }
85
86 pub fn with_max_results(mut self, max_results: usize) -> Self {
88 self.max_results = max_results;
89 self
90 }
91
92 pub fn with_max_context_lines(mut self, max_context_lines: usize) -> Self {
94 self.max_context_lines = max_context_lines;
95 self
96 }
97
98 pub fn without_ripgrep(mut self) -> Self {
100 self.use_ripgrep = false;
101 self
102 }
103
104 fn is_ripgrep_available() -> bool {
106 Command::new("rg").arg("--version").output().is_ok()
107 }
108
109 fn is_grep_available() -> bool {
111 Command::new("grep").arg("--version").output().is_ok()
112 }
113
114 #[allow(clippy::too_many_arguments)]
118 fn search_with_ripgrep(
119 &self,
120 pattern: &str,
121 path: &Path,
122 mode: GrepOutputMode,
123 context_before: usize,
124 context_after: usize,
125 case_insensitive: bool,
126 include_hidden: bool,
127 ) -> Result<Vec<SearchResult>, ToolError> {
128 let mut cmd = Command::new("rg");
129
130 cmd.arg(pattern);
132
133 cmd.arg(path);
135
136 match mode {
138 GrepOutputMode::Content => {
139 cmd.arg("--line-number");
140 if context_before > 0 {
141 cmd.arg("-B").arg(context_before.to_string());
142 }
143 if context_after > 0 {
144 cmd.arg("-A").arg(context_after.to_string());
145 }
146 }
147 GrepOutputMode::FilesWithMatches => {
148 cmd.arg("-l");
149 }
150 GrepOutputMode::Count => {
151 cmd.arg("-c");
152 }
153 }
154
155 if case_insensitive {
157 cmd.arg("-i");
158 }
159
160 if include_hidden {
162 cmd.arg("--hidden");
163 }
164
165 cmd.arg("--max-count")
167 .arg((self.max_results * 10).to_string());
168
169 let output = cmd.output().map_err(|e| {
171 ToolError::execution_failed(format!("Failed to execute ripgrep: {}", e))
172 })?;
173
174 self.parse_grep_output(&output.stdout, mode, path)
176 }
177
178 fn search_with_grep(
182 &self,
183 pattern: &str,
184 path: &Path,
185 mode: GrepOutputMode,
186 context_before: usize,
187 context_after: usize,
188 case_insensitive: bool,
189 ) -> Result<Vec<SearchResult>, ToolError> {
190 let mut cmd = Command::new("grep");
191
192 cmd.arg("-r");
194
195 cmd.arg("-E");
197
198 match mode {
200 GrepOutputMode::Content => {
201 cmd.arg("-n"); if context_before > 0 {
203 cmd.arg("-B").arg(context_before.to_string());
204 }
205 if context_after > 0 {
206 cmd.arg("-A").arg(context_after.to_string());
207 }
208 }
209 GrepOutputMode::FilesWithMatches => {
210 cmd.arg("-l");
211 }
212 GrepOutputMode::Count => {
213 cmd.arg("-c");
214 }
215 }
216
217 if case_insensitive {
219 cmd.arg("-i");
220 }
221
222 cmd.arg(pattern);
224 cmd.arg(path);
225
226 let output = cmd
228 .output()
229 .map_err(|e| ToolError::execution_failed(format!("Failed to execute grep: {}", e)))?;
230
231 self.parse_grep_output(&output.stdout, mode, path)
233 }
234
235 fn parse_grep_output(
237 &self,
238 output: &[u8],
239 mode: GrepOutputMode,
240 _base_path: &Path,
241 ) -> Result<Vec<SearchResult>, ToolError> {
242 let output_str = String::from_utf8_lossy(output);
243 let mut results = Vec::new();
244
245 for line in output_str.lines() {
246 if line.is_empty() {
247 continue;
248 }
249
250 match mode {
251 GrepOutputMode::Content => {
252 if let Some((file_part, rest)) = line.split_once(':') {
254 if let Some((line_num_str, content)) = rest.split_once(':') {
255 if let Ok(line_num) = line_num_str.parse::<usize>() {
256 results.push(SearchResult::content_match(
257 PathBuf::from(file_part),
258 line_num,
259 content.to_string(),
260 ));
261 }
262 }
263 }
264 }
265 GrepOutputMode::FilesWithMatches => {
266 results.push(SearchResult::file_match(PathBuf::from(line)));
268 }
269 GrepOutputMode::Count => {
270 if let Some((file_part, count_str)) = line.rsplit_once(':') {
272 if let Ok(count) = count_str.parse::<usize>() {
273 if count > 0 {
274 results.push(SearchResult::count_match(
275 PathBuf::from(file_part),
276 count,
277 ));
278 }
279 }
280 }
281 }
282 }
283 }
284
285 Ok(results)
286 }
287
288 fn search_rust(
292 &self,
293 pattern: &str,
294 path: &Path,
295 mode: GrepOutputMode,
296 context_before: usize,
297 context_after: usize,
298 case_insensitive: bool,
299 ) -> Result<Vec<SearchResult>, ToolError> {
300 let regex = if case_insensitive {
302 Regex::new(&format!("(?i){}", pattern))
303 } else {
304 Regex::new(pattern)
305 }
306 .map_err(|e| ToolError::invalid_params(format!("Invalid regex pattern: {}", e)))?;
307
308 let mut results = Vec::new();
309
310 self.search_directory(
312 ®ex,
313 path,
314 mode,
315 context_before,
316 context_after,
317 &mut results,
318 )?;
319
320 Ok(results)
321 }
322
323 fn search_directory(
325 &self,
326 regex: &Regex,
327 path: &Path,
328 mode: GrepOutputMode,
329 context_before: usize,
330 context_after: usize,
331 results: &mut Vec<SearchResult>,
332 ) -> Result<(), ToolError> {
333 if path.is_file() {
334 self.search_file(regex, path, mode, context_before, context_after, results)?;
335 } else if path.is_dir() {
336 let entries = fs::read_dir(path).map_err(|e| {
337 ToolError::execution_failed(format!("Failed to read directory: {}", e))
338 })?;
339
340 for entry in entries.flatten() {
341 let entry_path = entry.path();
342
343 if entry_path
345 .file_name()
346 .and_then(|n| n.to_str())
347 .is_some_and(|n| n.starts_with('.'))
348 {
349 continue;
350 }
351
352 self.search_directory(
354 regex,
355 &entry_path,
356 mode,
357 context_before,
358 context_after,
359 results,
360 )?;
361
362 if results.len() >= self.max_results * 10 {
364 break;
365 }
366 }
367 }
368
369 Ok(())
370 }
371
372 fn search_file(
374 &self,
375 regex: &Regex,
376 path: &Path,
377 mode: GrepOutputMode,
378 context_before: usize,
379 context_after: usize,
380 results: &mut Vec<SearchResult>,
381 ) -> Result<(), ToolError> {
382 if self.is_binary_file(path) {
384 return Ok(());
385 }
386
387 let file = fs::File::open(path)?;
388 let reader = BufReader::new(file);
389 let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
390
391 let mut match_count = 0;
392 let mut file_has_match = false;
393
394 for (idx, line) in lines.iter().enumerate() {
395 if regex.is_match(line) {
396 file_has_match = true;
397 match_count += 1;
398
399 if mode == GrepOutputMode::Content {
400 let line_number = idx + 1;
401
402 let before: Vec<String> =
404 lines[idx.saturating_sub(context_before)..idx].to_vec();
405 let after: Vec<String> = lines
406 .get(idx + 1..=(idx + context_after).min(lines.len() - 1))
407 .unwrap_or(&[])
408 .to_vec();
409
410 let result =
411 SearchResult::content_match(path.to_path_buf(), line_number, line.clone())
412 .with_context(before, after);
413
414 results.push(result);
415 }
416 }
417 }
418
419 match mode {
421 GrepOutputMode::FilesWithMatches if file_has_match => {
422 results.push(SearchResult::file_match(path.to_path_buf()));
423 }
424 GrepOutputMode::Count if match_count > 0 => {
425 results.push(SearchResult::count_match(path.to_path_buf(), match_count));
426 }
427 _ => {}
428 }
429
430 Ok(())
431 }
432
433 fn is_binary_file(&self, path: &Path) -> bool {
435 let binary_extensions = [
437 "exe", "dll", "so", "dylib", "bin", "obj", "o", "a", "lib", "png", "jpg", "jpeg",
438 "gif", "bmp", "ico", "webp", "mp3", "mp4", "avi", "mov", "mkv", "wav", "flac", "zip",
439 "tar", "gz", "bz2", "xz", "7z", "rar", "pdf", "doc", "docx", "xls", "xlsx", "ppt",
440 "pptx", "wasm", "pyc", "class",
441 ];
442
443 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
444 if binary_extensions.contains(&ext.to_lowercase().as_str()) {
445 return true;
446 }
447 }
448
449 if let Ok(mut file) = fs::File::open(path) {
451 use std::io::Read;
452 let mut buffer = [0u8; 512];
453 if let Ok(n) = file.read(&mut buffer) {
454 return buffer[..n].contains(&0);
455 }
456 }
457
458 false
459 }
460
461 #[allow(clippy::too_many_arguments)]
463 pub fn search(
464 &self,
465 pattern: &str,
466 path: &Path,
467 mode: GrepOutputMode,
468 context_before: usize,
469 context_after: usize,
470 case_insensitive: bool,
471 include_hidden: bool,
472 ) -> Result<Vec<SearchResult>, ToolError> {
473 if self.use_ripgrep && Self::is_ripgrep_available() {
475 return self.search_with_ripgrep(
476 pattern,
477 path,
478 mode,
479 context_before,
480 context_after,
481 case_insensitive,
482 include_hidden,
483 );
484 }
485
486 if Self::is_grep_available() {
488 return self.search_with_grep(
489 pattern,
490 path,
491 mode,
492 context_before,
493 context_after,
494 case_insensitive,
495 );
496 }
497
498 self.search_rust(
500 pattern,
501 path,
502 mode,
503 context_before,
504 context_after,
505 case_insensitive,
506 )
507 }
508
509 fn truncate_output(&self, output: &str) -> (String, bool) {
513 if output.len() <= MAX_OUTPUT_SIZE {
514 (output.to_string(), false)
515 } else {
516 let truncated = output.get(..MAX_OUTPUT_SIZE).unwrap_or(output);
517 let last_newline = truncated.rfind('\n').unwrap_or(truncated.len());
519 let clean_truncated = truncated.get(..last_newline).unwrap_or(truncated);
520 (
521 format!(
522 "{}\n\n[Output truncated. Showing first {} bytes of {} bytes total.]",
523 clean_truncated,
524 last_newline,
525 output.len()
526 ),
527 true,
528 )
529 }
530 }
531}
532
533#[async_trait]
534impl Tool for GrepTool {
535 fn name(&self) -> &str {
536 "grep"
537 }
538
539 fn description(&self) -> &str {
540 "Search file contents using regex patterns. Uses ripgrep for speed when available, \
541 with grep or pure Rust fallback. Supports multiple output modes: content (default), \
542 files_with_matches, and count."
543 }
544
545 fn input_schema(&self) -> serde_json::Value {
546 serde_json::json!({
547 "type": "object",
548 "properties": {
549 "pattern": {
550 "type": "string",
551 "description": "Regex pattern to search for"
552 },
553 "path": {
554 "type": "string",
555 "description": "Path to search in. Defaults to working directory."
556 },
557 "mode": {
558 "type": "string",
559 "enum": ["content", "files_with_matches", "count"],
560 "description": "Output mode. 'content' returns matching lines, 'files_with_matches' returns file names, 'count' returns match counts."
561 },
562 "context_before": {
563 "type": "integer",
564 "description": "Number of lines to show before each match. Default: 0"
565 },
566 "context_after": {
567 "type": "integer",
568 "description": "Number of lines to show after each match. Default: 0"
569 },
570 "case_insensitive": {
571 "type": "boolean",
572 "description": "Whether to ignore case. Default: false"
573 },
574 "include_hidden": {
575 "type": "boolean",
576 "description": "Whether to search hidden files. Default: false"
577 },
578 "max_results": {
579 "type": "integer",
580 "description": "Maximum number of results to return. Default: 100"
581 }
582 },
583 "required": ["pattern"]
584 })
585 }
586
587 async fn execute(
588 &self,
589 params: serde_json::Value,
590 context: &ToolContext,
591 ) -> Result<ToolResult, ToolError> {
592 if context.is_cancelled() {
594 return Err(ToolError::Cancelled);
595 }
596
597 let pattern = params
599 .get("pattern")
600 .and_then(|v| v.as_str())
601 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: pattern"))?;
602
603 let path = params
604 .get("path")
605 .and_then(|v| v.as_str())
606 .map(PathBuf::from)
607 .unwrap_or_else(|| context.working_directory.clone());
608
609 let mode = params
610 .get("mode")
611 .and_then(|v| v.as_str())
612 .and_then(GrepOutputMode::parse)
613 .unwrap_or_default();
614
615 let context_before = params
616 .get("context_before")
617 .and_then(|v| v.as_u64())
618 .map(|v| v as usize)
619 .unwrap_or(0)
620 .min(self.max_context_lines);
621
622 let context_after = params
623 .get("context_after")
624 .and_then(|v| v.as_u64())
625 .map(|v| v as usize)
626 .unwrap_or(0)
627 .min(self.max_context_lines);
628
629 let case_insensitive = params
630 .get("case_insensitive")
631 .and_then(|v| v.as_bool())
632 .unwrap_or(false);
633
634 let include_hidden = params
635 .get("include_hidden")
636 .and_then(|v| v.as_bool())
637 .unwrap_or(false);
638
639 let max_results = params
640 .get("max_results")
641 .and_then(|v| v.as_u64())
642 .map(|v| v as usize)
643 .unwrap_or(self.max_results);
644
645 let results = self.search(
647 pattern,
648 &path,
649 mode,
650 context_before,
651 context_after,
652 case_insensitive,
653 include_hidden,
654 )?;
655
656 let (results, result_truncated) = truncate_results(results, max_results);
658
659 let output = format_search_results(&results, result_truncated);
661
662 let (output, output_truncated) = self.truncate_output(&output);
664
665 Ok(ToolResult::success(output)
666 .with_metadata("count", serde_json::json!(results.len()))
667 .with_metadata(
668 "truncated",
669 serde_json::json!(result_truncated || output_truncated),
670 )
671 .with_metadata("mode", serde_json::json!(format!("{:?}", mode))))
672 }
673
674 async fn check_permissions(
675 &self,
676 _params: &serde_json::Value,
677 _context: &ToolContext,
678 ) -> PermissionCheckResult {
679 PermissionCheckResult::allow()
681 }
682
683 fn options(&self) -> ToolOptions {
684 ToolOptions::default().with_base_timeout(std::time::Duration::from_secs(120))
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use std::fs::File;
692 use std::io::Write;
693 use tempfile::TempDir;
694
695 fn create_test_files(dir: &TempDir) {
696 let files = vec![
698 ("test1.txt", "Hello World\nThis is a test\nHello again"),
699 (
700 "test2.txt",
701 "Another file\nWith some content\nAnd more lines",
702 ),
703 ("src/main.rs", "fn main() {\n println!(\"Hello\");\n}"),
704 ("src/lib.rs", "pub fn hello() {\n // Hello function\n}"),
705 ];
706
707 for (path, content) in files {
708 let file_path = dir.path().join(path);
709 if let Some(parent) = file_path.parent() {
710 fs::create_dir_all(parent).unwrap();
711 }
712 let mut f = File::create(&file_path).unwrap();
713 write!(f, "{}", content).unwrap();
714 }
715 }
716
717 #[test]
718 fn test_grep_tool_new() {
719 let tool = GrepTool::new();
720 assert_eq!(tool.max_results, DEFAULT_MAX_RESULTS);
721 assert_eq!(tool.max_context_lines, DEFAULT_MAX_CONTEXT_LINES);
722 assert!(tool.use_ripgrep);
723 }
724
725 #[test]
726 fn test_grep_tool_builder() {
727 let tool = GrepTool::new()
728 .with_max_results(50)
729 .with_max_context_lines(10)
730 .without_ripgrep();
731
732 assert_eq!(tool.max_results, 50);
733 assert_eq!(tool.max_context_lines, 10);
734 assert!(!tool.use_ripgrep);
735 }
736
737 #[test]
738 fn test_grep_output_mode_parse() {
739 assert_eq!(
740 GrepOutputMode::parse("content"),
741 Some(GrepOutputMode::Content)
742 );
743 assert_eq!(
744 GrepOutputMode::parse("files_with_matches"),
745 Some(GrepOutputMode::FilesWithMatches)
746 );
747 assert_eq!(
748 GrepOutputMode::parse("files"),
749 Some(GrepOutputMode::FilesWithMatches)
750 );
751 assert_eq!(
752 GrepOutputMode::parse("l"),
753 Some(GrepOutputMode::FilesWithMatches)
754 );
755 assert_eq!(GrepOutputMode::parse("count"), Some(GrepOutputMode::Count));
756 assert_eq!(GrepOutputMode::parse("c"), Some(GrepOutputMode::Count));
757 assert_eq!(GrepOutputMode::parse("invalid"), None);
758 }
759
760 #[test]
761 fn test_grep_rust_search_content() {
762 let temp_dir = TempDir::new().unwrap();
763 create_test_files(&temp_dir);
764
765 let tool = GrepTool::new().without_ripgrep();
766 let results = tool
767 .search_rust(
768 "Hello",
769 temp_dir.path(),
770 GrepOutputMode::Content,
771 0,
772 0,
773 false,
774 )
775 .unwrap();
776
777 assert!(!results.is_empty());
778 assert!(results.iter().all(|r| r.line_content.is_some()));
779 }
780
781 #[test]
782 fn test_grep_rust_search_files_with_matches() {
783 let temp_dir = TempDir::new().unwrap();
784 create_test_files(&temp_dir);
785
786 let tool = GrepTool::new().without_ripgrep();
787 let results = tool
788 .search_rust(
789 "Hello",
790 temp_dir.path(),
791 GrepOutputMode::FilesWithMatches,
792 0,
793 0,
794 false,
795 )
796 .unwrap();
797
798 assert!(!results.is_empty());
799 assert!(results.iter().all(|r| r.line_content.is_none()));
800 assert!(results.iter().all(|r| r.match_count.is_none()));
801 }
802
803 #[test]
804 fn test_grep_rust_search_count() {
805 let temp_dir = TempDir::new().unwrap();
806 create_test_files(&temp_dir);
807
808 let tool = GrepTool::new().without_ripgrep();
809 let results = tool
810 .search_rust("Hello", temp_dir.path(), GrepOutputMode::Count, 0, 0, false)
811 .unwrap();
812
813 assert!(!results.is_empty());
814 assert!(results.iter().all(|r| r.match_count.is_some()));
815 }
816
817 #[test]
818 fn test_grep_rust_case_insensitive() {
819 let temp_dir = TempDir::new().unwrap();
820 create_test_files(&temp_dir);
821
822 let tool = GrepTool::new().without_ripgrep();
823
824 let results_sensitive = tool
826 .search_rust(
827 "hello",
828 temp_dir.path(),
829 GrepOutputMode::Content,
830 0,
831 0,
832 false,
833 )
834 .unwrap();
835
836 let results_insensitive = tool
838 .search_rust(
839 "hello",
840 temp_dir.path(),
841 GrepOutputMode::Content,
842 0,
843 0,
844 true,
845 )
846 .unwrap();
847
848 assert!(results_insensitive.len() >= results_sensitive.len());
849 }
850
851 #[test]
852 fn test_grep_rust_with_context() {
853 let temp_dir = TempDir::new().unwrap();
854 create_test_files(&temp_dir);
855
856 let tool = GrepTool::new().without_ripgrep();
857 let results = tool
858 .search_rust(
859 "test",
860 temp_dir.path(),
861 GrepOutputMode::Content,
862 1,
863 1,
864 false,
865 )
866 .unwrap();
867
868 let has_context = results
870 .iter()
871 .any(|r| !r.context_before.is_empty() || !r.context_after.is_empty());
872 assert!(has_context || results.is_empty());
873 }
874
875 #[test]
876 fn test_grep_invalid_regex() {
877 let temp_dir = TempDir::new().unwrap();
878 let tool = GrepTool::new().without_ripgrep();
879
880 let result = tool.search_rust(
881 "[invalid",
882 temp_dir.path(),
883 GrepOutputMode::Content,
884 0,
885 0,
886 false,
887 );
888
889 assert!(result.is_err());
890 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
891 }
892
893 #[test]
894 fn test_grep_truncate_output() {
895 let tool = GrepTool::new();
896
897 let (output, truncated) = tool.truncate_output("short output");
899 assert_eq!(output, "short output");
900 assert!(!truncated);
901
902 let long_output = "x".repeat(MAX_OUTPUT_SIZE + 1000);
904 let (output, truncated) = tool.truncate_output(&long_output);
905 assert!(output.len() < long_output.len());
906 assert!(truncated);
907 assert!(output.contains("[Output truncated"));
908 }
909
910 #[tokio::test]
911 async fn test_grep_tool_execute() {
912 let temp_dir = TempDir::new().unwrap();
913 create_test_files(&temp_dir);
914
915 let tool = GrepTool::new();
916 let context = ToolContext::new(temp_dir.path().to_path_buf());
917 let params = serde_json::json!({
918 "pattern": "Hello"
919 });
920
921 let result = tool.execute(params, &context).await.unwrap();
922 assert!(result.is_success());
923 assert!(result.output.is_some());
924 }
925
926 #[tokio::test]
927 async fn test_grep_tool_execute_with_mode() {
928 let temp_dir = TempDir::new().unwrap();
929 create_test_files(&temp_dir);
930
931 let tool = GrepTool::new();
932 let context = ToolContext::new(temp_dir.path().to_path_buf());
933 let params = serde_json::json!({
934 "pattern": "Hello",
935 "mode": "count"
936 });
937
938 let result = tool.execute(params, &context).await.unwrap();
939 assert!(result.is_success());
940 assert_eq!(
941 result.metadata.get("mode"),
942 Some(&serde_json::json!("Count"))
943 );
944 }
945
946 #[tokio::test]
947 async fn test_grep_tool_execute_with_context() {
948 let temp_dir = TempDir::new().unwrap();
949 create_test_files(&temp_dir);
950
951 let tool = GrepTool::new();
952 let context = ToolContext::new(temp_dir.path().to_path_buf());
953 let params = serde_json::json!({
954 "pattern": "test",
955 "context_before": 1,
956 "context_after": 1
957 });
958
959 let result = tool.execute(params, &context).await.unwrap();
960 assert!(result.is_success());
961 }
962
963 #[tokio::test]
964 async fn test_grep_tool_missing_pattern() {
965 let tool = GrepTool::new();
966 let context = ToolContext::new(PathBuf::from("/tmp"));
967 let params = serde_json::json!({});
968
969 let result = tool.execute(params, &context).await;
970 assert!(result.is_err());
971 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
972 }
973
974 #[test]
975 fn test_grep_tool_name() {
976 let tool = GrepTool::new();
977 assert_eq!(tool.name(), "grep");
978 }
979
980 #[test]
981 fn test_grep_tool_description() {
982 let tool = GrepTool::new();
983 assert!(!tool.description().is_empty());
984 assert!(tool.description().contains("regex"));
985 }
986
987 #[test]
988 fn test_grep_tool_input_schema() {
989 let tool = GrepTool::new();
990 let schema = tool.input_schema();
991
992 assert_eq!(schema["type"], "object");
993 assert!(schema["properties"]["pattern"].is_object());
994 assert!(schema["properties"]["mode"].is_object());
995 assert!(schema["required"]
996 .as_array()
997 .unwrap()
998 .contains(&serde_json::json!("pattern")));
999 }
1000
1001 #[tokio::test]
1002 async fn test_grep_tool_check_permissions() {
1003 let tool = GrepTool::new();
1004 let context = ToolContext::new(PathBuf::from("/tmp"));
1005 let params = serde_json::json!({"pattern": "test"});
1006
1007 let result = tool.check_permissions(¶ms, &context).await;
1008 assert!(result.is_allowed());
1009 }
1010
1011 #[tokio::test]
1012 async fn test_grep_tool_cancellation() {
1013 let tool = GrepTool::new();
1014 let token = tokio_util::sync::CancellationToken::new();
1015 token.cancel();
1016
1017 let context = ToolContext::new(PathBuf::from("/tmp")).with_cancellation_token(token);
1018 let params = serde_json::json!({"pattern": "test"});
1019
1020 let result = tool.execute(params, &context).await;
1021 assert!(result.is_err());
1022 assert!(matches!(result.unwrap_err(), ToolError::Cancelled));
1023 }
1024
1025 #[test]
1026 fn test_is_binary_file() {
1027 let tool = GrepTool::new();
1028
1029 assert!(tool.is_binary_file(Path::new("test.exe")));
1031 assert!(tool.is_binary_file(Path::new("image.png")));
1032 assert!(tool.is_binary_file(Path::new("archive.zip")));
1033 assert!(!tool.is_binary_file(Path::new("code.rs")));
1034 assert!(!tool.is_binary_file(Path::new("readme.md")));
1035 }
1036}