1use super::path_security::PathGuard;
2use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
4use crate::tools::typed::TypedTool;
5use async_trait::async_trait;
6use regex::RegexBuilder;
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde_json::{Value, json};
10use std::path::{Path, PathBuf};
11use tokio::fs;
12use tokio::sync::oneshot;
13
14const GREP_MAX_LINE_LENGTH: usize = 500;
16
17fn truncate_line(line: &str) -> (String, bool) {
19 if line.len() <= GREP_MAX_LINE_LENGTH {
20 (line.to_string(), false)
21 } else {
22 (
23 format!("{}... [truncated]", &line[..GREP_MAX_LINE_LENGTH]),
24 true,
25 )
26 }
27}
28
29#[derive(Deserialize, JsonSchema)]
33pub struct GrepArgs {
34 pattern: String,
35 #[serde(default)]
36 path: String,
37 #[serde(default)]
38 case_insensitive: bool,
39 #[serde(default)]
40 literal: bool,
41 #[serde(default)]
42 context: usize,
43 include: Option<String>,
44 #[serde(default = "default_grep_results")]
45 max_results: usize,
46}
47
48fn default_grep_results() -> usize {
49 100
50}
51
52pub struct GrepTool {
54 root_dir: Option<PathBuf>,
55}
56
57impl GrepTool {
58 pub fn new() -> Self {
60 Self { root_dir: None }
61 }
62
63 pub fn with_cwd(cwd: PathBuf) -> Self {
65 Self {
66 root_dir: Some(cwd),
67 }
68 }
69
70 fn matches_glob(file_name: &str, pattern: &str) -> bool {
72 if let Some(ext) = pattern.strip_prefix("*.") {
73 file_name.ends_with(ext)
74 } else if pattern.contains('*') {
75 let parts: Vec<&str> = pattern.split('*').collect();
77 if parts.len() == 2 {
78 file_name.starts_with(parts[0]) && file_name.ends_with(parts[1])
79 } else {
80 file_name == pattern
81 }
82 } else {
83 file_name == pattern
84 }
85 }
86
87 #[allow(clippy::type_complexity)]
88 #[allow(clippy::too_many_arguments)]
89 async fn grep_impl(
90 root_dir: &Path,
91 pattern: &str,
92 path: &str,
93 case_insensitive: bool,
94 literal: bool,
95 context_before: usize,
96 context_after: usize,
97 include: Option<&str>,
98 max_results: usize,
99 ) -> Result<(String, bool), ToolError> {
100 let guard = PathGuard::new(root_dir);
102 let root = guard
103 .validate_traversal(Path::new(path))
104 .map_err(|e| e.to_string())?;
105
106 if !root.exists() {
107 return Err(format!("Path not found: {}", path));
108 }
109
110 let pattern = if literal {
112 regex::escape(pattern)
113 } else {
114 pattern.to_string()
115 };
116
117 let re = RegexBuilder::new(&pattern)
118 .case_insensitive(case_insensitive)
119 .build()
120 .map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
121
122 let mut matches: Vec<String> = Vec::new();
123 let mut lines_truncated = false;
124 Self::grep_walk(
125 &root,
126 &root,
127 &re,
128 include,
129 context_before,
130 context_after,
131 max_results,
132 &mut matches,
133 &mut lines_truncated,
134 )
135 .await?;
136
137 if matches.is_empty() {
138 Ok(("No matches found".to_string(), false))
139 } else {
140 let header = format!("Found {} matches:\n", matches.len());
141 Ok((header + &matches.join("\n"), lines_truncated))
142 }
143 }
144
145 async fn read_file_lines(path: &Path) -> Result<Vec<String>, ToolError> {
147 match fs::read_to_string(path).await {
148 Ok(content) => {
149 let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
151 Ok(normalized.lines().map(|s| s.to_string()).collect())
152 }
153 Err(e) => Err(format!("Cannot read file: {}", e)),
154 }
155 }
156
157 #[allow(clippy::too_many_arguments)]
158 async fn grep_walk(
159 root: &Path,
160 current: &Path,
161 re: ®ex::Regex,
162 include: Option<&str>,
163 context_before: usize,
164 context_after: usize,
165 max_results: usize,
166 matches: &mut Vec<String>,
167 lines_truncated: &mut bool,
168 ) -> Result<(), ToolError> {
169 if matches.len() >= max_results {
170 return Ok(());
171 }
172
173 if current
176 .symlink_metadata()
177 .map(|m| m.file_type().is_symlink())
178 .unwrap_or(false)
179 && !current.exists()
180 {
181 return Ok(());
182 }
183
184 if current.is_file() {
185 if let Some(glob) = include {
187 let file_name = current
188 .file_name()
189 .map(|n| n.to_string_lossy().to_string())
190 .unwrap_or_default();
191 if !Self::matches_glob(&file_name, glob) {
192 return Ok(());
193 }
194 }
195
196 match Self::read_file_lines(current).await {
198 Ok(lines) => {
199 let relative = current.strip_prefix(root).unwrap_or(current).display();
200
201 for (i, line) in lines.iter().enumerate() {
202 if re.is_match(line) {
203 let context_lines_count = if context_before > 0 || context_after > 0 {
206 let start = if context_before > 0 {
207 i.saturating_sub(context_before)
208 } else {
209 i
210 };
211 let end = std::cmp::min(lines.len(), i + context_after + 1);
212 end - start
213 } else {
214 1
215 };
216
217 if matches.len() + context_lines_count > max_results {
218 return Ok(());
220 }
221
222 if context_before > 0 && i > 0 {
224 let start = i.saturating_sub(context_before);
225 for (j, context_line) in
226 lines.iter().enumerate().take(i).skip(start)
227 {
228 let (truncated_text, was_truncated) =
229 truncate_line(context_line);
230 if was_truncated {
231 *lines_truncated = true;
232 }
233 matches.push(format!(
234 "{}-{}- {}",
235 relative,
236 j + 1,
237 truncated_text
238 ));
239 }
240 }
241
242 let (truncated_text, was_truncated) = truncate_line(line);
244 if was_truncated {
245 *lines_truncated = true;
246 }
247 matches.push(format!("{}:{}: {}", relative, i + 1, truncated_text));
248
249 if context_after > 0 {
251 let end = std::cmp::min(lines.len(), i + context_after + 1);
252 for (j, context_line) in
253 lines.iter().enumerate().take(end).skip(i + 1)
254 {
255 let (truncated_text, was_truncated) =
256 truncate_line(context_line);
257 if was_truncated {
258 *lines_truncated = true;
259 }
260 matches.push(format!(
261 "{}-{}- {}",
262 relative,
263 j + 1,
264 truncated_text
265 ));
266 }
267 }
268
269 if matches.len() >= max_results {
270 return Ok(());
271 }
272 }
273 }
274 }
275 Err(_) => {
276 }
278 }
279 return Ok(());
280 }
281
282 let mut entries = fs::read_dir(current)
284 .await
285 .map_err(|e| format!("Cannot read directory {}: {}", current.display(), e))?;
286
287 while let Some(entry) = entries
288 .next_entry()
289 .await
290 .map_err(|e| format!("Error reading entry: {}", e))?
291 {
292 let entry_path = entry.path();
293
294 if entry_path
296 .file_name()
297 .map(|n| n.to_string_lossy().starts_with('.'))
298 .unwrap_or(false)
299 {
300 continue;
301 }
302
303 if entry_path.is_dir() {
305 let dir_name = entry_path
306 .file_name()
307 .map(|n| n.to_string_lossy().to_string())
308 .unwrap_or_default();
309 if matches!(
310 dir_name.as_str(),
311 "node_modules"
312 | "target"
313 | ".git"
314 | "dist"
315 | "build"
316 | "__pycache__"
317 | ".venv"
318 | "venv"
319 ) {
320 continue;
321 }
322 }
323
324 Box::pin(Self::grep_walk(
325 root,
326 &entry_path,
327 re,
328 include,
329 context_before,
330 context_after,
331 max_results,
332 matches,
333 lines_truncated,
334 ))
335 .await?;
336 }
337
338 Ok(())
339 }
340}
341
342impl Default for GrepTool {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348#[async_trait]
349impl AgentTool for GrepTool {
350 fn name(&self) -> &str {
351 "grep"
352 }
353
354 fn label(&self) -> &str {
355 "Grep"
356 }
357
358 fn essential(&self) -> bool {
359 true
360 }
361 fn description(&self) -> &str {
362 "Search files for a pattern. Returns matching lines with file paths and line numbers. Use literal=true to treat pattern as a literal string. Use context=n to show n lines before and after matches. Long lines are truncated to 500 chars."
363 }
364
365 fn parameters_schema(&self) -> Value {
366 json!({
367 "type": "object",
368 "properties": {
369 "pattern": {
370 "type": "string",
371 "description": "The pattern to search for (regex by default, or literal string if literal=true)"
372 },
373 "path": {
374 "type": "string",
375 "description": "The directory or file to search in",
376 "default": "."
377 },
378 "case_insensitive": {
379 "type": "boolean",
380 "description": "If true, perform case-insensitive search",
381 "default": false
382 },
383 "literal": {
384 "type": "boolean",
385 "description": "If true, treat pattern as a literal string instead of regex",
386 "default": false
387 },
388 "context": {
389 "type": "integer",
390 "description": "Number of lines to show before and after each match",
391 "default": 0
392 },
393 "include": {
394 "type": "string",
395 "description": "Glob pattern to filter files (e.g., '*.rs', '*.ts')"
396 },
397 "max_results": {
398 "type": "integer",
399 "description": "Maximum number of results to return",
400 "default": 100
401 }
402 },
403 "required": ["pattern"]
404 })
405 }
406
407 async fn execute(
408 &self,
409 _tool_call_id: &str,
410 params: Value,
411 _signal: Option<oneshot::Receiver<()>>,
412 ctx: &ToolContext,
413 ) -> Result<AgentToolResult, ToolError> {
414 let args: GrepArgs =
415 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
416 self.execute_typed(_tool_call_id, args, _signal, ctx).await
417 }
418}
419
420#[async_trait]
421impl TypedTool for GrepTool {
422 type Args = GrepArgs;
423 async fn execute_typed(
424 &self,
425 _tool_call_id: &str,
426 args: Self::Args,
427 _signal: Option<oneshot::Receiver<()>>,
428 ctx: &ToolContext,
429 ) -> Result<AgentToolResult, ToolError> {
430 let path = if args.path.is_empty() {
431 "."
432 } else {
433 &args.path
434 };
435 let pattern = &args.pattern;
436 let case_insensitive = args.case_insensitive;
437 let literal = args.literal;
438 let context = args.context;
439 let include = args.include.as_deref();
440 let max_results = args.max_results;
441
442 if let Some(ref resolver) = ctx.url_resolver
444 && resolver.can_resolve(path)
445 {
446 let resolved = resolver.resolve(path).await?;
447 let pattern_str = if literal {
448 regex::escape(pattern)
449 } else {
450 pattern.to_string()
451 };
452 let re = RegexBuilder::new(&pattern_str)
453 .case_insensitive(case_insensitive)
454 .build()
455 .map_err(|e| format!("Invalid pattern '{}': {}", pattern, e))?;
456
457 let lines: Vec<&str> = resolved.content.lines().collect();
458 let mut matches: Vec<String> = Vec::new();
459 let mut lines_truncated = false;
460
461 for (i, line) in lines.iter().enumerate() {
462 if matches.len() >= max_results {
463 break;
464 }
465 if re.is_match(line) {
466 if context > 0 && i > 0 {
467 let start = i.saturating_sub(context);
468 for (j, ctx_line) in lines.iter().enumerate().take(i).skip(start) {
469 let (truncated, was_trunc) = truncate_line(ctx_line);
470 if was_trunc {
471 lines_truncated = true;
472 }
473 matches.push(format!("{}-{}- {}", path, j + 1, truncated));
474 }
475 }
476 let (truncated, was_trunc) = truncate_line(line);
477 if was_trunc {
478 lines_truncated = true;
479 }
480 matches.push(format!("{}:{}: {}", path, i + 1, truncated));
481 if context > 0 {
482 let end = std::cmp::min(lines.len(), i + context + 1);
483 for (j, ctx_line) in lines.iter().enumerate().take(end).skip(i + 1) {
484 let (truncated, was_trunc) = truncate_line(ctx_line);
485 if was_trunc {
486 lines_truncated = true;
487 }
488 matches.push(format!("{}-{}- {}", path, j + 1, truncated));
489 }
490 }
491 if matches.len() >= max_results {
492 break;
493 }
494 }
495 }
496 let output = if matches.is_empty() {
497 "No matches found".to_string()
498 } else {
499 format!("Found {} matches:\n{}", matches.len(), matches.join("\n"))
500 };
501 let mut result = AgentToolResult::success(output);
502 if lines_truncated {
503 result.metadata = Some(json!({
504 "lines_truncated": true,
505 "message": "Some lines truncated to 500 chars. Use read tool to see full lines."
506 }));
507 }
508 return Ok(result);
509 }
510
511 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
512 match Self::grep_impl(
513 root,
514 pattern,
515 path,
516 case_insensitive,
517 literal,
518 context,
519 context,
520 include,
521 max_results,
522 )
523 .await
524 {
525 Ok((output, lines_truncated)) => {
526 let mut result = AgentToolResult::success(output);
527 if lines_truncated {
528 result.metadata = Some(json!({
529 "lines_truncated": true,
530 "message": "Some lines truncated to 500 chars. Use read tool to see full lines."
531 }));
532 }
533 Ok(result)
534 }
535 Err(e) => Ok(AgentToolResult::error(e)),
536 }
537 }
538}