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, 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        // Check read capability
96        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        // 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        // The pattern is the model's own input, so validate the regex up front
110        // and report a syntax error as a correctable tool error rather than an
111        // infrastructure failure. Genuine I/O errors from `grep` still surface
112        // as `anyhow::Err`.
113        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        // NOTE: `Environment::grep` implementations MUST enforce read
121        // permissions per traversed path; the root-only capability check above
122        // plus the per-result filter below are defense-in-depth, not a
123        // substitute.
124
125        // Execute grep
126        let matches = self
127            .ctx
128            .environment
129            .grep(&pattern, &search_path, input.recursive)
130            .await
131            .context("Failed to execute grep")?;
132
133        // Filter out matches in files the agent can't read
134        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    // ===================
201    // Unit Tests
202    // ===================
203
204    #[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:")); // Line number
219        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        // Use ASCII-only text since unicode-case feature may not be enabled
256        fs.write_file("test.txt", "Hello\nHELLO\nhello").await?;
257
258        let tool = create_test_tool(fs, AgentCapabilities::full_access());
259        // Use ASCII-only case-insensitive pattern (regex supports (?i-u) for ASCII)
260        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        // Should only find the top-level file
302        assert!(result.output.contains("Found 1 matches"));
303        assert!(result.output.contains("file.txt"));
304        Ok(())
305    }
306
307    // ===================
308    // Integration Tests
309    // ===================
310
311    #[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        // No read permission
317        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        // Allow src but deny secrets
336        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    // ===================
350    // Edge Cases
351    // ===================
352
353    #[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        // Create file with many matching lines
373        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        // Escaped dot
399        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        // Missing required pattern field
445        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        // An unbalanced char class is invalid; the model should get a
457        // correctable tool error, not an infrastructure failure.
458        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}