1use std::collections::HashMap;
8use std::future::Future;
9use std::path::{Path, PathBuf};
10use std::pin::Pin;
11use std::sync::Arc;
12use std::time::SystemTime;
13
14use globset::{Glob, GlobMatcher};
15use walkdir::WalkDir;
16
17use super::ask_for_permissions::{PermissionCategory, PermissionRequest};
18use super::permission_registry::PermissionRegistry;
19use super::types::{
20 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
21};
22
23pub const GLOB_TOOL_NAME: &str = "glob";
25
26pub const GLOB_TOOL_DESCRIPTION: &str = r#"Fast file pattern matching tool that works with any codebase size.
28
29Usage:
30- Supports glob patterns like "**/*.js" or "src/**/*.ts"
31- Returns matching file paths sorted by modification time (most recent first)
32- Use this tool when you need to find files by name patterns
33- The path parameter specifies the directory to search in (defaults to current directory)
34
35Pattern Syntax:
36- `*` matches any sequence of characters except path separators
37- `**` matches any sequence of characters including path separators (recursive)
38- `?` matches any single character except path separators
39- `[abc]` matches any character in the brackets
40- `[!abc]` matches any character not in the brackets
41- `{a,b}` matches either pattern a or pattern b
42
43Examples:
44- "*.rs" - all Rust files in the search directory
45- "**/*.rs" - all Rust files recursively
46- "src/**/*.{ts,tsx}" - all TypeScript files under src/
47- "**/test_*.py" - all Python test files recursively"#;
48
49pub const GLOB_TOOL_SCHEMA: &str = r#"{
51 "type": "object",
52 "properties": {
53 "pattern": {
54 "type": "string",
55 "description": "The glob pattern to match files against"
56 },
57 "path": {
58 "type": "string",
59 "description": "The directory to search in. Defaults to current working directory."
60 },
61 "limit": {
62 "type": "integer",
63 "description": "Maximum number of results to return. Defaults to 1000."
64 },
65 "include_hidden": {
66 "type": "boolean",
67 "description": "Include hidden files (starting with dot). Defaults to false."
68 }
69 },
70 "required": ["pattern"]
71}"#;
72
73pub struct GlobTool {
75 permission_registry: Arc<PermissionRegistry>,
77 default_path: Option<PathBuf>,
79}
80
81impl GlobTool {
82 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
87 Self {
88 permission_registry,
89 default_path: None,
90 }
91 }
92
93 pub fn with_default_path(
99 permission_registry: Arc<PermissionRegistry>,
100 default_path: PathBuf,
101 ) -> Self {
102 Self {
103 permission_registry,
104 default_path: Some(default_path),
105 }
106 }
107
108 fn build_permission_request(search_path: &str, pattern: &str) -> PermissionRequest {
110 let path = Path::new(search_path);
111 let display_name = path
112 .file_name()
113 .and_then(|n| n.to_str())
114 .unwrap_or(search_path);
115
116 PermissionRequest {
117 action: format!("Search for '{}' in: {}", pattern, display_name),
118 reason: Some("Find files matching glob pattern".to_string()),
119 resources: vec![search_path.to_string()],
120 category: PermissionCategory::DirectoryRead,
121 }
122 }
123
124 fn get_mtime(path: &Path) -> SystemTime {
126 path.metadata()
127 .and_then(|m| m.modified())
128 .unwrap_or(SystemTime::UNIX_EPOCH)
129 }
130}
131
132impl Executable for GlobTool {
133 fn name(&self) -> &str {
134 GLOB_TOOL_NAME
135 }
136
137 fn description(&self) -> &str {
138 GLOB_TOOL_DESCRIPTION
139 }
140
141 fn input_schema(&self) -> &str {
142 GLOB_TOOL_SCHEMA
143 }
144
145 fn tool_type(&self) -> ToolType {
146 ToolType::FileRead
147 }
148
149 fn execute(
150 &self,
151 context: ToolContext,
152 input: HashMap<String, serde_json::Value>,
153 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
154 let permission_registry = self.permission_registry.clone();
155 let default_path = self.default_path.clone();
156
157 Box::pin(async move {
158 let pattern = input
162 .get("pattern")
163 .and_then(|v| v.as_str())
164 .ok_or_else(|| "Missing required 'pattern' parameter".to_string())?;
165
166 let search_path = input
167 .get("path")
168 .and_then(|v| v.as_str())
169 .map(PathBuf::from)
170 .or(default_path)
171 .unwrap_or_else(|| {
172 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
173 });
174
175 let search_path_str = search_path.to_string_lossy().to_string();
176
177 let limit = input
178 .get("limit")
179 .and_then(|v| v.as_i64())
180 .map(|v| v.max(1) as usize)
181 .unwrap_or(1000);
182
183 let include_hidden = input
184 .get("include_hidden")
185 .and_then(|v| v.as_bool())
186 .unwrap_or(false);
187
188 if !search_path.exists() {
190 return Err(format!(
191 "Search path does not exist: {}",
192 search_path_str
193 ));
194 }
195
196 if !search_path.is_dir() {
197 return Err(format!(
198 "Search path is not a directory: {}",
199 search_path_str
200 ));
201 }
202
203 let permission_request = Self::build_permission_request(&search_path_str, pattern);
207
208 let already_granted = permission_registry
212 .is_granted(context.session_id, &permission_request)
213 .await;
214
215 if !already_granted {
216 let response_rx = permission_registry
221 .register(
222 context.tool_use_id.clone(),
223 context.session_id,
224 permission_request,
225 context.turn_id.clone(),
226 )
227 .await
228 .map_err(|e| format!("Failed to request permission: {}", e))?;
229
230 let response = response_rx
234 .await
235 .map_err(|_| "Permission request was cancelled".to_string())?;
236
237 if !response.granted {
241 let reason = response
242 .message
243 .unwrap_or_else(|| "Permission denied by user".to_string());
244 return Err(format!(
245 "Permission denied to search '{}': {}",
246 search_path_str, reason
247 ));
248 }
249 }
250
251 let glob_matcher: GlobMatcher = Glob::new(pattern)
255 .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?
256 .compile_matcher();
257
258 let search_path_for_filter = search_path.clone();
262 let mut matches: Vec<PathBuf> = WalkDir::new(&search_path)
263 .follow_links(false)
264 .into_iter()
265 .filter_entry(move |e| {
266 let is_root = e.path() == search_path_for_filter;
269 is_root
270 || include_hidden
271 || !e.file_name().to_string_lossy().starts_with('.')
272 })
273 .filter_map(|e| e.ok())
274 .filter(|e| e.file_type().is_file())
275 .filter(|e| {
276 let path = e.path();
277 let relative = path.strip_prefix(&search_path).unwrap_or(path);
278 glob_matcher.is_match(relative)
279 })
280 .map(|e| e.path().to_path_buf())
281 .collect();
282
283 matches.sort_by(|a, b| Self::get_mtime(b).cmp(&Self::get_mtime(a)));
287
288 let total_matches = matches.len();
292
293 if matches.is_empty() {
294 return Ok(format!(
295 "No files found matching pattern '{}' in '{}'",
296 pattern, search_path_str
297 ));
298 }
299
300 matches.truncate(limit);
301
302 let mut output = String::new();
303 for path in &matches {
304 output.push_str(&path.display().to_string());
305 output.push('\n');
306 }
307
308 if total_matches > limit {
309 output.push_str(&format!(
310 "\n... and {} more files (showing {}/{})",
311 total_matches - limit,
312 limit,
313 total_matches
314 ));
315 }
316
317 Ok(output.trim_end().to_string())
318 })
319 }
320
321 fn display_config(&self) -> DisplayConfig {
322 DisplayConfig {
323 display_name: "Glob".to_string(),
324 display_title: Box::new(|input| {
325 input
326 .get("pattern")
327 .and_then(|v| v.as_str())
328 .unwrap_or("*")
329 .to_string()
330 }),
331 display_content: Box::new(|_input, result| {
332 let lines: Vec<&str> = result.lines().take(20).collect();
333 let total_lines = result.lines().count();
334
335 DisplayResult {
336 content: lines.join("\n"),
337 content_type: ResultContentType::PlainText,
338 is_truncated: total_lines > 20,
339 full_length: total_lines,
340 }
341 }),
342 }
343 }
344
345 fn compact_summary(
346 &self,
347 input: &HashMap<String, serde_json::Value>,
348 result: &str,
349 ) -> String {
350 let pattern = input
351 .get("pattern")
352 .and_then(|v| v.as_str())
353 .unwrap_or("*");
354
355 let file_count = result
356 .lines()
357 .filter(|line| !line.starts_with("...") && !line.starts_with("No files") && !line.is_empty())
358 .count();
359
360 format!("[Glob: {} ({} files)]", pattern, file_count)
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::controller::tools::ask_for_permissions::{PermissionResponse, PermissionScope};
368 use crate::controller::types::ControllerEvent;
369 use std::fs;
370 use tempfile::TempDir;
371 use tokio::sync::mpsc;
372
373 fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
375 let (tx, rx) = mpsc::channel(16);
376 let registry = Arc::new(PermissionRegistry::new(tx));
377 (registry, rx)
378 }
379
380 fn setup_test_dir() -> TempDir {
381 let temp = TempDir::new().unwrap();
382
383 fs::create_dir_all(temp.path().join("src")).unwrap();
385 fs::create_dir_all(temp.path().join("tests")).unwrap();
386 fs::create_dir_all(temp.path().join(".hidden_dir")).unwrap();
387 fs::write(temp.path().join("src/main.rs"), "fn main() {}").unwrap();
388 fs::write(temp.path().join("src/lib.rs"), "pub mod lib;").unwrap();
389 fs::write(temp.path().join("tests/test.rs"), "#[test]").unwrap();
390 fs::write(temp.path().join("README.md"), "# README").unwrap();
391 fs::write(temp.path().join(".hidden"), "hidden file").unwrap();
392 fs::write(temp.path().join(".hidden_dir/secret.txt"), "secret").unwrap();
393
394 temp
395 }
396
397 #[tokio::test]
398 async fn test_simple_pattern_with_permission() {
399 let temp = setup_test_dir();
400 let (registry, mut event_rx) = create_test_registry();
401 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
402
403 let mut input = HashMap::new();
404 input.insert(
405 "pattern".to_string(),
406 serde_json::Value::String("*.md".to_string()),
407 );
408
409 let context = ToolContext {
410 session_id: 1,
411 tool_use_id: "test-glob-1".to_string(),
412 turn_id: None,
413 };
414
415 let registry_clone = registry.clone();
417 tokio::spawn(async move {
418 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
419 event_rx.recv().await
420 {
421 registry_clone
422 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
423 .await
424 .unwrap();
425 }
426 });
427
428 let result = tool.execute(context, input).await;
429 assert!(result.is_ok());
430 assert!(result.unwrap().contains("README.md"));
431 }
432
433 #[tokio::test]
434 async fn test_recursive_pattern() {
435 let temp = setup_test_dir();
436 let (registry, mut event_rx) = create_test_registry();
437 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
438
439 let mut input = HashMap::new();
440 input.insert(
441 "pattern".to_string(),
442 serde_json::Value::String("**/*.rs".to_string()),
443 );
444
445 let context = ToolContext {
446 session_id: 1,
447 tool_use_id: "test-glob-recursive".to_string(),
448 turn_id: None,
449 };
450
451 let registry_clone = registry.clone();
452 tokio::spawn(async move {
453 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
454 event_rx.recv().await
455 {
456 registry_clone
457 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
458 .await
459 .unwrap();
460 }
461 });
462
463 let result = tool.execute(context, input).await;
464 assert!(result.is_ok());
465 let output = result.unwrap();
466 assert!(output.contains("main.rs"));
467 assert!(output.contains("lib.rs"));
468 assert!(output.contains("test.rs"));
469 }
470
471 #[tokio::test]
472 async fn test_hidden_files_excluded() {
473 let temp = setup_test_dir();
474 let (registry, mut event_rx) = create_test_registry();
475 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
476
477 let mut input = HashMap::new();
478 input.insert(
479 "pattern".to_string(),
480 serde_json::Value::String("**/*".to_string()),
481 );
482
483 let context = ToolContext {
484 session_id: 1,
485 tool_use_id: "test-glob-hidden".to_string(),
486 turn_id: None,
487 };
488
489 let registry_clone = registry.clone();
490 tokio::spawn(async move {
491 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
492 event_rx.recv().await
493 {
494 registry_clone
495 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
496 .await
497 .unwrap();
498 }
499 });
500
501 let result = tool.execute(context, input).await;
502 assert!(result.is_ok());
503 let output = result.unwrap();
504 assert!(!output.contains(".hidden"));
506 assert!(!output.contains("secret.txt"));
507 }
508
509 #[tokio::test]
510 async fn test_hidden_files_included() {
511 let temp = setup_test_dir();
512 let (registry, mut event_rx) = create_test_registry();
513 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
514
515 let mut input = HashMap::new();
516 input.insert(
517 "pattern".to_string(),
518 serde_json::Value::String("**/*".to_string()),
519 );
520 input.insert(
521 "include_hidden".to_string(),
522 serde_json::Value::Bool(true),
523 );
524
525 let context = ToolContext {
526 session_id: 1,
527 tool_use_id: "test-glob-hidden-incl".to_string(),
528 turn_id: None,
529 };
530
531 let registry_clone = registry.clone();
532 tokio::spawn(async move {
533 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
534 event_rx.recv().await
535 {
536 registry_clone
537 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
538 .await
539 .unwrap();
540 }
541 });
542
543 let result = tool.execute(context, input).await;
544 assert!(result.is_ok());
545 let output = result.unwrap();
546 assert!(output.contains(".hidden") || output.contains("secret.txt"));
548 }
549
550 #[tokio::test]
551 async fn test_permission_denied() {
552 let temp = setup_test_dir();
553 let (registry, mut event_rx) = create_test_registry();
554 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
555
556 let mut input = HashMap::new();
557 input.insert(
558 "pattern".to_string(),
559 serde_json::Value::String("*.rs".to_string()),
560 );
561
562 let context = ToolContext {
563 session_id: 1,
564 tool_use_id: "test-glob-denied".to_string(),
565 turn_id: None,
566 };
567
568 let registry_clone = registry.clone();
569 tokio::spawn(async move {
570 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
571 event_rx.recv().await
572 {
573 registry_clone
574 .respond(
575 &tool_use_id,
576 PermissionResponse::deny(Some("Access denied".to_string())),
577 )
578 .await
579 .unwrap();
580 }
581 });
582
583 let result = tool.execute(context, input).await;
584 assert!(result.is_err());
585 assert!(result.unwrap_err().contains("Permission denied"));
586 }
587
588 #[tokio::test]
589 async fn test_limit() {
590 let temp = setup_test_dir();
591 let (registry, mut event_rx) = create_test_registry();
592 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
593
594 let mut input = HashMap::new();
595 input.insert(
596 "pattern".to_string(),
597 serde_json::Value::String("**/*".to_string()),
598 );
599 input.insert("limit".to_string(), serde_json::Value::Number(2.into()));
600
601 let context = ToolContext {
602 session_id: 1,
603 tool_use_id: "test-glob-limit".to_string(),
604 turn_id: None,
605 };
606
607 let registry_clone = registry.clone();
608 tokio::spawn(async move {
609 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
610 event_rx.recv().await
611 {
612 registry_clone
613 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
614 .await
615 .unwrap();
616 }
617 });
618
619 let result = tool.execute(context, input).await;
620 assert!(result.is_ok());
621 let output = result.unwrap();
622 assert!(output.contains("... and"));
624 }
625
626 #[tokio::test]
627 async fn test_no_matches() {
628 let temp = setup_test_dir();
629 let (registry, mut event_rx) = create_test_registry();
630 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
631
632 let mut input = HashMap::new();
633 input.insert(
634 "pattern".to_string(),
635 serde_json::Value::String("*.nonexistent".to_string()),
636 );
637
638 let context = ToolContext {
639 session_id: 1,
640 tool_use_id: "test-glob-nomatch".to_string(),
641 turn_id: None,
642 };
643
644 let registry_clone = registry.clone();
645 tokio::spawn(async move {
646 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
647 event_rx.recv().await
648 {
649 registry_clone
650 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
651 .await
652 .unwrap();
653 }
654 });
655
656 let result = tool.execute(context, input).await;
657 assert!(result.is_ok());
658 assert!(result.unwrap().contains("No files found"));
659 }
660
661 #[tokio::test]
662 async fn test_invalid_pattern() {
663 let temp = setup_test_dir();
664 let (registry, mut event_rx) = create_test_registry();
665 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
666
667 let mut input = HashMap::new();
668 input.insert(
670 "pattern".to_string(),
671 serde_json::Value::String("[invalid".to_string()),
672 );
673
674 let context = ToolContext {
675 session_id: 1,
676 tool_use_id: "test-glob-invalid".to_string(),
677 turn_id: None,
678 };
679
680 let registry_clone = registry.clone();
681 tokio::spawn(async move {
682 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
683 event_rx.recv().await
684 {
685 registry_clone
686 .respond(&tool_use_id, PermissionResponse::grant(PermissionScope::Once))
687 .await
688 .unwrap();
689 }
690 });
691
692 let result = tool.execute(context, input).await;
693 assert!(result.is_err());
694 assert!(result.unwrap_err().contains("Invalid glob pattern"));
695 }
696
697 #[tokio::test]
698 async fn test_missing_pattern() {
699 let (registry, _event_rx) = create_test_registry();
700 let tool = GlobTool::new(registry);
701
702 let input = HashMap::new();
703
704 let context = ToolContext {
705 session_id: 1,
706 tool_use_id: "test".to_string(),
707 turn_id: None,
708 };
709
710 let result = tool.execute(context, input).await;
711 assert!(result.is_err());
712 assert!(result.unwrap_err().contains("Missing required 'pattern'"));
713 }
714
715 #[tokio::test]
716 async fn test_nonexistent_path() {
717 let (registry, _event_rx) = create_test_registry();
718 let tool = GlobTool::new(registry);
719
720 let mut input = HashMap::new();
721 input.insert(
722 "pattern".to_string(),
723 serde_json::Value::String("*.rs".to_string()),
724 );
725 input.insert(
726 "path".to_string(),
727 serde_json::Value::String("/nonexistent/path".to_string()),
728 );
729
730 let context = ToolContext {
731 session_id: 1,
732 tool_use_id: "test".to_string(),
733 turn_id: None,
734 };
735
736 let result = tool.execute(context, input).await;
737 assert!(result.is_err());
738 assert!(result.unwrap_err().contains("does not exist"));
739 }
740
741 #[test]
742 fn test_compact_summary() {
743 let (registry, _event_rx) = create_test_registry();
744 let tool = GlobTool::new(registry);
745
746 let mut input = HashMap::new();
747 input.insert(
748 "pattern".to_string(),
749 serde_json::Value::String("**/*.rs".to_string()),
750 );
751
752 let result = "/path/main.rs\n/path/lib.rs\n/path/test.rs";
753 let summary = tool.compact_summary(&input, result);
754 assert_eq!(summary, "[Glob: **/*.rs (3 files)]");
755 }
756
757 #[test]
758 fn test_compact_summary_no_matches() {
759 let (registry, _event_rx) = create_test_registry();
760 let tool = GlobTool::new(registry);
761
762 let mut input = HashMap::new();
763 input.insert(
764 "pattern".to_string(),
765 serde_json::Value::String("*.xyz".to_string()),
766 );
767
768 let result = "No files found matching pattern '*.xyz' in '/path'";
769 let summary = tool.compact_summary(&input, result);
770 assert_eq!(summary, "[Glob: *.xyz (0 files)]");
771 }
772
773 #[test]
774 fn test_build_permission_request() {
775 let request = GlobTool::build_permission_request("/path/to/src", "**/*.rs");
776
777 assert_eq!(request.action, "Search for '**/*.rs' in: src");
778 assert_eq!(
779 request.reason,
780 Some("Find files matching glob pattern".to_string())
781 );
782 assert_eq!(request.resources, vec!["/path/to/src".to_string()]);
783 assert_eq!(request.category, PermissionCategory::DirectoryRead);
784 }
785}