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