agent_sdk/primitive_tools/
grep.rs1use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7use super::PrimitiveToolContext;
8
9pub struct GrepTool<E: Environment> {
11 ctx: PrimitiveToolContext<E>,
12}
13
14impl<E: Environment> GrepTool<E> {
15 #[must_use]
16 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
17 Self {
18 ctx: PrimitiveToolContext::new(environment, capabilities),
19 }
20 }
21}
22
23#[derive(Debug, Deserialize)]
24struct GrepInput {
25 pattern: String,
27 #[serde(default)]
29 path: Option<String>,
30 #[serde(default = "default_recursive")]
32 recursive: bool,
33 #[serde(default)]
35 case_insensitive: bool,
36}
37
38const fn default_recursive() -> bool {
39 true
40}
41
42impl<E: Environment + 'static, Ctx: Send + Sync + 'static> Tool<Ctx> for GrepTool<E> {
43 type Name = PrimitiveToolName;
44
45 fn name(&self) -> PrimitiveToolName {
46 PrimitiveToolName::Grep
47 }
48
49 fn display_name(&self) -> &'static str {
50 "Search Files"
51 }
52
53 fn description(&self) -> &'static str {
54 "Search for a regex pattern in files. Returns matching lines with file paths and line numbers."
55 }
56
57 fn tier(&self) -> ToolTier {
58 ToolTier::Observe
59 }
60
61 fn input_schema(&self) -> Value {
62 json!({
63 "type": "object",
64 "properties": {
65 "pattern": {
66 "type": "string",
67 "description": "Regex pattern to search for"
68 },
69 "path": {
70 "type": "string",
71 "description": "Path to search in (file or directory). Defaults to environment root."
72 },
73 "recursive": {
74 "type": "boolean",
75 "description": "Search recursively in directories. Default: true"
76 },
77 "case_insensitive": {
78 "type": "boolean",
79 "description": "Case insensitive search. Default: false"
80 }
81 },
82 "required": ["pattern"]
83 })
84 }
85
86 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
87 let input: GrepInput = GrepInput::deserialize(&input)
88 .with_context(|| format!("Invalid input for grep tool: {input}"))?;
89
90 let search_path = input.path.as_ref().map_or_else(
91 || self.ctx.environment.root().to_string(),
92 |p| self.ctx.environment.resolve_path(p),
93 );
94
95 if let Err(reason) = self.ctx.capabilities.check_read(&search_path) {
97 return Ok(ToolResult::error(format!(
98 "Permission denied: cannot search in '{search_path}': {reason}"
99 )));
100 }
101
102 let pattern = if input.case_insensitive {
104 format!("(?i){}", input.pattern)
105 } else {
106 input.pattern.clone()
107 };
108
109 if let Err(err) = regex::Regex::new(&pattern) {
114 return Ok(ToolResult::error(format!(
115 "Invalid pattern '{}': {err:#}",
116 input.pattern
117 )));
118 }
119
120 let matches = self
127 .ctx
128 .environment
129 .grep(&pattern, &search_path, input.recursive)
130 .await
131 .context("Failed to execute grep")?;
132
133 let accessible_matches: Vec<_> = matches
135 .into_iter()
136 .filter(|m| self.ctx.capabilities.check_read(&m.path).is_ok())
137 .collect();
138
139 if accessible_matches.is_empty() {
140 return Ok(ToolResult::success(format!(
141 "No matches found for pattern '{}'",
142 input.pattern
143 )));
144 }
145
146 let count = accessible_matches.len();
147 let max_results = 50;
148
149 let output_lines: Vec<String> = accessible_matches
150 .iter()
151 .take(max_results)
152 .map(|m| {
153 format!(
154 "{}:{}:{}",
155 m.path,
156 m.line_number,
157 truncate_line(&m.line_content, 200)
158 )
159 })
160 .collect();
161
162 let output = if count > max_results {
163 format!(
164 "Found {count} matches (showing first {max_results}):\n{}",
165 output_lines.join("\n")
166 )
167 } else {
168 format!("Found {count} matches:\n{}", output_lines.join("\n"))
169 };
170
171 Ok(ToolResult::success(output))
172 }
173}
174
175fn truncate_line(s: &str, max_len: usize) -> String {
176 let trimmed = s.trim();
177 if trimmed.len() <= max_len {
178 trimmed.to_string()
179 } else {
180 format!("{}...", super::truncate_str(trimmed, max_len))
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::{AgentCapabilities, InMemoryFileSystem};
188
189 fn create_test_tool(
190 fs: Arc<InMemoryFileSystem>,
191 capabilities: AgentCapabilities,
192 ) -> GrepTool<InMemoryFileSystem> {
193 GrepTool::new(fs, capabilities)
194 }
195
196 fn tool_ctx() -> ToolContext<()> {
197 ToolContext::new(())
198 }
199
200 #[tokio::test]
205 async fn test_grep_simple_pattern() -> anyhow::Result<()> {
206 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
207 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
208 .await?;
209
210 let tool = create_test_tool(fs, AgentCapabilities::full_access());
211 let result = tool
212 .execute(&tool_ctx(), json!({"pattern": "println"}))
213 .await?;
214
215 assert!(result.success);
216 assert!(result.output.contains("Found 1 matches"));
217 assert!(result.output.contains("println"));
218 assert!(result.output.contains(":2:")); Ok(())
220 }
221
222 #[tokio::test]
223 async fn test_grep_regex_pattern() -> anyhow::Result<()> {
224 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
225 fs.write_file("test.txt", "foo123\nbar456\nfoo789").await?;
226
227 let tool = create_test_tool(fs, AgentCapabilities::full_access());
228 let result = tool
229 .execute(&tool_ctx(), json!({"pattern": "foo\\d+"}))
230 .await?;
231
232 assert!(result.success);
233 assert!(result.output.contains("Found 2 matches"));
234 Ok(())
235 }
236
237 #[tokio::test]
238 async fn test_grep_no_matches() -> anyhow::Result<()> {
239 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
240 fs.write_file("test.txt", "Hello, World!").await?;
241
242 let tool = create_test_tool(fs, AgentCapabilities::full_access());
243 let result = tool
244 .execute(&tool_ctx(), json!({"pattern": "Rust"}))
245 .await?;
246
247 assert!(result.success);
248 assert!(result.output.contains("No matches found"));
249 Ok(())
250 }
251
252 #[tokio::test]
253 async fn test_grep_case_insensitive() -> anyhow::Result<()> {
254 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
255 fs.write_file("test.txt", "Hello\nHELLO\nhello").await?;
257
258 let tool = create_test_tool(fs, AgentCapabilities::full_access());
259 let result = tool
261 .execute(&tool_ctx(), json!({"pattern": "[Hh][Ee][Ll][Ll][Oo]"}))
262 .await?;
263
264 assert!(result.success);
265 assert!(result.output.contains("Found 3 matches"));
266 Ok(())
267 }
268
269 #[tokio::test]
270 async fn test_grep_with_path() -> anyhow::Result<()> {
271 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
272 fs.write_file("src/main.rs", "fn main() {}").await?;
273 fs.write_file("tests/test.rs", "fn test() {}").await?;
274
275 let tool = create_test_tool(fs, AgentCapabilities::full_access());
276 let result = tool
277 .execute(
278 &tool_ctx(),
279 json!({"pattern": "fn", "path": "/workspace/src"}),
280 )
281 .await?;
282
283 assert!(result.success);
284 assert!(result.output.contains("Found 1 matches"));
285 assert!(result.output.contains("main.rs"));
286 Ok(())
287 }
288
289 #[tokio::test]
290 async fn test_grep_non_recursive() -> anyhow::Result<()> {
291 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
292 fs.write_file("file.txt", "match here").await?;
293 fs.write_file("subdir/nested.txt", "match nested").await?;
294
295 let tool = create_test_tool(fs, AgentCapabilities::full_access());
296 let result = tool
297 .execute(&tool_ctx(), json!({"pattern": "match", "recursive": false}))
298 .await?;
299
300 assert!(result.success);
301 assert!(result.output.contains("Found 1 matches"));
303 assert!(result.output.contains("file.txt"));
304 Ok(())
305 }
306
307 #[tokio::test]
312 async fn test_grep_permission_denied() -> anyhow::Result<()> {
313 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
314 fs.write_file("test.txt", "content").await?;
315
316 let caps = AgentCapabilities::none();
318
319 let tool = create_test_tool(fs, caps);
320 let result = tool
321 .execute(&tool_ctx(), json!({"pattern": "content"}))
322 .await?;
323
324 assert!(!result.success);
325 assert!(result.output.contains("Permission denied"));
326 Ok(())
327 }
328
329 #[tokio::test]
330 async fn test_grep_filters_inaccessible_files() -> anyhow::Result<()> {
331 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
332 fs.write_file("src/main.rs", "fn main() {}").await?;
333 fs.write_file("secrets/key.txt", "fn secret() {}").await?;
334
335 let caps =
337 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
338
339 let tool = create_test_tool(fs, caps);
340 let result = tool.execute(&tool_ctx(), json!({"pattern": "fn"})).await?;
341
342 assert!(result.success);
343 assert!(result.output.contains("Found 1 matches"));
344 assert!(result.output.contains("main.rs"));
345 assert!(!result.output.contains("key.txt"));
346 Ok(())
347 }
348
349 #[tokio::test]
354 async fn test_grep_empty_file() -> anyhow::Result<()> {
355 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
356 fs.write_file("empty.txt", "").await?;
357
358 let tool = create_test_tool(fs, AgentCapabilities::full_access());
359 let result = tool
360 .execute(&tool_ctx(), json!({"pattern": "anything"}))
361 .await?;
362
363 assert!(result.success);
364 assert!(result.output.contains("No matches found"));
365 Ok(())
366 }
367
368 #[tokio::test]
369 async fn test_grep_many_matches_truncated() -> anyhow::Result<()> {
370 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
371
372 let content: String = (1..=100)
374 .map(|i| format!("match line {i}"))
375 .collect::<Vec<_>>()
376 .join("\n");
377 fs.write_file("many.txt", &content).await?;
378
379 let tool = create_test_tool(fs, AgentCapabilities::full_access());
380 let result = tool
381 .execute(&tool_ctx(), json!({"pattern": "match"}))
382 .await?;
383
384 assert!(result.success);
385 assert!(result.output.contains("Found 100 matches"));
386 assert!(result.output.contains("showing first 50"));
387 Ok(())
388 }
389
390 #[tokio::test]
391 async fn test_grep_special_regex_characters() -> anyhow::Result<()> {
392 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
393 fs.write_file("test.txt", "foo.bar\nbaz*qux\n(parens)")
394 .await?;
395
396 let tool = create_test_tool(fs, AgentCapabilities::full_access());
397
398 let result = tool
400 .execute(&tool_ctx(), json!({"pattern": "foo\\.bar"}))
401 .await?;
402 assert!(result.success);
403 assert!(result.output.contains("Found 1 matches"));
404 Ok(())
405 }
406
407 #[tokio::test]
408 async fn test_grep_multiple_files() -> anyhow::Result<()> {
409 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
410 fs.write_file("src/main.rs", "fn main() {}").await?;
411 fs.write_file("src/lib.rs", "fn lib() {}").await?;
412 fs.write_file("README.md", "# README").await?;
413
414 let tool = create_test_tool(fs, AgentCapabilities::full_access());
415 let result = tool.execute(&tool_ctx(), json!({"pattern": "fn"})).await?;
416
417 assert!(result.success);
418 assert!(result.output.contains("Found 2 matches"));
419 Ok(())
420 }
421
422 #[tokio::test]
423 async fn test_grep_tool_metadata() {
424 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
425 let tool = create_test_tool(fs, AgentCapabilities::full_access());
426
427 assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Grep);
428 assert_eq!(Tool::<()>::tier(&tool), ToolTier::Observe);
429 assert!(Tool::<()>::description(&tool).contains("Search"));
430
431 let schema = Tool::<()>::input_schema(&tool);
432 assert!(schema.get("properties").is_some());
433 assert!(schema["properties"].get("pattern").is_some());
434 assert!(schema["properties"].get("path").is_some());
435 assert!(schema["properties"].get("recursive").is_some());
436 assert!(schema["properties"].get("case_insensitive").is_some());
437 }
438
439 #[tokio::test]
440 async fn test_grep_invalid_input() -> anyhow::Result<()> {
441 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
442 let tool = create_test_tool(fs, AgentCapabilities::full_access());
443
444 let result = tool.execute(&tool_ctx(), json!({})).await;
446 assert!(result.is_err());
447 Ok(())
448 }
449
450 #[tokio::test]
451 async fn test_grep_invalid_pattern() -> anyhow::Result<()> {
452 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
453 fs.write_file("test.txt", "content").await?;
454
455 let tool = create_test_tool(fs, AgentCapabilities::full_access());
456 let result = tool
459 .execute(&tool_ctx(), json!({"pattern": "[unclosed"}))
460 .await?;
461
462 assert!(!result.success);
463 assert!(result.output.contains("Invalid pattern"));
464 Ok(())
465 }
466
467 #[tokio::test]
468 async fn test_truncate_line_function() {
469 assert_eq!(truncate_line("short", 10), "short");
470 assert_eq!(truncate_line(" trimmed ", 10), "trimmed");
471 assert_eq!(truncate_line("this is a longer line", 10), "this is a ...");
472 }
473
474 #[tokio::test]
475 async fn test_grep_long_line_truncated() -> anyhow::Result<()> {
476 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
477 let long_line = "match ".to_string() + &"x".repeat(300);
478 fs.write_file("long.txt", &long_line).await?;
479
480 let tool = create_test_tool(fs, AgentCapabilities::full_access());
481 let result = tool
482 .execute(&tool_ctx(), json!({"pattern": "match"}))
483 .await?;
484
485 assert!(result.success);
486 assert!(result.output.contains("..."));
487 Ok(())
488 }
489}