Skip to main content

claude_rust_tools/infrastructure/
grep_tool.rs

1use claude_rust_errors::{AppError, AppResult};
2use claude_rust_types::{PermissionLevel, SearchReadInfo, 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    fn is_read_only(&self, _input: &Value) -> bool { true }
44    fn is_concurrent_safe(&self, _input: &Value) -> bool { true }
45
46    fn is_search_or_read_command(&self, _input: &Value) -> SearchReadInfo {
47        SearchReadInfo { is_search: true, is_read: false, is_list: false }
48    }
49
50    async fn execute(&self, input: Value) -> AppResult<String> {
51        let pattern = input
52            .get("pattern")
53            .and_then(|v| v.as_str())
54            .ok_or_else(|| AppError::Tool("missing 'pattern' field".into()))?;
55
56        let search_path = input
57            .get("path")
58            .and_then(|v| v.as_str())
59            .unwrap_or(".");
60
61        let file_glob = input.get("glob").and_then(|v| v.as_str());
62
63        tracing::info!(pattern, search_path, "grepping");
64
65        // Try ripgrep first, fall back to grep
66        let output = match try_ripgrep(pattern, search_path, file_glob).await {
67            Ok(out) => out,
68            Err(_) => try_grep(pattern, search_path).await?,
69        };
70
71        if output.is_empty() {
72            return Ok("No matches found.".into());
73        }
74
75        let mut result = output;
76        if result.len() > 100_000 {
77            result.truncate(100_000);
78            result.push_str("\n... (truncated)");
79        }
80
81        Ok(result)
82    }
83}
84
85async fn try_ripgrep(
86    pattern: &str,
87    path: &str,
88    file_glob: Option<&str>,
89) -> AppResult<String> {
90    let mut cmd = Command::new("rg");
91    cmd.arg("-n").arg("--no-heading").arg(pattern);
92
93    if let Some(g) = file_glob {
94        cmd.arg("--glob").arg(g);
95    }
96
97    cmd.arg(path);
98
99    let output = cmd
100        .output()
101        .await
102        .map_err(|e| AppError::Tool(format!("rg not available: {e}")))?;
103
104    // rg returns exit code 1 for no matches, 2 for errors
105    if output.status.code() == Some(2) {
106        return Err(AppError::Tool("ripgrep error".into()));
107    }
108
109    Ok(String::from_utf8_lossy(&output.stdout).to_string())
110}
111
112async fn try_grep(pattern: &str, path: &str) -> AppResult<String> {
113    let output = Command::new("grep")
114        .arg("-rn")
115        .arg(pattern)
116        .arg(path)
117        .output()
118        .await
119        .map_err(|e| AppError::Tool(format!("grep failed: {e}")))?;
120
121    Ok(String::from_utf8_lossy(&output.stdout).to_string())
122}