1use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7
8pub struct GlobSearchTool {
10 workspace_root: PathBuf,
11}
12
13impl GlobSearchTool {
14 pub fn new(workspace_root: PathBuf) -> Self {
15 Self { workspace_root }
16 }
17}
18
19#[async_trait]
20impl Tool for GlobSearchTool {
21 fn name(&self) -> &str {
22 "glob_search"
23 }
24
25 fn description(&self) -> &str {
26 "Find files matching a glob pattern. Respects .gitignore. \
27 Examples: '**/*.rs', 'src/**/*.toml', 'Cargo.*'"
28 }
29
30 fn parameters_schema(&self) -> Value {
31 json!({
32 "type": "object",
33 "properties": {
34 "pattern": {
35 "type": "string",
36 "description": "Glob pattern to match files"
37 },
38 "path": {
39 "type": "string",
40 "description": "Directory to search in (optional, defaults to workspace root)"
41 },
42 "max_results": {
43 "type": "integer",
44 "description": "Maximum number of results (default: 100)"
45 }
46 },
47 "required": ["pattern"]
48 })
49 }
50
51 async fn execute(&self, args: Value) -> crate::Result<Value> {
52 let pattern = args["pattern"]
53 .as_str()
54 .ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
55
56 let base_path = args["path"]
57 .as_str()
58 .map(|p| self.workspace_root.join(p))
59 .unwrap_or_else(|| self.workspace_root.clone());
60
61 let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
62
63 let mut builder = ignore::WalkBuilder::new(&base_path);
65 builder.hidden(false); let mut matches = Vec::new();
68 let glob_matcher = glob::Pattern::new(pattern)
69 .map_err(|e| crate::PawanError::Tool(format!("Invalid glob pattern: {}", e)))?;
70
71 for result in builder.build() {
72 if matches.len() >= max_results {
73 break;
74 }
75
76 if let Ok(entry) = result {
77 let path = entry.path();
78 if path.is_file() {
79 let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
80 let relative_str = relative.to_string_lossy();
81
82 if glob_matcher.matches(&relative_str) {
83 let metadata = path.metadata().ok();
84 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
85 let modified = metadata.and_then(|m| m.modified().ok()).map(|t| {
86 t.duration_since(std::time::UNIX_EPOCH)
87 .map(|d| d.as_secs())
88 .unwrap_or(0)
89 });
90 matches.push(json!({
91 "path": relative_str,
92 "size": size,
93 "modified": modified
94 }));
95 }
96 }
97 }
98 }
99
100 matches.sort_by(|a, b| {
102 let a_mod = a["modified"].as_u64().unwrap_or(0);
103 let b_mod = b["modified"].as_u64().unwrap_or(0);
104 b_mod.cmp(&a_mod)
105 });
106
107 Ok(json!({
108 "pattern": pattern,
109 "matches": matches,
110 "count": matches.len(),
111 "truncated": matches.len() >= max_results
112 }))
113 }
114}
115
116pub struct GrepSearchTool {
118 workspace_root: PathBuf,
119}
120
121impl GrepSearchTool {
122 pub fn new(workspace_root: PathBuf) -> Self {
123 Self { workspace_root }
124 }
125}
126
127#[async_trait]
128impl Tool for GrepSearchTool {
129 fn name(&self) -> &str {
130 "grep_search"
131 }
132
133 fn description(&self) -> &str {
134 "Search file contents for a pattern. Supports regex. \
135 Returns file paths and line numbers with matches."
136 }
137
138 fn parameters_schema(&self) -> Value {
139 json!({
140 "type": "object",
141 "properties": {
142 "pattern": {
143 "type": "string",
144 "description": "Pattern to search for (supports regex)"
145 },
146 "path": {
147 "type": "string",
148 "description": "Directory to search in (optional, defaults to workspace root)"
149 },
150 "include": {
151 "type": "string",
152 "description": "File pattern to include (e.g., '*.rs', '*.{ts,tsx}')"
153 },
154 "max_results": {
155 "type": "integer",
156 "description": "Maximum number of matching files (default: 50)"
157 },
158 "context_lines": {
159 "type": "integer",
160 "description": "Lines of context around matches (default: 0)"
161 }
162 },
163 "required": ["pattern"]
164 })
165 }
166
167 async fn execute(&self, args: Value) -> crate::Result<Value> {
168 let pattern = args["pattern"]
169 .as_str()
170 .ok_or_else(|| crate::PawanError::Tool("pattern is required".into()))?;
171
172 let base_path = args["path"]
173 .as_str()
174 .map(|p| self.workspace_root.join(p))
175 .unwrap_or_else(|| self.workspace_root.clone());
176
177 let include = args["include"].as_str();
178 let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
179 let context_lines = args["context_lines"].as_u64().unwrap_or(0) as usize;
180
181 let regex = regex::Regex::new(pattern)
183 .map_err(|e| crate::PawanError::Tool(format!("Invalid regex: {}", e)))?;
184
185 let include_matcher = include
187 .map(glob::Pattern::new)
188 .transpose()
189 .map_err(|e| crate::PawanError::Tool(format!("Invalid include pattern: {}", e)))?;
190
191 let mut file_matches = Vec::new();
192
193 let mut builder = ignore::WalkBuilder::new(&base_path);
195 builder.hidden(false);
196
197 for result in builder.build() {
198 if file_matches.len() >= max_results {
199 break;
200 }
201
202 if let Ok(entry) = result {
203 let path = entry.path();
204 if !path.is_file() {
205 continue;
206 }
207
208 let relative = path.strip_prefix(&self.workspace_root).unwrap_or(path);
209 let relative_str = relative.to_string_lossy();
210
211 if let Some(ref matcher) = include_matcher {
213 let filename = path
215 .file_name()
216 .map(|n| n.to_string_lossy())
217 .unwrap_or_default();
218 if !matcher.matches(&filename) && !matcher.matches(&relative_str) {
219 continue;
220 }
221 }
222
223 if let Ok(content) = std::fs::read_to_string(path) {
225 let mut line_matches = Vec::new();
226 let lines: Vec<&str> = content.lines().collect();
227
228 for (line_num, line) in lines.iter().enumerate() {
229 if regex.is_match(line) {
230 let mut match_info = json!({
231 "line": line_num + 1,
232 "content": line.chars().take(200).collect::<String>()
233 });
234
235 if context_lines > 0 {
237 let start = line_num.saturating_sub(context_lines);
238 let end = (line_num + context_lines + 1).min(lines.len());
239 let context: Vec<String> = lines[start..end]
240 .iter()
241 .enumerate()
242 .map(|(i, l)| format!("{}: {}", start + i + 1, l))
243 .collect();
244 match_info["context"] = json!(context);
245 }
246
247 line_matches.push(match_info);
248 }
249 }
250
251 if !line_matches.is_empty() {
252 file_matches.push(json!({
253 "path": relative_str,
254 "matches": line_matches,
255 "match_count": line_matches.len()
256 }));
257 }
258 }
259 }
260 }
261
262 file_matches.sort_by(|a, b| {
264 let a_count = a["match_count"].as_u64().unwrap_or(0);
265 let b_count = b["match_count"].as_u64().unwrap_or(0);
266 b_count.cmp(&a_count)
267 });
268
269 let total_matches: u64 = file_matches
270 .iter()
271 .map(|f| f["match_count"].as_u64().unwrap_or(0))
272 .sum();
273
274 Ok(json!({
275 "pattern": pattern,
276 "files": file_matches,
277 "file_count": file_matches.len(),
278 "total_matches": total_matches,
279 "truncated": file_matches.len() >= max_results
280 }))
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use tempfile::TempDir;
288
289 #[tokio::test]
290 async fn test_glob_search() {
291 let temp_dir = TempDir::new().unwrap();
292 std::fs::write(temp_dir.path().join("file1.rs"), "rust code").unwrap();
293 std::fs::write(temp_dir.path().join("file2.rs"), "more rust").unwrap();
294 std::fs::write(temp_dir.path().join("file3.txt"), "text file").unwrap();
295
296 let tool = GlobSearchTool::new(temp_dir.path().to_path_buf());
297 let result = tool.execute(json!({"pattern": "*.rs"})).await.unwrap();
298
299 assert_eq!(result["count"], 2);
300 }
301
302 #[tokio::test]
303 async fn test_grep_search() {
304 let temp_dir = TempDir::new().unwrap();
305 std::fs::write(
306 temp_dir.path().join("test.rs"),
307 "fn main() {\n println!(\"hello\");\n}",
308 )
309 .unwrap();
310
311 let tool = GrepSearchTool::new(temp_dir.path().to_path_buf());
312 let result = tool
313 .execute(json!({
314 "pattern": "println",
315 "include": "*.rs"
316 }))
317 .await
318 .unwrap();
319
320 assert_eq!(result["file_count"], 1);
321 assert_eq!(result["total_matches"], 1);
322 }
323}