code_mesh_core/tool/
grep.rs1use 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
12pub 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 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 let mut cmd = Command::new(rg_path);
113
114 cmd.arg("--no-heading")
116 .arg("--no-config");
117
118 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 if params.case_insensitive {
136 cmd.arg("--ignore-case");
137 }
138
139 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 if let Some(max) = params.max_count {
149 cmd.arg("--max-count").arg(max.to_string());
150 }
151
152 if let Some(glob) = ¶ms.glob {
154 cmd.arg("--glob").arg(glob);
155 }
156
157 cmd.arg(¶ms.pattern);
159
160 let search_path = if let Some(path) = ¶ms.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 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 if output.status.code() == Some(1) {
185 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 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 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}