code_mesh_core/tool/
grep.rs

1//! Grep tool implementation using ripgrep
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use std::process::Stdio;
8use tokio::process::Command;
9
10use super::{Tool, ToolContext, ToolResult, ToolError};
11
12/// Tool for searching file contents using ripgrep
13pub struct GrepTool;
14
15#[derive(Debug, Deserialize)]
16struct GrepParams {
17    pattern: String,
18    #[serde(default)]
19    glob: Option<String>,
20    #[serde(default)]
21    path: Option<String>,
22    #[serde(default = "default_output_mode")]
23    output_mode: String,
24    #[serde(default)]
25    case_insensitive: bool,
26    #[serde(default)]
27    line_numbers: bool,
28    #[serde(default)]
29    context_before: Option<usize>,
30    #[serde(default)]
31    context_after: Option<usize>,
32    #[serde(default)]
33    max_count: Option<usize>,
34}
35
36fn default_output_mode() -> String {
37    "files_with_matches".to_string()
38}
39
40#[async_trait]
41impl Tool for GrepTool {
42    fn id(&self) -> &str {
43        "grep"
44    }
45    
46    fn description(&self) -> &str {
47        "Search for patterns in files using ripgrep"
48    }
49    
50    fn parameters_schema(&self) -> Value {
51        json!({
52            "type": "object",
53            "properties": {
54                "pattern": {
55                    "type": "string",
56                    "description": "Regular expression pattern to search for"
57                },
58                "glob": {
59                    "type": "string",
60                    "description": "Glob pattern to filter files (e.g., '*.rs', '*.{js,ts}')"
61                },
62                "path": {
63                    "type": "string",
64                    "description": "Directory or file to search in (default: current directory)"
65                },
66                "output_mode": {
67                    "type": "string",
68                    "enum": ["content", "files_with_matches", "count"],
69                    "description": "Output format",
70                    "default": "files_with_matches"
71                },
72                "case_insensitive": {
73                    "type": "boolean",
74                    "description": "Case insensitive search",
75                    "default": false
76                },
77                "line_numbers": {
78                    "type": "boolean",
79                    "description": "Show line numbers",
80                    "default": false
81                },
82                "context_before": {
83                    "type": "integer",
84                    "description": "Lines of context before matches"
85                },
86                "context_after": {
87                    "type": "integer", 
88                    "description": "Lines of context after matches"
89                },
90                "max_count": {
91                    "type": "integer",
92                    "description": "Maximum number of results"
93                }
94            },
95            "required": ["pattern"]
96        })
97    }
98    
99    async fn execute(
100        &self,
101        args: Value,
102        ctx: ToolContext,
103    ) -> Result<ToolResult, ToolError> {
104        let params: GrepParams = serde_json::from_value(args)
105            .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
106        
107        // Check if ripgrep is available
108        let rg_path = which::which("rg").or_else(|_| which::which("ripgrep"))
109            .map_err(|_| ToolError::ExecutionFailed("ripgrep not found. Please install ripgrep.".to_string()))?;
110        
111        // Build command
112        let mut cmd = Command::new(rg_path);
113        
114        // Basic options
115        cmd.arg("--no-heading")
116           .arg("--no-config");
117        
118        // Output mode
119        match params.output_mode.as_str() {
120            "content" => {
121                if params.line_numbers {
122                    cmd.arg("--line-number");
123                }
124            },
125            "files_with_matches" => {
126                cmd.arg("--files-with-matches");
127            },
128            "count" => {
129                cmd.arg("--count");
130            },
131            _ => return Err(ToolError::InvalidParameters("Invalid output_mode".to_string())),
132        }
133        
134        // Case sensitivity
135        if params.case_insensitive {
136            cmd.arg("--ignore-case");
137        }
138        
139        // Context
140        if let Some(before) = params.context_before {
141            cmd.arg("--before-context").arg(before.to_string());
142        }
143        if let Some(after) = params.context_after {
144            cmd.arg("--after-context").arg(after.to_string());
145        }
146        
147        // Max count
148        if let Some(max) = params.max_count {
149            cmd.arg("--max-count").arg(max.to_string());
150        }
151        
152        // Glob pattern
153        if let Some(glob) = &params.glob {
154            cmd.arg("--glob").arg(glob);
155        }
156        
157        // Pattern
158        cmd.arg(&params.pattern);
159        
160        // Search path
161        let search_path = if let Some(path) = &params.path {
162            if PathBuf::from(path).is_absolute() {
163                PathBuf::from(path)
164            } else {
165                ctx.working_directory.join(path)
166            }
167        } else {
168            ctx.working_directory.clone()
169        };
170        cmd.arg(&search_path);
171        
172        // Execute
173        cmd.stdout(Stdio::piped())
174           .stderr(Stdio::piped());
175        
176        let output = cmd.output().await
177            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to execute ripgrep: {}", e)))?;
178        
179        let stdout = String::from_utf8_lossy(&output.stdout);
180        let stderr = String::from_utf8_lossy(&output.stderr);
181        
182        if !output.status.success() && !stdout.is_empty() {
183            // ripgrep returns non-zero when no matches found, but that's not an error
184            if output.status.code() == Some(1) {
185                // No matches found
186                return Ok(ToolResult {
187                    title: format!("No matches found for '{}'", params.pattern),
188                    metadata: json!({
189                        "pattern": params.pattern,
190                        "matches": 0,
191                        "output_mode": params.output_mode,
192                    }),
193                    output: "No matches found".to_string(),
194                });
195            } else {
196                return Err(ToolError::ExecutionFailed(format!("ripgrep error: {}", stderr)));
197            }
198        }
199        
200        // Count results
201        let result_count = match params.output_mode.as_str() {
202            "content" => stdout.lines().count(),
203            "files_with_matches" => stdout.lines().filter(|line| !line.trim().is_empty()).count(),
204            "count" => stdout.lines()
205                .filter_map(|line| line.split(':').last()?.parse::<usize>().ok())
206                .sum(),
207            _ => 0,
208        };
209        
210        // Truncate if too long
211        let truncated = stdout.len() > 10000;
212        let display_output = if truncated {
213            format!("{}... (truncated, {} total results)", &stdout[..10000], result_count)
214        } else {
215            stdout.to_string()
216        };
217        
218        let metadata = json!({
219            "pattern": params.pattern,
220            "glob": params.glob,
221            "path": search_path.to_string_lossy(),
222            "output_mode": params.output_mode,
223            "matches": result_count,
224            "truncated": truncated,
225        });
226        
227        Ok(ToolResult {
228            title: format!("Found {} match{} for '{}'", 
229                result_count, 
230                if result_count == 1 { "" } else { "es" },
231                params.pattern
232            ),
233            metadata,
234            output: display_output,
235        })
236    }
237}