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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
18use super::types::{
19 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
20};
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(
109 tool_use_id: &str,
110 path: &str,
111 pattern: &str,
112 ) -> PermissionRequest {
113 let reason = format!("Search for '{}' pattern", pattern);
114 PermissionRequest::new(
115 tool_use_id,
116 GrantTarget::path(path, true), PermissionLevel::Read,
118 &format!("Glob search in: {}", path),
119 )
120 .with_reason(reason)
121 .with_tool(GLOB_TOOL_NAME)
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 if !context.permissions_pre_approved {
207 let permission_request =
208 Self::build_permission_request(&context.tool_use_id, &search_path_str, pattern);
209
210 let response_rx = permission_registry
211 .request_permission(context.session_id, permission_request, context.turn_id.clone())
212 .await
213 .map_err(|e| format!("Failed to request permission: {}", e))?;
214
215 let response = response_rx
216 .await
217 .map_err(|_| "Permission request was cancelled".to_string())?;
218
219 if !response.granted {
220 let reason = response
221 .message
222 .unwrap_or_else(|| "Permission denied by user".to_string());
223 return Err(format!(
224 "Permission denied to search '{}': {}",
225 search_path_str, reason
226 ));
227 }
228 }
229
230 let glob_matcher: GlobMatcher = Glob::new(pattern)
234 .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?
235 .compile_matcher();
236
237 let search_path_for_filter = search_path.clone();
241 let mut matches: Vec<PathBuf> = WalkDir::new(&search_path)
242 .follow_links(false)
243 .into_iter()
244 .filter_entry(move |e| {
245 let is_root = e.path() == search_path_for_filter;
248 is_root
249 || include_hidden
250 || !e.file_name().to_string_lossy().starts_with('.')
251 })
252 .filter_map(|e| e.ok())
253 .filter(|e| e.file_type().is_file())
254 .filter(|e| {
255 let path = e.path();
256 let relative = path.strip_prefix(&search_path).unwrap_or(path);
257 glob_matcher.is_match(relative)
258 })
259 .map(|e| e.path().to_path_buf())
260 .collect();
261
262 matches.sort_by(|a, b| Self::get_mtime(b).cmp(&Self::get_mtime(a)));
266
267 let total_matches = matches.len();
271
272 if matches.is_empty() {
273 return Ok(format!(
274 "No files found matching pattern '{}' in '{}'",
275 pattern, search_path_str
276 ));
277 }
278
279 matches.truncate(limit);
280
281 let mut output = String::new();
282 for path in &matches {
283 output.push_str(&path.display().to_string());
284 output.push('\n');
285 }
286
287 if total_matches > limit {
288 output.push_str(&format!(
289 "\n... and {} more files (showing {}/{})",
290 total_matches - limit,
291 limit,
292 total_matches
293 ));
294 }
295
296 Ok(output.trim_end().to_string())
297 })
298 }
299
300 fn display_config(&self) -> DisplayConfig {
301 DisplayConfig {
302 display_name: "Glob".to_string(),
303 display_title: Box::new(|input| {
304 input
305 .get("pattern")
306 .and_then(|v| v.as_str())
307 .unwrap_or("*")
308 .to_string()
309 }),
310 display_content: Box::new(|_input, result| {
311 let lines: Vec<&str> = result.lines().take(20).collect();
312 let total_lines = result.lines().count();
313
314 DisplayResult {
315 content: lines.join("\n"),
316 content_type: ResultContentType::PlainText,
317 is_truncated: total_lines > 20,
318 full_length: total_lines,
319 }
320 }),
321 }
322 }
323
324 fn compact_summary(
325 &self,
326 input: &HashMap<String, serde_json::Value>,
327 result: &str,
328 ) -> String {
329 let pattern = input
330 .get("pattern")
331 .and_then(|v| v.as_str())
332 .unwrap_or("*");
333
334 let file_count = result
335 .lines()
336 .filter(|line| !line.starts_with("...") && !line.starts_with("No files") && !line.is_empty())
337 .count();
338
339 format!("[Glob: {} ({} files)]", pattern, file_count)
340 }
341
342 fn required_permissions(
343 &self,
344 context: &ToolContext,
345 input: &HashMap<String, serde_json::Value>,
346 ) -> Option<Vec<PermissionRequest>> {
347 let pattern = input.get("pattern").and_then(|v| v.as_str())?;
349
350 let search_path = input
352 .get("path")
353 .and_then(|v| v.as_str())
354 .map(PathBuf::from)
355 .or_else(|| self.default_path.clone())
356 .unwrap_or_else(|| {
357 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
358 });
359
360 let search_path_str = search_path.to_string_lossy().to_string();
361
362 let permission_request =
364 Self::build_permission_request(&context.tool_use_id, &search_path_str, pattern);
365
366 Some(vec![permission_request])
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::controller::types::ControllerEvent;
374 use crate::permissions::{PermissionLevel, PermissionPanelResponse};
375 use std::fs;
376 use tempfile::TempDir;
377 use tokio::sync::mpsc;
378
379 fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
381 let (tx, rx) = mpsc::channel(16);
382 let registry = Arc::new(PermissionRegistry::new(tx));
383 (registry, rx)
384 }
385
386 fn grant_once() -> PermissionPanelResponse {
387 PermissionPanelResponse { granted: true, grant: None, message: None }
388 }
389
390 fn deny(reason: &str) -> PermissionPanelResponse {
391 PermissionPanelResponse { granted: false, grant: None, message: Some(reason.to_string()) }
392 }
393
394 fn setup_test_dir() -> TempDir {
395 let temp = TempDir::new().unwrap();
396
397 fs::create_dir_all(temp.path().join("src")).unwrap();
399 fs::create_dir_all(temp.path().join("tests")).unwrap();
400 fs::create_dir_all(temp.path().join(".hidden_dir")).unwrap();
401 fs::write(temp.path().join("src/main.rs"), "fn main() {}").unwrap();
402 fs::write(temp.path().join("src/lib.rs"), "pub mod lib;").unwrap();
403 fs::write(temp.path().join("tests/test.rs"), "#[test]").unwrap();
404 fs::write(temp.path().join("README.md"), "# README").unwrap();
405 fs::write(temp.path().join(".hidden"), "hidden file").unwrap();
406 fs::write(temp.path().join(".hidden_dir/secret.txt"), "secret").unwrap();
407
408 temp
409 }
410
411 #[tokio::test]
412 async fn test_simple_pattern_with_permission() {
413 let temp = setup_test_dir();
414 let (registry, mut event_rx) = create_test_registry();
415 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
416
417 let mut input = HashMap::new();
418 input.insert(
419 "pattern".to_string(),
420 serde_json::Value::String("*.md".to_string()),
421 );
422
423 let context = ToolContext {
424 session_id: 1,
425 tool_use_id: "test-glob-1".to_string(),
426 turn_id: None,
427 permissions_pre_approved: false,
428 };
429
430 let registry_clone = registry.clone();
432 tokio::spawn(async move {
433 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
434 event_rx.recv().await
435 {
436 registry_clone
437 .respond_to_request(&tool_use_id, grant_once())
438 .await
439 .unwrap();
440 }
441 });
442
443 let result = tool.execute(context, input).await;
444 assert!(result.is_ok());
445 assert!(result.unwrap().contains("README.md"));
446 }
447
448 #[tokio::test]
449 async fn test_recursive_pattern() {
450 let temp = setup_test_dir();
451 let (registry, mut event_rx) = create_test_registry();
452 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
453
454 let mut input = HashMap::new();
455 input.insert(
456 "pattern".to_string(),
457 serde_json::Value::String("**/*.rs".to_string()),
458 );
459
460 let context = ToolContext {
461 session_id: 1,
462 tool_use_id: "test-glob-recursive".to_string(),
463 turn_id: None,
464 permissions_pre_approved: false,
465 };
466
467 let registry_clone = registry.clone();
468 tokio::spawn(async move {
469 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
470 event_rx.recv().await
471 {
472 registry_clone
473 .respond_to_request(&tool_use_id, grant_once())
474 .await
475 .unwrap();
476 }
477 });
478
479 let result = tool.execute(context, input).await;
480 assert!(result.is_ok());
481 let output = result.unwrap();
482 assert!(output.contains("main.rs"));
483 assert!(output.contains("lib.rs"));
484 assert!(output.contains("test.rs"));
485 }
486
487 #[tokio::test]
488 async fn test_hidden_files_excluded() {
489 let temp = setup_test_dir();
490 let (registry, mut event_rx) = create_test_registry();
491 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
492
493 let mut input = HashMap::new();
494 input.insert(
495 "pattern".to_string(),
496 serde_json::Value::String("**/*".to_string()),
497 );
498
499 let context = ToolContext {
500 session_id: 1,
501 tool_use_id: "test-glob-hidden".to_string(),
502 turn_id: None,
503 permissions_pre_approved: false,
504 };
505
506 let registry_clone = registry.clone();
507 tokio::spawn(async move {
508 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
509 event_rx.recv().await
510 {
511 registry_clone
512 .respond_to_request(&tool_use_id, grant_once())
513 .await
514 .unwrap();
515 }
516 });
517
518 let result = tool.execute(context, input).await;
519 assert!(result.is_ok());
520 let output = result.unwrap();
521 assert!(!output.contains(".hidden"));
523 assert!(!output.contains("secret.txt"));
524 }
525
526 #[tokio::test]
527 async fn test_hidden_files_included() {
528 let temp = setup_test_dir();
529 let (registry, mut event_rx) = create_test_registry();
530 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
531
532 let mut input = HashMap::new();
533 input.insert(
534 "pattern".to_string(),
535 serde_json::Value::String("**/*".to_string()),
536 );
537 input.insert(
538 "include_hidden".to_string(),
539 serde_json::Value::Bool(true),
540 );
541
542 let context = ToolContext {
543 session_id: 1,
544 tool_use_id: "test-glob-hidden-incl".to_string(),
545 turn_id: None,
546 permissions_pre_approved: false,
547 };
548
549 let registry_clone = registry.clone();
550 tokio::spawn(async move {
551 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
552 event_rx.recv().await
553 {
554 registry_clone
555 .respond_to_request(&tool_use_id, grant_once())
556 .await
557 .unwrap();
558 }
559 });
560
561 let result = tool.execute(context, input).await;
562 assert!(result.is_ok());
563 let output = result.unwrap();
564 assert!(output.contains(".hidden") || output.contains("secret.txt"));
566 }
567
568 #[tokio::test]
569 async fn test_permission_denied() {
570 let temp = setup_test_dir();
571 let (registry, mut event_rx) = create_test_registry();
572 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
573
574 let mut input = HashMap::new();
575 input.insert(
576 "pattern".to_string(),
577 serde_json::Value::String("*.rs".to_string()),
578 );
579
580 let context = ToolContext {
581 session_id: 1,
582 tool_use_id: "test-glob-denied".to_string(),
583 turn_id: None,
584 permissions_pre_approved: false,
585 };
586
587 let registry_clone = registry.clone();
588 tokio::spawn(async move {
589 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
590 event_rx.recv().await
591 {
592 registry_clone
593 .respond_to_request(&tool_use_id, deny("Access denied"))
594 .await
595 .unwrap();
596 }
597 });
598
599 let result = tool.execute(context, input).await;
600 assert!(result.is_err());
601 assert!(result.unwrap_err().contains("Permission denied"));
602 }
603
604 #[tokio::test]
605 async fn test_limit() {
606 let temp = setup_test_dir();
607 let (registry, mut event_rx) = create_test_registry();
608 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
609
610 let mut input = HashMap::new();
611 input.insert(
612 "pattern".to_string(),
613 serde_json::Value::String("**/*".to_string()),
614 );
615 input.insert("limit".to_string(), serde_json::Value::Number(2.into()));
616
617 let context = ToolContext {
618 session_id: 1,
619 tool_use_id: "test-glob-limit".to_string(),
620 turn_id: None,
621 permissions_pre_approved: false,
622 };
623
624 let registry_clone = registry.clone();
625 tokio::spawn(async move {
626 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
627 event_rx.recv().await
628 {
629 registry_clone
630 .respond_to_request(&tool_use_id, grant_once())
631 .await
632 .unwrap();
633 }
634 });
635
636 let result = tool.execute(context, input).await;
637 assert!(result.is_ok());
638 let output = result.unwrap();
639 assert!(output.contains("... and"));
641 }
642
643 #[tokio::test]
644 async fn test_no_matches() {
645 let temp = setup_test_dir();
646 let (registry, mut event_rx) = create_test_registry();
647 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
648
649 let mut input = HashMap::new();
650 input.insert(
651 "pattern".to_string(),
652 serde_json::Value::String("*.nonexistent".to_string()),
653 );
654
655 let context = ToolContext {
656 session_id: 1,
657 tool_use_id: "test-glob-nomatch".to_string(),
658 turn_id: None,
659 permissions_pre_approved: false,
660 };
661
662 let registry_clone = registry.clone();
663 tokio::spawn(async move {
664 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
665 event_rx.recv().await
666 {
667 registry_clone
668 .respond_to_request(&tool_use_id, grant_once())
669 .await
670 .unwrap();
671 }
672 });
673
674 let result = tool.execute(context, input).await;
675 assert!(result.is_ok());
676 assert!(result.unwrap().contains("No files found"));
677 }
678
679 #[tokio::test]
680 async fn test_invalid_pattern() {
681 let temp = setup_test_dir();
682 let (registry, mut event_rx) = create_test_registry();
683 let tool = GlobTool::with_default_path(registry.clone(), temp.path().to_path_buf());
684
685 let mut input = HashMap::new();
686 input.insert(
688 "pattern".to_string(),
689 serde_json::Value::String("[invalid".to_string()),
690 );
691
692 let context = ToolContext {
693 session_id: 1,
694 tool_use_id: "test-glob-invalid".to_string(),
695 turn_id: None,
696 permissions_pre_approved: false,
697 };
698
699 let registry_clone = registry.clone();
700 tokio::spawn(async move {
701 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
702 event_rx.recv().await
703 {
704 registry_clone
705 .respond_to_request(&tool_use_id, grant_once())
706 .await
707 .unwrap();
708 }
709 });
710
711 let result = tool.execute(context, input).await;
712 assert!(result.is_err());
713 assert!(result.unwrap_err().contains("Invalid glob pattern"));
714 }
715
716 #[tokio::test]
717 async fn test_missing_pattern() {
718 let (registry, _event_rx) = create_test_registry();
719 let tool = GlobTool::new(registry);
720
721 let input = HashMap::new();
722
723 let context = ToolContext {
724 session_id: 1,
725 tool_use_id: "test".to_string(),
726 turn_id: None,
727 permissions_pre_approved: false,
728 };
729
730 let result = tool.execute(context, input).await;
731 assert!(result.is_err());
732 assert!(result.unwrap_err().contains("Missing required 'pattern'"));
733 }
734
735 #[tokio::test]
736 async fn test_nonexistent_path() {
737 let (registry, _event_rx) = create_test_registry();
738 let tool = GlobTool::new(registry);
739
740 let mut input = HashMap::new();
741 input.insert(
742 "pattern".to_string(),
743 serde_json::Value::String("*.rs".to_string()),
744 );
745 input.insert(
746 "path".to_string(),
747 serde_json::Value::String("/nonexistent/path".to_string()),
748 );
749
750 let context = ToolContext {
751 session_id: 1,
752 tool_use_id: "test".to_string(),
753 turn_id: None,
754 permissions_pre_approved: false,
755 };
756
757 let result = tool.execute(context, input).await;
758 assert!(result.is_err());
759 assert!(result.unwrap_err().contains("does not exist"));
760 }
761
762 #[test]
763 fn test_compact_summary() {
764 let (registry, _event_rx) = create_test_registry();
765 let tool = GlobTool::new(registry);
766
767 let mut input = HashMap::new();
768 input.insert(
769 "pattern".to_string(),
770 serde_json::Value::String("**/*.rs".to_string()),
771 );
772
773 let result = "/path/main.rs\n/path/lib.rs\n/path/test.rs";
774 let summary = tool.compact_summary(&input, result);
775 assert_eq!(summary, "[Glob: **/*.rs (3 files)]");
776 }
777
778 #[test]
779 fn test_compact_summary_no_matches() {
780 let (registry, _event_rx) = create_test_registry();
781 let tool = GlobTool::new(registry);
782
783 let mut input = HashMap::new();
784 input.insert(
785 "pattern".to_string(),
786 serde_json::Value::String("*.xyz".to_string()),
787 );
788
789 let result = "No files found matching pattern '*.xyz' in '/path'";
790 let summary = tool.compact_summary(&input, result);
791 assert_eq!(summary, "[Glob: *.xyz (0 files)]");
792 }
793
794 #[test]
795 fn test_build_permission_request() {
796 let request = GlobTool::build_permission_request("tool-123", "/path/to/src", "**/*.rs");
797
798 assert_eq!(request.description, "Glob search in: /path/to/src");
799 assert_eq!(
800 request.reason,
801 Some("Search for '**/*.rs' pattern".to_string())
802 );
803 assert_eq!(request.target, GrantTarget::path("/path/to/src", true));
804 assert_eq!(request.required_level, PermissionLevel::Read);
805 }
806}