Skip to main content

aster/tools/search/
grep.rs

1//! Grep Tool Implementation
2//!
3//! Provides content search using regex patterns with ripgrep or grep fallback.
4//!
5//! Requirements: 5.3, 5.4, 5.5, 5.6, 5.7, 5.8
6
7use 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/// Output mode for grep results
25///
26/// Requirements: 5.4
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum GrepOutputMode {
30    /// Return matching lines with content
31    #[default]
32    Content,
33    /// Return only file names that contain matches
34    FilesWithMatches,
35    /// Return count of matches per file
36    Count,
37}
38
39impl GrepOutputMode {
40    /// Parse from string
41    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
51/// Grep tool for searching file contents using regex patterns
52///
53/// Supports:
54/// - Regex pattern matching
55/// - Multiple output modes (content, files_with_matches, count)
56/// - Context lines (before/after)
57/// - Multiline matching
58/// - ripgrep acceleration with grep fallback
59///
60/// Requirements: 5.3, 5.4, 5.5, 5.6, 5.7, 5.8
61pub struct GrepTool {
62    /// Maximum number of results to return
63    max_results: usize,
64    /// Maximum context lines
65    max_context_lines: usize,
66    /// Whether to use ripgrep if available
67    use_ripgrep: bool,
68}
69
70impl Default for GrepTool {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl GrepTool {
77    /// Create a new GrepTool with default settings
78    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    /// Set the maximum number of results
87    pub fn with_max_results(mut self, max_results: usize) -> Self {
88        self.max_results = max_results;
89        self
90    }
91
92    /// Set the maximum context lines
93    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    /// Disable ripgrep (use pure Rust implementation)
99    pub fn without_ripgrep(mut self) -> Self {
100        self.use_ripgrep = false;
101        self
102    }
103
104    /// Check if ripgrep is available
105    fn is_ripgrep_available() -> bool {
106        Command::new("rg").arg("--version").output().is_ok()
107    }
108
109    /// Check if grep is available
110    fn is_grep_available() -> bool {
111        Command::new("grep").arg("--version").output().is_ok()
112    }
113
114    /// Search using ripgrep
115    ///
116    /// Requirements: 5.3
117    #[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        // Add pattern
131        cmd.arg(pattern);
132
133        // Add path
134        cmd.arg(path);
135
136        // Add options based on mode
137        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        // Case sensitivity
156        if case_insensitive {
157            cmd.arg("-i");
158        }
159
160        // Hidden files
161        if include_hidden {
162            cmd.arg("--hidden");
163        }
164
165        // Max count to avoid overwhelming output
166        cmd.arg("--max-count")
167            .arg((self.max_results * 10).to_string());
168
169        // Execute
170        let output = cmd.output().map_err(|e| {
171            ToolError::execution_failed(format!("Failed to execute ripgrep: {}", e))
172        })?;
173
174        // Parse output
175        self.parse_grep_output(&output.stdout, mode, path)
176    }
177
178    /// Search using grep (fallback)
179    ///
180    /// Requirements: 5.7
181    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        // Recursive search
193        cmd.arg("-r");
194
195        // Extended regex
196        cmd.arg("-E");
197
198        // Add options based on mode
199        match mode {
200            GrepOutputMode::Content => {
201                cmd.arg("-n"); // Line numbers
202                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        // Case sensitivity
218        if case_insensitive {
219            cmd.arg("-i");
220        }
221
222        // Add pattern and path
223        cmd.arg(pattern);
224        cmd.arg(path);
225
226        // Execute
227        let output = cmd
228            .output()
229            .map_err(|e| ToolError::execution_failed(format!("Failed to execute grep: {}", e)))?;
230
231        // Parse output
232        self.parse_grep_output(&output.stdout, mode, path)
233    }
234
235    /// Parse grep/ripgrep output into SearchResults
236    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                    // Format: file:line_number:content
253                    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                    // Format: file
267                    results.push(SearchResult::file_match(PathBuf::from(line)));
268                }
269                GrepOutputMode::Count => {
270                    // Format: file:count
271                    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    /// Pure Rust search implementation (fallback when no external tools available)
289    ///
290    /// Requirements: 5.3, 5.5, 5.6
291    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        // Compile regex
301        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        // Walk directory
311        self.search_directory(
312            &regex,
313            path,
314            mode,
315            context_before,
316            context_after,
317            &mut results,
318        )?;
319
320        Ok(results)
321    }
322
323    /// Recursively search a directory
324    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                // Skip hidden files/directories
344                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                // Recurse
353                self.search_directory(
354                    regex,
355                    &entry_path,
356                    mode,
357                    context_before,
358                    context_after,
359                    results,
360                )?;
361
362                // Check result limit
363                if results.len() >= self.max_results * 10 {
364                    break;
365                }
366            }
367        }
368
369        Ok(())
370    }
371
372    /// Search a single file
373    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        // Skip binary files
383        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                    // Get context
403                    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        // Add file-level results for other modes
420        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    /// Check if a file appears to be binary
434    fn is_binary_file(&self, path: &Path) -> bool {
435        // Check by extension first
436        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        // Check first bytes for null characters
450        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    /// Main search method - chooses best available implementation
462    #[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        // Try ripgrep first if enabled
474        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        // Try grep as fallback
487        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        // Fall back to pure Rust implementation
499        self.search_rust(
500            pattern,
501            path,
502            mode,
503            context_before,
504            context_after,
505            case_insensitive,
506        )
507    }
508
509    /// Truncate output to fit within size limit
510    ///
511    /// Requirements: 5.8
512    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            // Find last newline to avoid cutting mid-line
518            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        // Check for cancellation
593        if context.is_cancelled() {
594            return Err(ToolError::Cancelled);
595        }
596
597        // Parse parameters
598        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        // Execute search
646        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        // Truncate results if needed
657        let (results, result_truncated) = truncate_results(results, max_results);
658
659        // Format output
660        let output = format_search_results(&results, result_truncated);
661
662        // Truncate output if too large
663        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        // Grep is a read-only operation, generally safe
680        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        // Create test files with searchable content
697        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        // Case sensitive - should not match "hello" in lowercase
825        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        // Case insensitive - should match both "Hello" and "hello"
837        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        // Should have context lines
869        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        // Short output - no truncation
898        let (output, truncated) = tool.truncate_output("short output");
899        assert_eq!(output, "short output");
900        assert!(!truncated);
901
902        // Long output - should truncate
903        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(&params, &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        // Test by extension
1030        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}