Skip to main content

aster/tools/search/
glob.rs

1//! Glob Tool Implementation
2//!
3//! Provides file search using glob patterns with results sorted by modification time.
4//!
5//! Requirements: 5.1, 5.2
6
7use async_trait::async_trait;
8use glob::glob as glob_match;
9use std::cmp::Reverse;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14use crate::tools::base::{PermissionCheckResult, Tool};
15use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
16use crate::tools::error::ToolError;
17
18use super::{format_search_results, truncate_results, SearchResult, DEFAULT_MAX_RESULTS};
19
20/// Glob tool for finding files using glob patterns
21///
22/// Supports standard glob patterns:
23/// - `*` matches any sequence of characters except path separators
24/// - `**` matches any sequence of characters including path separators
25/// - `?` matches any single character
26/// - `[abc]` matches any character in the brackets
27/// - `[!abc]` matches any character not in the brackets
28///
29/// Requirements: 5.1, 5.2
30pub struct GlobTool {
31    /// Maximum number of results to return
32    max_results: usize,
33}
34
35impl Default for GlobTool {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl GlobTool {
42    /// Create a new GlobTool with default settings
43    pub fn new() -> Self {
44        Self {
45            max_results: DEFAULT_MAX_RESULTS,
46        }
47    }
48
49    /// Set the maximum number of results
50    pub fn with_max_results(mut self, max_results: usize) -> Self {
51        self.max_results = max_results;
52        self
53    }
54
55    /// Execute glob search
56    pub fn search(&self, pattern: &str, base_path: &Path) -> Result<Vec<SearchResult>, ToolError> {
57        // Construct the full pattern
58        let full_pattern = if pattern.starts_with('/') || pattern.starts_with("./") {
59            pattern.to_string()
60        } else {
61            format!("{}/{}", base_path.display(), pattern)
62        };
63
64        // Execute glob
65        let paths = glob_match(&full_pattern)
66            .map_err(|e| ToolError::invalid_params(format!("Invalid glob pattern: {}", e)))?;
67
68        // Collect results with metadata
69        let mut results: Vec<(SearchResult, Option<SystemTime>)> = Vec::new();
70
71        for entry in paths {
72            match entry {
73                Ok(path) => {
74                    // Skip directories unless explicitly requested
75                    if path.is_dir() {
76                        continue;
77                    }
78
79                    let mut result = SearchResult::file_match(path.clone());
80
81                    // Get file metadata for sorting and display
82                    if let Ok(metadata) = fs::metadata(&path) {
83                        let mtime = metadata.modified().ok();
84                        let size = metadata.len();
85
86                        if let Some(mt) = mtime {
87                            result = result.with_metadata(mt, size);
88                        }
89
90                        results.push((result, mtime));
91                    } else {
92                        results.push((result, None));
93                    }
94                }
95                Err(e) => {
96                    // Log but continue on individual file errors
97                    tracing::warn!("Glob error for entry: {}", e);
98                }
99            }
100        }
101
102        // Sort by modification time (newest first)
103        // Requirements: 5.2
104        results.sort_by(|a, b| match (&a.1, &b.1) {
105            (Some(a_time), Some(b_time)) => Reverse(a_time).cmp(&Reverse(b_time)),
106            (Some(_), None) => std::cmp::Ordering::Less,
107            (None, Some(_)) => std::cmp::Ordering::Greater,
108            (None, None) => std::cmp::Ordering::Equal,
109        });
110
111        // Extract just the results
112        let results: Vec<SearchResult> = results.into_iter().map(|(r, _)| r).collect();
113
114        Ok(results)
115    }
116
117    /// Search with include/exclude patterns
118    pub fn search_with_filters(
119        &self,
120        pattern: &str,
121        base_path: &Path,
122        exclude_patterns: &[String],
123    ) -> Result<Vec<SearchResult>, ToolError> {
124        let results = self.search(pattern, base_path)?;
125
126        // Filter out excluded patterns
127        let filtered: Vec<SearchResult> = results
128            .into_iter()
129            .filter(|r| {
130                let path_str = r.path.to_string_lossy();
131                !exclude_patterns.iter().any(|exclude| {
132                    // Simple substring match for exclusion
133                    path_str.contains(exclude)
134                })
135            })
136            .collect();
137
138        Ok(filtered)
139    }
140}
141
142#[async_trait]
143impl Tool for GlobTool {
144    fn name(&self) -> &str {
145        "glob"
146    }
147
148    fn description(&self) -> &str {
149        "Find files using glob patterns. Supports wildcards like *, **, ?, and character classes. \
150         Results are sorted by modification time (newest first)."
151    }
152
153    fn input_schema(&self) -> serde_json::Value {
154        serde_json::json!({
155            "type": "object",
156            "properties": {
157                "pattern": {
158                    "type": "string",
159                    "description": "Glob pattern to match files. Examples: '*.rs', 'src/**/*.ts', 'test_*.py'"
160                },
161                "path": {
162                    "type": "string",
163                    "description": "Base path to search from. Defaults to working directory."
164                },
165                "exclude": {
166                    "type": "array",
167                    "items": { "type": "string" },
168                    "description": "Patterns to exclude from results (e.g., ['node_modules', '.git'])"
169                },
170                "max_results": {
171                    "type": "integer",
172                    "description": "Maximum number of results to return. Default: 100"
173                }
174            },
175            "required": ["pattern"]
176        })
177    }
178
179    async fn execute(
180        &self,
181        params: serde_json::Value,
182        context: &ToolContext,
183    ) -> Result<ToolResult, ToolError> {
184        // Check for cancellation
185        if context.is_cancelled() {
186            return Err(ToolError::Cancelled);
187        }
188
189        // Parse parameters
190        let pattern = params
191            .get("pattern")
192            .and_then(|v| v.as_str())
193            .ok_or_else(|| ToolError::invalid_params("Missing required parameter: pattern"))?;
194
195        let base_path = params
196            .get("path")
197            .and_then(|v| v.as_str())
198            .map(PathBuf::from)
199            .unwrap_or_else(|| context.working_directory.clone());
200
201        let exclude_patterns: Vec<String> = params
202            .get("exclude")
203            .and_then(|v| v.as_array())
204            .map(|arr| {
205                arr.iter()
206                    .filter_map(|v| v.as_str().map(String::from))
207                    .collect()
208            })
209            .unwrap_or_default();
210
211        let max_results = params
212            .get("max_results")
213            .and_then(|v| v.as_u64())
214            .map(|v| v as usize)
215            .unwrap_or(self.max_results);
216
217        // Execute search
218        let results = if exclude_patterns.is_empty() {
219            self.search(pattern, &base_path)?
220        } else {
221            self.search_with_filters(pattern, &base_path, &exclude_patterns)?
222        };
223
224        // Truncate if needed
225        let (results, truncated) = truncate_results(results, max_results);
226
227        // Format output
228        let output = format_search_results(&results, truncated);
229
230        Ok(ToolResult::success(output)
231            .with_metadata("count", serde_json::json!(results.len()))
232            .with_metadata("truncated", serde_json::json!(truncated)))
233    }
234
235    async fn check_permissions(
236        &self,
237        _params: &serde_json::Value,
238        _context: &ToolContext,
239    ) -> PermissionCheckResult {
240        // Glob is a read-only operation, generally safe
241        PermissionCheckResult::allow()
242    }
243
244    fn options(&self) -> ToolOptions {
245        ToolOptions::default().with_base_timeout(std::time::Duration::from_secs(60))
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::fs::File;
253    use std::io::Write;
254    use tempfile::TempDir;
255
256    fn create_test_files(dir: &TempDir) -> Vec<PathBuf> {
257        let files = vec![
258            "test1.txt",
259            "test2.txt",
260            "src/main.rs",
261            "src/lib.rs",
262            "src/utils/helper.rs",
263            "docs/readme.md",
264        ];
265
266        let mut paths = Vec::new();
267        for file in files {
268            let path = dir.path().join(file);
269            if let Some(parent) = path.parent() {
270                fs::create_dir_all(parent).unwrap();
271            }
272            let mut f = File::create(&path).unwrap();
273            writeln!(f, "content of {}", file).unwrap();
274            paths.push(path);
275            // Small delay to ensure different mtimes
276            std::thread::sleep(std::time::Duration::from_millis(10));
277        }
278        paths
279    }
280
281    #[test]
282    fn test_glob_tool_new() {
283        let tool = GlobTool::new();
284        assert_eq!(tool.max_results, DEFAULT_MAX_RESULTS);
285    }
286
287    #[test]
288    fn test_glob_tool_with_max_results() {
289        let tool = GlobTool::new().with_max_results(50);
290        assert_eq!(tool.max_results, 50);
291    }
292
293    #[test]
294    fn test_glob_search_simple() {
295        let temp_dir = TempDir::new().unwrap();
296        create_test_files(&temp_dir);
297
298        let tool = GlobTool::new();
299        let results = tool.search("*.txt", temp_dir.path()).unwrap();
300
301        assert_eq!(results.len(), 2);
302        assert!(results.iter().all(|r| r.path.extension().unwrap() == "txt"));
303    }
304
305    #[test]
306    fn test_glob_search_recursive() {
307        let temp_dir = TempDir::new().unwrap();
308        create_test_files(&temp_dir);
309
310        let tool = GlobTool::new();
311        let results = tool.search("**/*.rs", temp_dir.path()).unwrap();
312
313        assert_eq!(results.len(), 3);
314        assert!(results.iter().all(|r| r.path.extension().unwrap() == "rs"));
315    }
316
317    #[test]
318    fn test_glob_search_sorted_by_mtime() {
319        let temp_dir = TempDir::new().unwrap();
320        create_test_files(&temp_dir);
321
322        let tool = GlobTool::new();
323        let results = tool.search("**/*", temp_dir.path()).unwrap();
324
325        // Results should be sorted by mtime (newest first)
326        for i in 0..results.len().saturating_sub(1) {
327            if let (Some(mtime1), Some(mtime2)) = (results[i].mtime, results[i + 1].mtime) {
328                assert!(
329                    mtime1 >= mtime2,
330                    "Results should be sorted by mtime (newest first)"
331                );
332            }
333        }
334    }
335
336    #[test]
337    fn test_glob_search_with_exclude() {
338        let temp_dir = TempDir::new().unwrap();
339        create_test_files(&temp_dir);
340
341        let tool = GlobTool::new();
342        let results = tool
343            .search_with_filters("**/*", temp_dir.path(), &["utils".to_string()])
344            .unwrap();
345
346        // Should not include files in utils directory
347        assert!(results
348            .iter()
349            .all(|r| !r.path.to_string_lossy().contains("utils")));
350    }
351
352    #[test]
353    fn test_glob_invalid_pattern() {
354        let temp_dir = TempDir::new().unwrap();
355        let tool = GlobTool::new();
356
357        // Invalid pattern with unclosed bracket
358        let result = tool.search("[invalid", temp_dir.path());
359        assert!(result.is_err());
360    }
361
362    #[tokio::test]
363    async fn test_glob_tool_execute() {
364        let temp_dir = TempDir::new().unwrap();
365        create_test_files(&temp_dir);
366
367        let tool = GlobTool::new();
368        let context = ToolContext::new(temp_dir.path().to_path_buf());
369        let params = serde_json::json!({
370            "pattern": "*.txt"
371        });
372
373        let result = tool.execute(params, &context).await.unwrap();
374        assert!(result.is_success());
375        assert!(result.output.is_some());
376
377        let output = result.output.unwrap();
378        assert!(output.contains("test1.txt"));
379        assert!(output.contains("test2.txt"));
380    }
381
382    #[tokio::test]
383    async fn test_glob_tool_execute_with_path() {
384        let temp_dir = TempDir::new().unwrap();
385        create_test_files(&temp_dir);
386
387        let tool = GlobTool::new();
388        let context = ToolContext::new(PathBuf::from("/tmp"));
389        let params = serde_json::json!({
390            "pattern": "*.rs",
391            "path": temp_dir.path().join("src").to_str().unwrap()
392        });
393
394        let result = tool.execute(params, &context).await.unwrap();
395        assert!(result.is_success());
396
397        let output = result.output.unwrap();
398        assert!(output.contains("main.rs"));
399        assert!(output.contains("lib.rs"));
400    }
401
402    #[tokio::test]
403    async fn test_glob_tool_execute_with_exclude() {
404        let temp_dir = TempDir::new().unwrap();
405        create_test_files(&temp_dir);
406
407        let tool = GlobTool::new();
408        let context = ToolContext::new(temp_dir.path().to_path_buf());
409        let params = serde_json::json!({
410            "pattern": "**/*.rs",
411            "exclude": ["utils"]
412        });
413
414        let result = tool.execute(params, &context).await.unwrap();
415        assert!(result.is_success());
416
417        let output = result.output.unwrap();
418        assert!(!output.contains("helper.rs"));
419        assert!(output.contains("main.rs"));
420    }
421
422    #[tokio::test]
423    async fn test_glob_tool_execute_with_max_results() {
424        let temp_dir = TempDir::new().unwrap();
425        create_test_files(&temp_dir);
426
427        let tool = GlobTool::new();
428        let context = ToolContext::new(temp_dir.path().to_path_buf());
429        let params = serde_json::json!({
430            "pattern": "**/*",
431            "max_results": 2
432        });
433
434        let result = tool.execute(params, &context).await.unwrap();
435        assert!(result.is_success());
436
437        // Check metadata
438        assert_eq!(result.metadata.get("count"), Some(&serde_json::json!(2)));
439        assert_eq!(
440            result.metadata.get("truncated"),
441            Some(&serde_json::json!(true))
442        );
443    }
444
445    #[tokio::test]
446    async fn test_glob_tool_missing_pattern() {
447        let tool = GlobTool::new();
448        let context = ToolContext::new(PathBuf::from("/tmp"));
449        let params = serde_json::json!({});
450
451        let result = tool.execute(params, &context).await;
452        assert!(result.is_err());
453        assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
454    }
455
456    #[test]
457    fn test_glob_tool_name() {
458        let tool = GlobTool::new();
459        assert_eq!(tool.name(), "glob");
460    }
461
462    #[test]
463    fn test_glob_tool_description() {
464        let tool = GlobTool::new();
465        assert!(!tool.description().is_empty());
466        assert!(tool.description().contains("glob"));
467    }
468
469    #[test]
470    fn test_glob_tool_input_schema() {
471        let tool = GlobTool::new();
472        let schema = tool.input_schema();
473
474        assert_eq!(schema["type"], "object");
475        assert!(schema["properties"]["pattern"].is_object());
476        assert!(schema["properties"]["path"].is_object());
477        assert!(schema["properties"]["exclude"].is_object());
478        assert!(schema["required"]
479            .as_array()
480            .unwrap()
481            .contains(&serde_json::json!("pattern")));
482    }
483
484    #[tokio::test]
485    async fn test_glob_tool_check_permissions() {
486        let tool = GlobTool::new();
487        let context = ToolContext::new(PathBuf::from("/tmp"));
488        let params = serde_json::json!({"pattern": "*.txt"});
489
490        let result = tool.check_permissions(&params, &context).await;
491        assert!(result.is_allowed());
492    }
493
494    #[tokio::test]
495    async fn test_glob_tool_cancellation() {
496        let tool = GlobTool::new();
497        let token = tokio_util::sync::CancellationToken::new();
498        token.cancel();
499
500        let context = ToolContext::new(PathBuf::from("/tmp")).with_cancellation_token(token);
501        let params = serde_json::json!({"pattern": "*.txt"});
502
503        let result = tool.execute(params, &context).await;
504        assert!(result.is_err());
505        assert!(matches!(result.unwrap_err(), ToolError::Cancelled));
506    }
507}