Skip to main content

agent_sdk/primitive_tools/
grep.rs

1use 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
9/// Tool for searching file contents using regex patterns
10pub 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    /// Regex pattern to search for
26    pattern: String,
27    /// Path to search in (file or directory)
28    #[serde(default)]
29    path: Option<String>,
30    /// Search recursively in directories (default: true)
31    #[serde(default = "default_recursive")]
32    recursive: bool,
33    /// Case insensitive search (default: false)
34    #[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        // Check read capability
96        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        // Build pattern with case insensitivity if requested
103        let pattern = if input.case_insensitive {
104            format!("(?i){}", input.pattern)
105        } else {
106            input.pattern.clone()
107        };
108
109        // Execute grep
110        let matches = self
111            .ctx
112            .environment
113            .grep(&pattern, &search_path, input.recursive)
114            .await
115            .context("Failed to execute grep")?;
116
117        // Filter out matches in files the agent can't read
118        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    // ===================
185    // Unit Tests
186    // ===================
187
188    #[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:")); // Line number
203        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        // Use ASCII-only text since unicode-case feature may not be enabled
240        fs.write_file("test.txt", "Hello\nHELLO\nhello").await?;
241
242        let tool = create_test_tool(fs, AgentCapabilities::full_access());
243        // Use ASCII-only case-insensitive pattern (regex supports (?i-u) for ASCII)
244        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        // Should only find the top-level file
286        assert!(result.output.contains("Found 1 matches"));
287        assert!(result.output.contains("file.txt"));
288        Ok(())
289    }
290
291    // ===================
292    // Integration Tests
293    // ===================
294
295    #[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        // No read permission
301        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        // Allow src but deny secrets
320        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    // ===================
334    // Edge Cases
335    // ===================
336
337    #[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        // Create file with many matching lines
357        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        // Escaped dot
383        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        // Missing required pattern field
429        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}