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