Skip to main content

claude_rust_tools/infrastructure/
grep_tool.rs

1use claude_rust_errors::{AppError, AppResult};
2use claude_rust_types::{PermissionLevel, Tool};
3use serde_json::{Value, json};
4use tokio::process::Command;
5
6pub struct GrepTool;
7
8#[async_trait::async_trait]
9impl Tool for GrepTool {
10    fn name(&self) -> &str {
11        "grep"
12    }
13
14    fn description(&self) -> &str {
15        "Search file contents using regex. Uses ripgrep (rg) if available, falls back to grep."
16    }
17
18    fn input_schema(&self) -> Value {
19        json!({
20            "type": "object",
21            "properties": {
22                "pattern": {
23                    "type": "string",
24                    "description": "Regular expression pattern to search for"
25                },
26                "path": {
27                    "type": "string",
28                    "description": "File or directory to search in (defaults to current working directory)"
29                },
30                "glob": {
31                    "type": "string",
32                    "description": "Glob pattern to filter files (e.g. \"*.rs\", \"*.{ts,tsx}\")"
33                }
34            },
35            "required": ["pattern"]
36        })
37    }
38
39    fn permission_level(&self) -> PermissionLevel {
40        PermissionLevel::ReadOnly
41    }
42
43    async fn execute(&self, input: Value) -> AppResult<String> {
44        let pattern = input
45            .get("pattern")
46            .and_then(|v| v.as_str())
47            .ok_or_else(|| AppError::Tool("missing 'pattern' field".into()))?;
48
49        let search_path = input
50            .get("path")
51            .and_then(|v| v.as_str())
52            .unwrap_or(".");
53
54        let file_glob = input.get("glob").and_then(|v| v.as_str());
55
56        tracing::info!(pattern, search_path, "grepping");
57
58        // Try ripgrep first, fall back to grep
59        let output = match try_ripgrep(pattern, search_path, file_glob).await {
60            Ok(out) => out,
61            Err(_) => try_grep(pattern, search_path).await?,
62        };
63
64        if output.is_empty() {
65            return Ok("No matches found.".into());
66        }
67
68        let mut result = output;
69        if result.len() > 100_000 {
70            result.truncate(100_000);
71            result.push_str("\n... (truncated)");
72        }
73
74        Ok(result)
75    }
76}
77
78async fn try_ripgrep(
79    pattern: &str,
80    path: &str,
81    file_glob: Option<&str>,
82) -> AppResult<String> {
83    let mut cmd = Command::new("rg");
84    cmd.arg("-n").arg("--no-heading").arg(pattern);
85
86    if let Some(g) = file_glob {
87        cmd.arg("--glob").arg(g);
88    }
89
90    cmd.arg(path);
91
92    let output = cmd
93        .output()
94        .await
95        .map_err(|e| AppError::Tool(format!("rg not available: {e}")))?;
96
97    // rg returns exit code 1 for no matches, 2 for errors
98    if output.status.code() == Some(2) {
99        return Err(AppError::Tool("ripgrep error".into()));
100    }
101
102    Ok(String::from_utf8_lossy(&output.stdout).to_string())
103}
104
105async fn try_grep(pattern: &str, path: &str) -> AppResult<String> {
106    let output = Command::new("grep")
107        .arg("-rn")
108        .arg(pattern)
109        .arg(path)
110        .output()
111        .await
112        .map_err(|e| AppError::Tool(format!("grep failed: {e}")))?;
113
114    Ok(String::from_utf8_lossy(&output.stdout).to_string())
115}