Skip to main content

sh_layer3/builtin_tools/
search.rs

1//! # Search Tools
2//!
3//! 搜索工具集:grep、glob、文件搜索等。
4
5use 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
13/// Grep Tool - Search content in files
14pub struct GrepTool;
15
16impl GrepTool {
17    /// Search for pattern in a single file
18    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    /// Recursively collect files in directory
42    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                        // Skip hidden directories
55                        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                        // Apply glob filter if provided
64                        let include = if let Some(glob) = glob_filter {
65                            // Simple glob matching: *.ext or **/*.ext
66                            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        // Build regex
148        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            // Search single file
166            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            // Search directory
173            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
200/// Glob Tool - Find files by pattern
201pub struct GlobTool;
202
203impl GlobTool {
204    /// Simple glob matching
205    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    /// Collect files matching pattern
229    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                        // Skip hidden directories
242                        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        // Sort by modification time (newest first)
264        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}