1use crate::builtin_tools::BuiltinTool;
6use crate::types::{Layer3Result, ToolCategory};
7use async_trait::async_trait;
8use regex::Regex;
9use std::fs;
10use std::io::{BufRead, BufReader};
11use std::path::Path;
12
13pub struct GrepTool;
15
16impl GrepTool {
17 fn search_file(
19 &self,
20 path: &Path,
21 pattern: &Regex,
22 max_results: usize,
23 ) -> Layer3Result<Vec<(usize, String)>> {
24 let file = fs::File::open(path)?;
25 let reader = BufReader::new(file);
26 let mut results = Vec::new();
27
28 for (line_num, line_result) in reader.lines().enumerate() {
29 if results.len() >= max_results {
30 break;
31 }
32 let line = line_result?;
33 if pattern.is_match(&line) {
34 results.push((line_num + 1, line));
35 }
36 }
37
38 Ok(results)
39 }
40
41 fn collect_files(
43 &self,
44 dir: &Path,
45 glob_pattern: Option<&str>,
46 ) -> Layer3Result<Vec<std::path::PathBuf>> {
47 let mut files = Vec::new();
48
49 fn walk_dir(dir: &Path, files: &mut Vec<std::path::PathBuf>, glob_filter: Option<&str>) {
50 if let Ok(entries) = fs::read_dir(dir) {
51 for entry in entries.flatten() {
52 let path = entry.path();
53 if path.is_dir() {
54 if !path
56 .file_name()
57 .map(|n| n.to_string_lossy().starts_with('.'))
58 .unwrap_or(false)
59 {
60 walk_dir(&path, files, glob_filter);
61 }
62 } else if path.is_file() {
63 let include = if let Some(glob) = glob_filter {
65 let file_name = path
67 .file_name()
68 .map(|n| n.to_string_lossy())
69 .unwrap_or_default();
70 if let Some(suffix) = glob.strip_prefix("**/") {
71 file_name.ends_with(suffix.trim_start_matches('*'))
72 } else if let Some(suffix) = glob.strip_prefix("*") {
73 file_name.ends_with(suffix)
74 } else {
75 file_name == glob
76 }
77 } else {
78 true
79 };
80 if include {
81 files.push(path);
82 }
83 }
84 }
85 }
86 }
87
88 walk_dir(dir, &mut files, glob_pattern);
89 Ok(files)
90 }
91}
92
93#[async_trait]
94impl BuiltinTool for GrepTool {
95 fn name(&self) -> &str {
96 "grep"
97 }
98
99 fn description(&self) -> &str {
100 "Search for a pattern in files using regex."
101 }
102
103 fn parameters_schema(&self) -> serde_json::Value {
104 serde_json::json!({
105 "type": "object",
106 "properties": {
107 "pattern": {
108 "type": "string",
109 "description": "The regex pattern to search for"
110 },
111 "path": {
112 "type": "string",
113 "description": "The file or directory to search in"
114 },
115 "glob": {
116 "type": "string",
117 "description": "Optional: glob pattern to filter files (e.g., '*.rs')"
118 },
119 "case_sensitive": {
120 "type": "boolean",
121 "description": "Optional: case sensitive search (default: false)"
122 },
123 "max_results": {
124 "type": "integer",
125 "description": "Optional: maximum results to return (default: 100)"
126 }
127 },
128 "required": ["pattern"]
129 })
130 }
131
132 fn category(&self) -> ToolCategory {
133 ToolCategory::Search
134 }
135
136 #[allow(unused_assignments)]
137 async fn execute(&self, args: serde_json::Value) -> Layer3Result<String> {
138 let pattern_str = args["pattern"]
139 .as_str()
140 .ok_or_else(|| anyhow::anyhow!("Missing pattern parameter"))?;
141
142 let path_str = args["path"].as_str().unwrap_or(".");
143 let glob_pattern = args["glob"].as_str();
144 let case_sensitive = args["case_sensitive"].as_bool().unwrap_or(false);
145 let max_results = args["max_results"].as_u64().unwrap_or(100) as usize;
146
147 let mut regex_builder = Regex::new(pattern_str);
149 if !case_sensitive {
150 regex_builder = Regex::new(&format!("(?i){}", pattern_str));
151 }
152
153 let pattern = regex_builder.map_err(|e| anyhow::anyhow!("Invalid regex pattern: {}", e))?;
154
155 let search_path = Path::new(path_str);
156
157 if !search_path.exists() {
158 return Err(anyhow::anyhow!("Path not found: {}", path_str));
159 }
160
161 let mut output_lines = Vec::new();
162 let mut total_matches = 0;
163
164 if search_path.is_file() {
165 let results = self.search_file(search_path, &pattern, max_results)?;
167 for (line_num, line) in results {
168 output_lines.push(format!("{}:{}: {}", search_path.display(), line_num, line));
169 total_matches += 1;
170 }
171 } else if search_path.is_dir() {
172 let files = self.collect_files(search_path, glob_pattern)?;
174
175 for file in files {
176 if total_matches >= max_results {
177 break;
178 }
179 if let Ok(results) = self.search_file(&file, &pattern, max_results - total_matches)
180 {
181 for (line_num, line) in results {
182 output_lines.push(format!("{}:{}: {}", file.display(), line_num, line));
183 total_matches += 1;
184 if total_matches >= max_results {
185 break;
186 }
187 }
188 }
189 }
190 }
191
192 if output_lines.is_empty() {
193 Ok("(no matches)".to_string())
194 } else {
195 Ok(output_lines.join("\n"))
196 }
197 }
198}
199
200pub struct GlobTool;
202
203impl GlobTool {
204 fn matches_pattern(file_name: &str, pattern: &str) -> bool {
206 if pattern == "**/*" {
207 return true;
208 }
209
210 if let Some(suffix) = pattern.strip_prefix("**/") {
211 if let Some(rest) = suffix.strip_prefix('*') {
212 return file_name.ends_with(rest);
213 }
214 return file_name == suffix;
215 }
216
217 if let Some(suffix) = pattern.strip_prefix("*") {
218 return file_name.ends_with(suffix);
219 }
220
221 if let Some(prefix) = pattern.strip_suffix("*") {
222 return file_name.starts_with(prefix);
223 }
224
225 file_name == pattern
226 }
227
228 fn collect_matching_files(
230 &self,
231 dir: &Path,
232 pattern: &str,
233 ) -> Layer3Result<Vec<std::path::PathBuf>> {
234 let mut files = Vec::new();
235
236 fn walk_dir(dir: &Path, files: &mut Vec<std::path::PathBuf>, pattern: &str) {
237 if let Ok(entries) = fs::read_dir(dir) {
238 for entry in entries.flatten() {
239 let path = entry.path();
240 if path.is_dir() {
241 if !path
243 .file_name()
244 .map(|n| n.to_string_lossy().starts_with('.'))
245 .unwrap_or(false)
246 {
247 walk_dir(&path, files, pattern);
248 }
249 } else if path.is_file() {
250 let file_name = path
251 .file_name()
252 .map(|n| n.to_string_lossy())
253 .unwrap_or_default();
254 if GlobTool::matches_pattern(&file_name, pattern) {
255 files.push(path);
256 }
257 }
258 }
259 }
260 }
261
262 walk_dir(dir, &mut files, pattern);
263 files.sort_by(|a, b| {
265 let a_time = a
266 .metadata()
267 .and_then(|m| m.modified())
268 .unwrap_or(std::time::UNIX_EPOCH);
269 let b_time = b
270 .metadata()
271 .and_then(|m| m.modified())
272 .unwrap_or(std::time::UNIX_EPOCH);
273 b_time.cmp(&a_time)
274 });
275
276 Ok(files)
277 }
278}
279
280#[async_trait]
281impl BuiltinTool for GlobTool {
282 fn name(&self) -> &str {
283 "glob"
284 }
285
286 fn description(&self) -> &str {
287 "Find files matching a glob pattern."
288 }
289
290 fn parameters_schema(&self) -> serde_json::Value {
291 serde_json::json!({
292 "type": "object",
293 "properties": {
294 "pattern": {
295 "type": "string",
296 "description": "The glob pattern (e.g., '**/*.rs', '*.txt')"
297 },
298 "path": {
299 "type": "string",
300 "description": "Optional: the directory to search in (default: current directory)"
301 }
302 },
303 "required": ["pattern"]
304 })
305 }
306
307 fn category(&self) -> ToolCategory {
308 ToolCategory::Search
309 }
310
311 async fn execute(&self, args: serde_json::Value) -> Layer3Result<String> {
312 let pattern = args["pattern"]
313 .as_str()
314 .ok_or_else(|| anyhow::anyhow!("Missing pattern parameter"))?;
315
316 let path_str = args["path"].as_str().unwrap_or(".");
317 let search_path = Path::new(path_str);
318
319 if !search_path.exists() {
320 return Err(anyhow::anyhow!("Path not found: {}", path_str));
321 }
322
323 if !search_path.is_dir() {
324 return Err(anyhow::anyhow!("Not a directory: {}", path_str));
325 }
326
327 let files = self.collect_matching_files(search_path, pattern)?;
328
329 if files.is_empty() {
330 Ok("(no matches)".to_string())
331 } else {
332 let output: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
333 Ok(output.join("\n"))
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use serde_json::json;
342 use std::io::Write;
343 use tempfile::TempDir;
344
345 #[test]
346 fn test_grep_tool_category() {
347 let tool = GrepTool;
348 assert_eq!(tool.category(), ToolCategory::Search);
349 }
350
351 #[test]
352 fn test_glob_tool_category() {
353 let tool = GlobTool;
354 assert_eq!(tool.category(), ToolCategory::Search);
355 }
356
357 #[tokio::test]
358 async fn test_grep_single_file() {
359 let temp_dir = TempDir::new().unwrap();
360 let file_path = temp_dir.path().join("test.txt");
361
362 let mut file = fs::File::create(&file_path).unwrap();
363 writeln!(file, "hello world").unwrap();
364 writeln!(file, "foo bar").unwrap();
365 writeln!(file, "hello again").unwrap();
366
367 let tool = GrepTool;
368 let result = tool
369 .execute(json!({
370 "pattern": "hello",
371 "path": file_path.to_str().unwrap()
372 }))
373 .await
374 .unwrap();
375
376 assert!(result.contains("hello"));
377 assert!(!result.contains("foo"));
378 }
379
380 #[tokio::test]
381 async fn test_grep_directory() {
382 let temp_dir = TempDir::new().unwrap();
383
384 let file1 = temp_dir.path().join("file1.txt");
385 let mut f1 = fs::File::create(&file1).unwrap();
386 writeln!(f1, "fn main() {{ }}").unwrap();
387
388 let file2 = temp_dir.path().join("file2.txt");
389 let mut f2 = fs::File::create(&file2).unwrap();
390 writeln!(f2, "fn test() {{ }}").unwrap();
391
392 let tool = GrepTool;
393 let result = tool
394 .execute(json!({
395 "pattern": "fn\\s+\\w+",
396 "path": temp_dir.path().to_str().unwrap(),
397 "glob": "*.txt"
398 }))
399 .await
400 .unwrap();
401
402 assert!(result.contains("fn main"));
403 assert!(result.contains("fn test"));
404 }
405
406 #[tokio::test]
407 async fn test_grep_case_insensitive() {
408 let temp_dir = TempDir::new().unwrap();
409 let file_path = temp_dir.path().join("test.txt");
410
411 let mut file = fs::File::create(&file_path).unwrap();
412 writeln!(file, "HELLO World").unwrap();
413
414 let tool = GrepTool;
415 let result = tool
416 .execute(json!({
417 "pattern": "hello",
418 "path": file_path.to_str().unwrap(),
419 "case_sensitive": false
420 }))
421 .await
422 .unwrap();
423
424 assert!(result.contains("HELLO"));
425 }
426
427 #[tokio::test]
428 async fn test_grep_no_matches() {
429 let temp_dir = TempDir::new().unwrap();
430 let file_path = temp_dir.path().join("test.txt");
431
432 let mut file = fs::File::create(&file_path).unwrap();
433 writeln!(file, "hello world").unwrap();
434
435 let tool = GrepTool;
436 let result = tool
437 .execute(json!({
438 "pattern": "nonexistent",
439 "path": file_path.to_str().unwrap()
440 }))
441 .await
442 .unwrap();
443
444 assert!(result.contains("no matches"));
445 }
446
447 #[tokio::test]
448 async fn test_grep_invalid_pattern() {
449 let tool = GrepTool;
450 let result = tool
451 .execute(json!({
452 "pattern": "[invalid("
453 }))
454 .await;
455
456 assert!(result.is_err());
457 assert!(result.unwrap_err().to_string().contains("Invalid regex"));
458 }
459
460 #[tokio::test]
461 async fn test_glob_find_files() {
462 let temp_dir = TempDir::new().unwrap();
463
464 fs::File::create(temp_dir.path().join("file1.rs")).unwrap();
465 fs::File::create(temp_dir.path().join("file2.rs")).unwrap();
466 fs::File::create(temp_dir.path().join("file3.txt")).unwrap();
467
468 let tool = GlobTool;
469 let result = tool
470 .execute(json!({
471 "pattern": "*.rs",
472 "path": temp_dir.path().to_str().unwrap()
473 }))
474 .await
475 .unwrap();
476
477 assert!(result.contains("file1.rs"));
478 assert!(result.contains("file2.rs"));
479 assert!(!result.contains("file3.txt"));
480 }
481
482 #[tokio::test]
483 async fn test_glob_recursive() {
484 let temp_dir = TempDir::new().unwrap();
485 let subdir = temp_dir.path().join("nested");
486 fs::create_dir(&subdir).unwrap();
487
488 fs::File::create(subdir.join("deep.rs")).unwrap();
489
490 let tool = GlobTool;
491 let result = tool
492 .execute(json!({
493 "pattern": "**/*.rs",
494 "path": temp_dir.path().to_str().unwrap()
495 }))
496 .await
497 .unwrap();
498
499 assert!(result.contains("deep.rs"));
500 }
501
502 #[tokio::test]
503 async fn test_glob_no_matches() {
504 let temp_dir = TempDir::new().unwrap();
505
506 let tool = GlobTool;
507 let result = tool
508 .execute(json!({
509 "pattern": "*.xyz",
510 "path": temp_dir.path().to_str().unwrap()
511 }))
512 .await
513 .unwrap();
514
515 assert!(result.contains("no matches"));
516 }
517
518 #[tokio::test]
519 async fn test_glob_nonexistent_path() {
520 let tool = GlobTool;
521 let result = tool
522 .execute(json!({
523 "pattern": "*.rs",
524 "path": "/nonexistent/path"
525 }))
526 .await;
527
528 assert!(result.is_err());
529 assert!(result.unwrap_err().to_string().contains("Path not found"));
530 }
531
532 #[test]
533 fn test_glob_pattern_matching() {
534 assert!(GlobTool::matches_pattern("test.rs", "*.rs"));
535 assert!(GlobTool::matches_pattern("test.rs", "**/*.rs"));
536 assert!(!GlobTool::matches_pattern("test.txt", "*.rs"));
537 assert!(GlobTool::matches_pattern("test.txt", "*.txt"));
538 }
539}