claude_rust_tools/infrastructure/
grep_tool.rs1use 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 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 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}