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> Tool<()> 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<()>, input: Value) -> Result<ToolResult> {
87 let input: GrepInput =
88 serde_json::from_value(input).context("Invalid input for grep tool")?;
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 !self.ctx.capabilities.can_read(&search_path) {
97 return Ok(ToolResult::error(format!(
98 "Permission denied: cannot search in '{search_path}'"
99 )));
100 }
101
102 let pattern = if input.case_insensitive {
104 format!("(?i){}", input.pattern)
105 } else {
106 input.pattern.clone()
107 };
108
109 let matches = self
111 .ctx
112 .environment
113 .grep(&pattern, &search_path, input.recursive)
114 .await
115 .context("Failed to execute grep")?;
116
117 let accessible_matches: Vec<_> = matches
119 .into_iter()
120 .filter(|m| self.ctx.capabilities.can_read(&m.path))
121 .collect();
122
123 if accessible_matches.is_empty() {
124 return Ok(ToolResult::success(format!(
125 "No matches found for pattern '{}'",
126 input.pattern
127 )));
128 }
129
130 let count = accessible_matches.len();
131 let max_results = 50;
132
133 let output_lines: Vec<String> = accessible_matches
134 .iter()
135 .take(max_results)
136 .map(|m| {
137 format!(
138 "{}:{}:{}",
139 m.path,
140 m.line_number,
141 truncate_line(&m.line_content, 200)
142 )
143 })
144 .collect();
145
146 let output = if count > max_results {
147 format!(
148 "Found {count} matches (showing first {max_results}):\n{}",
149 output_lines.join("\n")
150 )
151 } else {
152 format!("Found {count} matches:\n{}", output_lines.join("\n"))
153 };
154
155 Ok(ToolResult::success(output))
156 }
157}
158
159fn truncate_line(s: &str, max_len: usize) -> String {
160 let trimmed = s.trim();
161 if trimmed.len() <= max_len {
162 trimmed.to_string()
163 } else {
164 format!("{}...", &trimmed[..max_len])
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::{AgentCapabilities, InMemoryFileSystem};
172
173 fn create_test_tool(
174 fs: Arc<InMemoryFileSystem>,
175 capabilities: AgentCapabilities,
176 ) -> GrepTool<InMemoryFileSystem> {
177 GrepTool::new(fs, capabilities)
178 }
179
180 fn tool_ctx() -> ToolContext<()> {
181 ToolContext::new(())
182 }
183
184 #[tokio::test]
189 async fn test_grep_simple_pattern() -> anyhow::Result<()> {
190 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
191 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
192 .await?;
193
194 let tool = create_test_tool(fs, AgentCapabilities::full_access());
195 let result = tool
196 .execute(&tool_ctx(), json!({"pattern": "println"}))
197 .await?;
198
199 assert!(result.success);
200 assert!(result.output.contains("Found 1 matches"));
201 assert!(result.output.contains("println"));
202 assert!(result.output.contains(":2:")); Ok(())
204 }
205
206 #[tokio::test]
207 async fn test_grep_regex_pattern() -> anyhow::Result<()> {
208 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
209 fs.write_file("test.txt", "foo123\nbar456\nfoo789").await?;
210
211 let tool = create_test_tool(fs, AgentCapabilities::full_access());
212 let result = tool
213 .execute(&tool_ctx(), json!({"pattern": "foo\\d+"}))
214 .await?;
215
216 assert!(result.success);
217 assert!(result.output.contains("Found 2 matches"));
218 Ok(())
219 }
220
221 #[tokio::test]
222 async fn test_grep_no_matches() -> anyhow::Result<()> {
223 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
224 fs.write_file("test.txt", "Hello, World!").await?;
225
226 let tool = create_test_tool(fs, AgentCapabilities::full_access());
227 let result = tool
228 .execute(&tool_ctx(), json!({"pattern": "Rust"}))
229 .await?;
230
231 assert!(result.success);
232 assert!(result.output.contains("No matches found"));
233 Ok(())
234 }
235
236 #[tokio::test]
237 async fn test_grep_case_insensitive() -> anyhow::Result<()> {
238 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
239 fs.write_file("test.txt", "Hello\nHELLO\nhello").await?;
241
242 let tool = create_test_tool(fs, AgentCapabilities::full_access());
243 let result = tool
245 .execute(&tool_ctx(), json!({"pattern": "[Hh][Ee][Ll][Ll][Oo]"}))
246 .await?;
247
248 assert!(result.success);
249 assert!(result.output.contains("Found 3 matches"));
250 Ok(())
251 }
252
253 #[tokio::test]
254 async fn test_grep_with_path() -> anyhow::Result<()> {
255 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
256 fs.write_file("src/main.rs", "fn main() {}").await?;
257 fs.write_file("tests/test.rs", "fn test() {}").await?;
258
259 let tool = create_test_tool(fs, AgentCapabilities::full_access());
260 let result = tool
261 .execute(
262 &tool_ctx(),
263 json!({"pattern": "fn", "path": "/workspace/src"}),
264 )
265 .await?;
266
267 assert!(result.success);
268 assert!(result.output.contains("Found 1 matches"));
269 assert!(result.output.contains("main.rs"));
270 Ok(())
271 }
272
273 #[tokio::test]
274 async fn test_grep_non_recursive() -> anyhow::Result<()> {
275 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
276 fs.write_file("file.txt", "match here").await?;
277 fs.write_file("subdir/nested.txt", "match nested").await?;
278
279 let tool = create_test_tool(fs, AgentCapabilities::full_access());
280 let result = tool
281 .execute(&tool_ctx(), json!({"pattern": "match", "recursive": false}))
282 .await?;
283
284 assert!(result.success);
285 assert!(result.output.contains("Found 1 matches"));
287 assert!(result.output.contains("file.txt"));
288 Ok(())
289 }
290
291 #[tokio::test]
296 async fn test_grep_permission_denied() -> anyhow::Result<()> {
297 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
298 fs.write_file("test.txt", "content").await?;
299
300 let caps = AgentCapabilities::none();
302
303 let tool = create_test_tool(fs, caps);
304 let result = tool
305 .execute(&tool_ctx(), json!({"pattern": "content"}))
306 .await?;
307
308 assert!(!result.success);
309 assert!(result.output.contains("Permission denied"));
310 Ok(())
311 }
312
313 #[tokio::test]
314 async fn test_grep_filters_inaccessible_files() -> anyhow::Result<()> {
315 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
316 fs.write_file("src/main.rs", "fn main() {}").await?;
317 fs.write_file("secrets/key.txt", "fn secret() {}").await?;
318
319 let caps =
321 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
322
323 let tool = create_test_tool(fs, caps);
324 let result = tool.execute(&tool_ctx(), json!({"pattern": "fn"})).await?;
325
326 assert!(result.success);
327 assert!(result.output.contains("Found 1 matches"));
328 assert!(result.output.contains("main.rs"));
329 assert!(!result.output.contains("key.txt"));
330 Ok(())
331 }
332
333 #[tokio::test]
338 async fn test_grep_empty_file() -> anyhow::Result<()> {
339 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
340 fs.write_file("empty.txt", "").await?;
341
342 let tool = create_test_tool(fs, AgentCapabilities::full_access());
343 let result = tool
344 .execute(&tool_ctx(), json!({"pattern": "anything"}))
345 .await?;
346
347 assert!(result.success);
348 assert!(result.output.contains("No matches found"));
349 Ok(())
350 }
351
352 #[tokio::test]
353 async fn test_grep_many_matches_truncated() -> anyhow::Result<()> {
354 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
355
356 let content: String = (1..=100)
358 .map(|i| format!("match line {i}"))
359 .collect::<Vec<_>>()
360 .join("\n");
361 fs.write_file("many.txt", &content).await?;
362
363 let tool = create_test_tool(fs, AgentCapabilities::full_access());
364 let result = tool
365 .execute(&tool_ctx(), json!({"pattern": "match"}))
366 .await?;
367
368 assert!(result.success);
369 assert!(result.output.contains("Found 100 matches"));
370 assert!(result.output.contains("showing first 50"));
371 Ok(())
372 }
373
374 #[tokio::test]
375 async fn test_grep_special_regex_characters() -> anyhow::Result<()> {
376 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
377 fs.write_file("test.txt", "foo.bar\nbaz*qux\n(parens)")
378 .await?;
379
380 let tool = create_test_tool(fs, AgentCapabilities::full_access());
381
382 let result = tool
384 .execute(&tool_ctx(), json!({"pattern": "foo\\.bar"}))
385 .await?;
386 assert!(result.success);
387 assert!(result.output.contains("Found 1 matches"));
388 Ok(())
389 }
390
391 #[tokio::test]
392 async fn test_grep_multiple_files() -> anyhow::Result<()> {
393 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
394 fs.write_file("src/main.rs", "fn main() {}").await?;
395 fs.write_file("src/lib.rs", "fn lib() {}").await?;
396 fs.write_file("README.md", "# README").await?;
397
398 let tool = create_test_tool(fs, AgentCapabilities::full_access());
399 let result = tool.execute(&tool_ctx(), json!({"pattern": "fn"})).await?;
400
401 assert!(result.success);
402 assert!(result.output.contains("Found 2 matches"));
403 Ok(())
404 }
405
406 #[tokio::test]
407 async fn test_grep_tool_metadata() {
408 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
409 let tool = create_test_tool(fs, AgentCapabilities::full_access());
410
411 assert_eq!(tool.name(), PrimitiveToolName::Grep);
412 assert_eq!(tool.tier(), ToolTier::Observe);
413 assert!(tool.description().contains("Search"));
414
415 let schema = tool.input_schema();
416 assert!(schema.get("properties").is_some());
417 assert!(schema["properties"].get("pattern").is_some());
418 assert!(schema["properties"].get("path").is_some());
419 assert!(schema["properties"].get("recursive").is_some());
420 assert!(schema["properties"].get("case_insensitive").is_some());
421 }
422
423 #[tokio::test]
424 async fn test_grep_invalid_input() -> anyhow::Result<()> {
425 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
426 let tool = create_test_tool(fs, AgentCapabilities::full_access());
427
428 let result = tool.execute(&tool_ctx(), json!({})).await;
430 assert!(result.is_err());
431 Ok(())
432 }
433
434 #[tokio::test]
435 async fn test_truncate_line_function() {
436 assert_eq!(truncate_line("short", 10), "short");
437 assert_eq!(truncate_line(" trimmed ", 10), "trimmed");
438 assert_eq!(truncate_line("this is a longer line", 10), "this is a ...");
439 }
440
441 #[tokio::test]
442 async fn test_grep_long_line_truncated() -> anyhow::Result<()> {
443 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
444 let long_line = "match ".to_string() + &"x".repeat(300);
445 fs.write_file("long.txt", &long_line).await?;
446
447 let tool = create_test_tool(fs, AgentCapabilities::full_access());
448 let result = tool
449 .execute(&tool_ctx(), json!({"pattern": "match"}))
450 .await?;
451
452 assert!(result.success);
453 assert!(result.output.contains("..."));
454 Ok(())
455 }
456}