1use super::search::{self, content, pattern};
7use super::{resolve_path_in, Tool, ToolContext};
8use crate::{Result, RuntimeError};
9use serde_json::{json, Value};
10use std::time::Duration;
11
12const TIMEOUT: Duration = Duration::from_secs(15);
14
15const MAX_CONTEXT: usize = 1000;
17
18pub struct GrepTool;
19
20#[async_trait::async_trait]
21impl Tool for GrepTool {
22 fn effect(&self) -> crate::tools::catalog::ToolEffect {
23 crate::tools::catalog::ToolEffect::ReadOnly
24 }
25
26 fn origin(&self) -> crate::tools::ToolOrigin {
27 crate::tools::ToolOrigin::Builtin
28 }
29
30 fn name(&self) -> &str {
31 "grep"
32 }
33
34 fn description(&self) -> &str {
35 "Search file contents with a regex (Rust regex syntax, as in ripgrep). Returns matching lines with file paths and line numbers. Respects .gitignore (use no_ignore to include ignored files); hidden files are searched, binary files skipped."
36 }
37
38 fn parameters(&self) -> Value {
39 json!({
40 "type": "object",
41 "properties": {
42 "pattern": {
43 "type": "string",
44 "description": "Regex pattern to search for"
45 },
46 "path": {
47 "type": "string",
48 "description": "File or directory to search in (default: current directory)"
49 },
50 "include": {
51 "type": "string",
52 "description": "Glob pattern to filter files (e.g. \"*.rs\", \"*.py\")"
53 },
54 "context": {
55 "type": "integer",
56 "description": "Number of context lines to show before and after each match"
57 },
58 "no_ignore": {
59 "type": "boolean",
60 "description": "Also search files skipped by .gitignore/.ignore, node_modules and target (default false)"
61 }
62 },
63 "required": ["pattern"]
64 })
65 }
66
67 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
68 let request = request(¶ms, &ctx)?;
69 run_search(request, ctx.capabilities.launch_cancel.clone(), TIMEOUT).await
70 }
71}
72
73fn request(params: &Value, ctx: &ToolContext) -> Result<content::ContentRequest> {
75 let regex = params["pattern"]
76 .as_str()
77 .ok_or_else(|| RuntimeError::Tool("Missing pattern parameter".to_string()))?;
78 let root = resolve_path_in(
79 params["path"].as_str().unwrap_or("."),
80 ctx.capabilities.cwd.as_deref(),
81 );
82 let matcher = pattern::content_matcher(regex).map_err(RuntimeError::Tool)?;
83 let include = match params["include"].as_str().map(str::trim) {
84 Some(glob) if !glob.is_empty() => {
85 Some(pattern::include_filter(glob).map_err(RuntimeError::Tool)?)
86 }
87 _ => None,
88 };
89 let context = params["context"]
90 .as_u64()
91 .map(|n| usize::try_from(n).unwrap_or(usize::MAX).min(MAX_CONTEXT));
92 Ok(content::ContentRequest {
93 root,
94 matcher,
95 include,
96 context,
97 no_ignore: params["no_ignore"].as_bool().unwrap_or(false),
98 cwd: ctx.capabilities.cwd.clone(),
99 cap: ctx.limits.max_tool_buffer,
100 })
101}
102
103async fn run_search(
106 request: content::ContentRequest,
107 turn: Option<crate::CancellationToken>,
108 timeout: Duration,
109) -> Result<String> {
110 let cap = request.cap;
111 let outcome = search::run_blocking(timeout, turn, move |control| {
112 content::search(&request, control, search::open_regular)
113 })
114 .await?
115 .map_err(RuntimeError::Tool)?;
116 content::render(outcome, cap, timeout)
117}
118
119#[cfg(test)]
120mod tests {
121 use super::super::test_helpers::create_tool_context;
122 use super::*;
123 use crate::tools::Tool;
124 use serde_json::json;
125 use std::path::Path;
126
127 fn write(root: &Path, rel: &str, body: &str) {
128 let path = rel.split('/').fold(root.to_path_buf(), |p, c| p.join(c));
129 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
130 std::fs::write(path, body).unwrap();
131 }
132
133 #[test]
134 fn test_grep_tool_schema() {
135 let tool = GrepTool;
136 assert_eq!(tool.name(), "grep");
137 assert!(tool.description().contains("no_ignore"));
138 assert!(tool.description().contains(".gitignore"));
139
140 let params = tool.parameters();
141 assert_eq!(params["type"], "object");
142 assert_eq!(params["required"], json!(["pattern"]));
143 for (name, ty) in [
144 ("pattern", "string"),
145 ("path", "string"),
146 ("include", "string"),
147 ("context", "integer"),
148 ("no_ignore", "boolean"),
149 ] {
150 assert_eq!(params["properties"][name]["type"], ty, "{name}");
151 }
152 }
153
154 #[tokio::test]
156 async fn test_grep_tool_execution() {
157 let dir = tempfile::tempdir().unwrap();
158 let file = dir.path().join("a.txt");
159 std::fs::write(&file, "hello world\nfoo bar\nhello again").unwrap();
160
161 let params = json!({ "pattern": "hello", "path": file.to_string_lossy() });
162 let result = GrepTool
163 .execute(params, create_tool_context())
164 .await
165 .unwrap();
166 assert_eq!(result, "1:hello world\n3:hello again\n");
167 }
168
169 #[tokio::test]
172 async fn default_path_is_the_session_cwd() {
173 let dir = tempfile::tempdir().unwrap();
174 write(dir.path(), "src/a.txt", "x\nneedle\n");
175 let mut ctx = create_tool_context();
176 ctx.capabilities.cwd = Some(dir.path().to_path_buf());
177
178 let result = GrepTool
179 .execute(json!({ "pattern": "needle" }), ctx)
180 .await
181 .unwrap();
182 let shown = dir.path().join(".").join("src").join("a.txt");
183 assert_eq!(result, format!("{}:2:needle\n", shown.display()));
184 }
185
186 #[tokio::test]
187 async fn invalid_regex_is_a_tool_error() {
188 let dir = tempfile::tempdir().unwrap();
189 let params = json!({ "pattern": "fn main() {", "path": dir.path().to_string_lossy() });
190 let err = GrepTool
191 .execute(params, create_tool_context())
192 .await
193 .unwrap_err()
194 .to_string();
195 assert!(err.contains("invalid regex"), "{err}");
196 assert!(err.contains("Hint:"), "{err}");
197 }
198
199 #[tokio::test]
201 async fn missing_path_is_a_tool_error() {
202 let dir = tempfile::tempdir().unwrap();
203 let missing = dir.path().join("nope");
204 let params = json!({ "pattern": "x", "path": missing.to_string_lossy() });
205 let err = GrepTool
206 .execute(params, create_tool_context())
207 .await
208 .unwrap_err()
209 .to_string();
210 assert!(err.contains("Path not found"), "{err}");
211 assert!(!err.contains("No matches"), "{err}");
212 }
213
214 #[tokio::test]
215 async fn no_ignore_and_include_reach_the_engine() {
216 let dir = tempfile::tempdir().unwrap();
217 let root = dir.path();
218 write(root, "node_modules/m.rs", "hit\n");
219 write(root, "src/a.rs", "hit\n");
220 write(root, "src/b.txt", "hit\n");
221 let path = root.to_string_lossy();
222
223 let params = json!({ "pattern": "hit", "path": path, "include": "*.rs" });
224 let result = GrepTool
225 .execute(params, create_tool_context())
226 .await
227 .unwrap();
228 let a = root.join("src").join("a.rs").display().to_string();
229 assert_eq!(result, format!("{a}:1:hit\n"));
230
231 let params =
232 json!({ "pattern": "hit", "path": path, "include": "*.rs", "no_ignore": true });
233 let result = GrepTool
234 .execute(params, create_tool_context())
235 .await
236 .unwrap();
237 let m = root.join("node_modules").join("m.rs").display().to_string();
238 assert_eq!(result, format!("{m}:1:hit\n{a}:1:hit\n"));
239 }
240
241 #[tokio::test]
242 async fn output_is_capped_at_max_tool_buffer() {
243 let dir = tempfile::tempdir().unwrap();
244 std::fs::write(dir.path().join("a.txt"), "hit\n".repeat(1000)).unwrap();
245 let mut ctx = create_tool_context();
246 ctx.limits.max_tool_buffer = 100;
247 let params =
248 json!({ "pattern": "hit", "path": dir.path().join("a.txt").to_string_lossy() });
249 let result = GrepTool.execute(params, ctx).await.unwrap();
250 let (body, footer) = result.split_once("\n\n").unwrap();
251 assert!(body.len() < 100 && body.ends_with(":hit"), "{body}");
252 assert!(
253 body.len() > 80,
254 "stopped well short of the cap: {}",
255 body.len()
256 );
257 assert_eq!(footer, "... (output truncated, more than 100 total bytes)");
258 }
259
260 #[tokio::test]
262 async fn capped_output_ends_on_a_whole_line_with_the_footer() {
263 let dir = tempfile::tempdir().unwrap();
264 let file = dir.path().join("a.txt");
265 std::fs::write(&file, "aaaa\nbbbb\ncccc\n").unwrap();
266 let mut ctx = create_tool_context();
267 ctx.limits.max_tool_buffer = 16;
268 let params = json!({ "pattern": "[a-c]", "path": file.to_string_lossy() });
269 let result = GrepTool.execute(params, ctx).await.unwrap();
270 assert_eq!(
271 result,
272 "1:aaaa\n2:bbbb\n\n... (output truncated, more than 16 total bytes)"
273 );
274 }
275
276 #[tokio::test]
278 async fn turn_cancellation_reaches_the_search() {
279 let dir = tempfile::tempdir().unwrap();
280 write(dir.path(), "a.txt", "hit\n");
281 let turn = crate::CancellationToken::new();
282 turn.cancel();
283 let mut ctx = create_tool_context();
284 ctx.capabilities.launch_cancel = Some(turn);
285 let params = json!({ "pattern": "hit", "path": dir.path().to_string_lossy() });
286 let err = GrepTool.execute(params, ctx).await.unwrap_err().to_string();
287 assert!(err.contains("Grep was cancelled"), "{err}");
288 }
289
290 #[tokio::test]
293 async fn deadline_reaches_the_search() {
294 let dir = tempfile::tempdir().unwrap();
295 write(dir.path(), "a.txt", "hit\n");
296 let params = json!({ "pattern": "hit", "path": dir.path().to_string_lossy() });
297 let request = request(¶ms, &create_tool_context()).unwrap();
298 let err = run_search(request, None, Duration::ZERO)
299 .await
300 .unwrap_err()
301 .to_string();
302 assert!(err.contains("timed out"), "{err}");
303 }
304
305 fn tool_msg(e: RuntimeError) -> String {
309 match e {
310 RuntimeError::Tool(msg) => msg,
311 other => other.to_string(),
312 }
313 }
314
315 const CAP: usize = 256 * 1024;
316
317 fn outcome(text: &str) -> content::ContentOutcome {
319 content::ContentOutcome {
320 text: text.to_string(),
321 searched: 1,
322 ..Default::default()
323 }
324 }
325
326 #[test]
328 fn nothing_found_is_no_matches() {
329 assert_eq!(
330 content::render(outcome(""), CAP, TIMEOUT).unwrap(),
331 "No matches found."
332 );
333 }
334
335 #[test]
338 fn errors_without_output_are_an_error() {
339 let mut failed = outcome("");
340 failed.searched = 0;
341 failed.errors.add("/p/nope: No such file or directory");
342 let err = content::render(failed, CAP, TIMEOUT)
343 .map_err(tool_msg)
344 .unwrap_err();
345 assert_eq!(err, "/p/nope: No such file or directory");
346 assert!(!err.contains("No matches"), "{err}");
347 assert!(!err.contains("invalid regex"), "{err}");
348 }
349
350 #[test]
353 fn matches_with_errors_keep_the_matches_and_add_a_note() {
354 let mut out = outcome("/p/x:1:alpha\n");
355 out.errors.add("/p/a: Permission denied");
356 out.errors.add("/p/b: Permission denied");
357 assert_eq!(
358 content::render(out, CAP, TIMEOUT).unwrap(),
359 "/p/x:1:alpha\n\n[2 errors while searching, results may be incomplete. First: /p/a: Permission denied]"
360 );
361 }
362
363 #[test]
364 fn a_timeout_keeps_partial_output_with_a_note() {
365 let mut partial = outcome("/p/x:1:alpha\n");
366 partial.stop = Some(search::Stop::TimedOut);
367 assert_eq!(
368 content::render(partial, CAP, TIMEOUT).unwrap(),
369 "/p/x:1:alpha\n\n... (grep timed out after 15s; results are partial, more may be missing)"
370 );
371
372 let mut nothing = outcome("");
373 nothing.stop = Some(search::Stop::TimedOut);
374 let err = content::render(nothing, CAP, TIMEOUT)
375 .map_err(tool_msg)
376 .unwrap_err();
377 assert!(
378 err.starts_with("Grep timed out after 15s before finding a match"),
379 "{err}"
380 );
381 }
382
383 const TEXT: &str = "alpha\nbeta\ngamma\nalpha|beta\nfoo(bar\nfoo{x\ncolor\ncolour\naaa\nxx\n123\naa\nword here\nfn main() {\n";
386
387 fn text_dir() -> tempfile::TempDir {
389 let dir = tempfile::tempdir().unwrap();
390 write(dir.path(), "t.txt", TEXT);
391 dir
392 }
393
394 async fn grep_in(path: &Path, pattern: &str) -> Result<String> {
395 let params = json!({ "pattern": pattern, "path": path });
396 GrepTool.execute(params, create_tool_context()).await
397 }
398
399 fn lines_of(out: &str) -> Vec<&str> {
401 out.lines()
402 .filter_map(|l| l.split_once(':').map(|(_, text)| text))
403 .collect()
404 }
405
406 #[tokio::test]
408 async fn alternation_matches_both_lines() {
409 let dir = text_dir();
410 let out = grep_in(&dir.path().join("t.txt"), "alpha|beta")
411 .await
412 .unwrap();
413 assert_eq!(lines_of(&out), ["alpha", "beta", "alpha|beta"], "{out}");
414 }
415
416 #[tokio::test]
418 async fn quantifiers_groups_and_classes_work() {
419 let dir = text_dir();
420 let file = dir.path().join("t.txt");
421 for (pattern, want) in [
422 ("^a+$", &["aaa", "aa"][..]),
423 ("colou?r", &["color", "colour"][..]),
424 ("^x{2}$", &["xx"][..]),
425 ("^(al|be)", &["alpha", "beta", "alpha|beta"][..]),
426 ("^[[:digit:]]+$", &["123"][..]),
427 ] {
428 let out = grep_in(&file, pattern).await.unwrap();
429 assert_eq!(lines_of(&out), want, "{pattern}: {out}");
430 }
431 }
432
433 #[tokio::test]
436 async fn escaped_paren_is_literal() {
437 let dir = text_dir();
438 let out = grep_in(&dir.path().join("t.txt"), "foo\\(").await.unwrap();
439 assert_eq!(lines_of(&out), ["foo(bar"], "{out}");
440 }
441
442 #[tokio::test]
445 async fn invalid_pattern_is_an_error_not_no_matches() {
446 let dir = text_dir();
447 let err = grep_in(&dir.path().join("t.txt"), "foo(")
448 .await
449 .map_err(tool_msg)
450 .unwrap_err();
451 assert!(err.starts_with("invalid regex: "), "{err}");
452 assert!(err.contains("unclosed group"), "{err}");
453 assert!(err.contains("Hint: the pattern is a Rust regex"), "{err}");
454 assert!(!err.contains("No matches"), "{err}");
455 }
456
457 #[tokio::test]
460 async fn genuine_no_match_says_no_matches_found() {
461 let dir = text_dir();
462 assert_eq!(
463 grep_in(dir.path(), "zebra").await.unwrap(),
464 format!("No matches found.\n\n{}", search::IGNORED_NOTE)
465 );
466 }
467
468 #[tokio::test]
471 async fn an_ignored_file_is_missed_with_a_note_and_no_ignore_finds_it() {
472 let dir = tempfile::tempdir().unwrap();
473 write(dir.path(), ".ignore", ".env\n");
474 write(dir.path(), ".env", "API_TOKEN=x\n");
475 let missed = grep_in(dir.path(), "API_TOKEN").await.unwrap();
476 assert!(missed.starts_with("No matches found."), "{missed}");
477 assert!(missed.contains("no_ignore: true"), "{missed}");
478 let params = json!({ "pattern": "API_TOKEN", "path": dir.path(), "no_ignore": true });
479 let found = GrepTool
480 .execute(params, create_tool_context())
481 .await
482 .unwrap();
483 assert!(found.contains("API_TOKEN=x"), "{found}");
484 }
485
486 #[tokio::test]
488 async fn no_ignored_note_with_no_ignore_or_a_file_root() {
489 let dir = text_dir();
490 let params = json!({ "pattern": "zebra", "path": dir.path(), "no_ignore": true });
491 assert_eq!(
492 GrepTool
493 .execute(params, create_tool_context())
494 .await
495 .unwrap(),
496 "No matches found."
497 );
498 assert_eq!(
499 grep_in(&dir.path().join("t.txt"), "zebra").await.unwrap(),
500 "No matches found."
501 );
502 }
503
504 #[tokio::test]
507 async fn rust_regex_edge_cases_are_pinned() {
508 let dir = text_dir();
509 let file = dir.path().join("t.txt");
510 let out = grep_in(&file, "alpha\\|beta").await.unwrap();
512 assert_eq!(lines_of(&out), ["alpha|beta"], "{out}");
513 let out = grep_in(&file, "\\d+").await.unwrap();
515 assert_eq!(lines_of(&out), ["123"], "{out}");
516 let out = grep_in(&file, "(?i)ALPHA").await.unwrap();
517 assert_eq!(lines_of(&out), ["alpha", "alpha|beta"], "{out}");
518 let out = grep_in(&file, "\\bword\\b").await.unwrap();
520 assert_eq!(lines_of(&out), ["word here"], "{out}");
521 let out = grep_in(&file, "\\w+\\s").await.unwrap();
522 assert!(lines_of(&out).contains(&"word here"), "{out}");
523 for pattern in ["fn main() {", "foo{", "*foo", "^(a)\\1$", "(?=x)"] {
526 let err = grep_in(&file, pattern).await.map_err(tool_msg).unwrap_err();
527 assert!(err.starts_with("invalid regex: "), "{pattern}: {err}");
528 }
529 }
530
531 #[tokio::test]
533 async fn binary_file_matches_are_surfaced() {
534 let only = tempfile::tempdir().unwrap();
535 write(only.path(), "blob.bin", "abc\0alpha\n");
536 let out = grep_in(only.path(), "alpha").await.unwrap();
537 assert!(
538 out.starts_with("[binary file matches, lines not shown: "),
539 "{out}"
540 );
541 assert!(out.contains("blob.bin"), "{out}");
542
543 let mixed = tempfile::tempdir().unwrap();
544 write(mixed.path(), "blob.bin", "abc\0alpha\n");
545 write(mixed.path(), "t.txt", "alpha\n");
546 let out = grep_in(mixed.path(), "alpha").await.unwrap();
547 assert!(out.contains("t.txt:1:alpha"), "{out}");
548 assert!(
549 out.contains("[binary file matches, lines not shown: ") && out.contains("blob.bin"),
550 "{out}"
551 );
552 assert!(!out.contains("Binary file"), "{out}");
553 }
554
555 #[cfg(unix)]
558 #[tokio::test]
559 async fn unreadable_file_beside_matches_gives_matches_and_a_note() {
560 use std::os::unix::fs::PermissionsExt;
561 let dir = tempfile::tempdir().unwrap();
562 write(dir.path(), "t.txt", "alpha\n");
563 write(dir.path(), "locked.txt", "alpha\n");
564 let locked = dir.path().join("locked.txt");
565 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
566 if std::fs::read(&locked).is_ok() {
567 eprintln!("skipped: permissions are not enforced for this user");
568 return;
569 }
570 let out = grep_in(dir.path(), "alpha").await.unwrap();
571 assert!(out.contains("t.txt:1:alpha"), "{out}");
572 let note = format!(
573 "[1 error while searching, results may be incomplete. First: {}: Permission denied",
574 locked.display()
575 );
576 assert!(out.contains(¬e), "{out}");
577 }
578}