agent_sdk/primitive_tools/
grep.rs

1use 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
10/// Tool for searching file contents using regex patterns
11pub 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    /// Regex pattern to search for
27    pattern: String,
28    /// Path to search in (file or directory)
29    #[serde(default)]
30    path: Option<String>,
31    /// Search recursively in directories (default: true)
32    #[serde(default = "default_recursive")]
33    recursive: bool,
34    /// Case insensitive search (default: false)
35    #[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        // Check read capability
92        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        // Build pattern with case insensitivity if requested
99        let pattern = if input.case_insensitive {
100            format!("(?i){}", input.pattern)
101        } else {
102            input.pattern.clone()
103        };
104
105        // Execute grep
106        let matches = self
107            .ctx
108            .environment
109            .grep(&pattern, &search_path, input.recursive)
110            .await
111            .context("Failed to execute grep")?;
112
113        // Filter out matches in files the agent can't read
114        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    // ===================
181    // Unit Tests
182    // ===================
183
184    #[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:")); // Line number
199        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        // Use ASCII-only text since unicode-case feature may not be enabled
236        fs.write_file("test.txt", "Hello\nHELLO\nhello").await?;
237
238        let tool = create_test_tool(fs, AgentCapabilities::full_access());
239        // Use ASCII-only case-insensitive pattern (regex supports (?i-u) for ASCII)
240        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        // Should only find the top-level file
282        assert!(result.output.contains("Found 1 matches"));
283        assert!(result.output.contains("file.txt"));
284        Ok(())
285    }
286
287    // ===================
288    // Integration Tests
289    // ===================
290
291    #[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        // No read permission
297        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        // Allow src but deny secrets
316        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    // ===================
330    // Edge Cases
331    // ===================
332
333    #[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        // Create file with many matching lines
353        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        // Escaped dot
379        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        // Missing required pattern field
425        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}