1use 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
20pub struct GlobTool {
31 max_results: usize,
33}
34
35impl Default for GlobTool {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl GlobTool {
42 pub fn new() -> Self {
44 Self {
45 max_results: DEFAULT_MAX_RESULTS,
46 }
47 }
48
49 pub fn with_max_results(mut self, max_results: usize) -> Self {
51 self.max_results = max_results;
52 self
53 }
54
55 pub fn search(&self, pattern: &str, base_path: &Path) -> Result<Vec<SearchResult>, ToolError> {
57 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 let paths = glob_match(&full_pattern)
66 .map_err(|e| ToolError::invalid_params(format!("Invalid glob pattern: {}", e)))?;
67
68 let mut results: Vec<(SearchResult, Option<SystemTime>)> = Vec::new();
70
71 for entry in paths {
72 match entry {
73 Ok(path) => {
74 if path.is_dir() {
76 continue;
77 }
78
79 let mut result = SearchResult::file_match(path.clone());
80
81 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 tracing::warn!("Glob error for entry: {}", e);
98 }
99 }
100 }
101
102 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 let results: Vec<SearchResult> = results.into_iter().map(|(r, _)| r).collect();
113
114 Ok(results)
115 }
116
117 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 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 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 if context.is_cancelled() {
186 return Err(ToolError::Cancelled);
187 }
188
189 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 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 let (results, truncated) = truncate_results(results, max_results);
226
227 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 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 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 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 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 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 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(¶ms, &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}