1use async_trait::async_trait;
4use regex::Regex;
5use serde::Deserialize;
6use serde_json::Value;
7use std::path::Path;
8
9use super::{resolve_path, Tool, ToolContext, ToolResult};
10use crate::error::Result;
11
12pub struct GrepTool {
13 max_output_lines: usize,
15 max_output_bytes: usize,
17}
18
19#[derive(Debug, Deserialize)]
20struct GrepArgs {
21 pattern: String,
22 file_path: Option<String>,
23 dir_path: Option<String>,
24 #[serde(default)]
25 ignore_case: bool,
26 #[serde(default)]
27 recursive: bool,
28}
29
30impl GrepTool {
31 pub fn new(max_output_lines: usize, max_output_bytes: usize) -> Self {
32 Self {
33 max_output_lines,
34 max_output_bytes,
35 }
36 }
37}
38
39#[async_trait]
40impl Tool for GrepTool {
41 fn name(&self) -> &str {
42 "grep"
43 }
44
45 fn description(&self) -> &str {
46 "Search for a text pattern in files or directories. Supports regex."
47 }
48
49 fn parameters_schema(&self) -> Value {
50 serde_json::json!({
51 "type": "object",
52 "properties": {
53 "pattern": {
54 "type": "string",
55 "description": "Search pattern (regex)"
56 },
57 "file_path": {
58 "type": "string",
59 "description": "Single file path (use either this or dir_path)"
60 },
61 "dir_path": {
62 "type": "string",
63 "description": "Directory path (use either this or file_path)"
64 },
65 "ignore_case": {
66 "type": "boolean",
67 "description": "Case-insensitive search, default false"
68 },
69 "recursive": {
70 "type": "boolean",
71 "description": "Recursively search subdirectories (only effective with dir_path), default false"
72 }
73 },
74 "required": ["pattern"]
75 })
76 }
77
78 fn requires_confirmation(&self) -> bool {
79 false
80 }
81
82 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
83 let parsed: GrepArgs = match serde_json::from_value(args) {
84 Ok(a) => a,
85 Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
86 };
87
88 if parsed.file_path.is_none() && parsed.dir_path.is_none() {
90 return Ok(ToolResult::error("Either file_path or dir_path must be specified".to_string()));
91 }
92 if parsed.file_path.is_some() && parsed.dir_path.is_some() {
93 return Ok(ToolResult::error("Cannot specify both file_path and dir_path simultaneously".to_string()));
94 }
95
96 let regex_pattern = if parsed.ignore_case {
98 format!("(?i){}", parsed.pattern)
99 } else {
100 parsed.pattern.clone()
101 };
102
103 let regex = match Regex::new(®ex_pattern) {
104 Ok(r) => r,
105 Err(e) => {
106 return Ok(ToolResult::error(format!("Invalid regex '{}': {}", parsed.pattern, e)));
107 }
108 };
109
110 let mut files_to_search = Vec::new();
112
113 if let Some(fp) = &parsed.file_path {
114 let path = resolve_path(fp, &ctx.working_dir);
115 if !path.exists() {
116 return Ok(ToolResult::error(format!("File not found: {}", path.display())));
117 }
118 if path.is_dir() {
119 return Ok(ToolResult::error(format!("'{}' is a directory, use dir_path parameter instead", path.display())));
120 }
121 files_to_search.push(path);
122 } else if let Some(dp) = &parsed.dir_path {
123 let path = resolve_path(dp, &ctx.working_dir);
124 if !path.exists() {
125 return Ok(ToolResult::error(format!("Directory not found: {}", path.display())));
126 }
127 if !path.is_dir() {
128 return Ok(ToolResult::error(format!("'{}' is not a directory", path.display())));
129 }
130 collect_files(&path, parsed.recursive, &mut files_to_search).await;
131 }
132
133 let mut output = String::new();
135 output.push_str(&format!("Search: {}\n", parsed.pattern));
136 output.push_str(&format!("{}", "ā".repeat(50)));
137
138 let mut line_count = 0;
139 let mut byte_count = output.len();
140 let mut has_matches = false;
141
142 for file_path in &files_to_search {
143 let content = match tokio::fs::read_to_string(file_path).await {
145 Ok(c) => c,
146 Err(_) => continue,
147 };
148
149 let mut file_matches = Vec::new();
151 for (line_num, line) in content.lines().enumerate() {
152 if regex.is_match(line) {
153 file_matches.push((line_num + 1, line));
154 }
155 }
156
157 if !file_matches.is_empty() {
158 has_matches = true;
159
160 let rel_path = match file_path.strip_prefix(&ctx.working_dir) {
161 Ok(p) => p.to_path_buf(),
162 Err(_) => file_path.clone(),
163 };
164
165 let file_header = format!("\nš {}\n", rel_path.display());
166 if byte_count + file_header.len() > self.max_output_bytes {
167 output.push_str(&format!(
168 "\n... (Output truncated, byte limit of {} bytes reached)",
169 self.max_output_bytes
170 ));
171 break;
172 }
173 output.push_str(&file_header);
174 byte_count += file_header.len();
175
176 for (line_num, line) in file_matches {
177 if line_count >= self.max_output_lines {
178 output.push_str(&format!(
179 "\n... (Output truncated, line limit of {} lines reached)",
180 self.max_output_lines
181 ));
182 return Ok(ToolResult::success(output));
183 }
184
185 let line_str = format!("{:>6}: {}\n", line_num, line);
186 if byte_count + line_str.len() > self.max_output_bytes {
187 output.push_str(&format!(
188 "\n... (Output truncated, byte limit of {} bytes reached)",
189 self.max_output_bytes
190 ));
191 break;
192 }
193 output.push_str(&line_str);
194 byte_count += line_str.len();
195 line_count += 1;
196 }
197 }
198 }
199
200 if !has_matches {
201 output.push_str("\n(No matches found)");
202 }
203
204 Ok(ToolResult::success(output))
205 }
206}
207
208async fn collect_files(dir: &Path, recursive: bool, files: &mut Vec<std::path::PathBuf>) {
209 let mut dirs = vec![dir.to_path_buf()];
210 while let Some(dir) = dirs.pop() {
211 let mut entries = match tokio::fs::read_dir(&dir).await {
212 Ok(e) => e,
213 Err(_) => continue,
214 };
215 loop {
216 let entry = match entries.next_entry().await {
217 Ok(Some(e)) => e,
218 Ok(None) => break,
219 Err(_) => continue,
220 };
221 let path = entry.path();
222 if path.is_file() {
223 files.push(path);
224 } else if path.is_dir() && recursive {
225 dirs.push(path);
226 }
227 }
228 }
229}