Skip to main content

agent_core/controller/tools/
glob.rs

1//! Glob tool implementation for fast file pattern matching.
2//!
3//! This tool allows the LLM to find files matching glob patterns.
4//! It integrates with the PermissionRegistry to require user approval
5//! before searching directories.
6
7use 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
23/// Glob tool name constant.
24pub const GLOB_TOOL_NAME: &str = "glob";
25
26/// Glob tool description constant.
27pub 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
49/// Glob tool JSON schema constant.
50pub 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
73/// Tool that finds files matching glob patterns with permission checks.
74pub struct GlobTool {
75    /// Reference to the permission registry for requesting read permissions.
76    permission_registry: Arc<PermissionRegistry>,
77    /// Default search path if none provided.
78    default_path: Option<PathBuf>,
79}
80
81impl GlobTool {
82    /// Create a new GlobTool with the given permission registry.
83    ///
84    /// # Arguments
85    /// * `permission_registry` - The registry used to request and cache permissions.
86    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
87        Self {
88            permission_registry,
89            default_path: None,
90        }
91    }
92
93    /// Create a new GlobTool with a default search path.
94    ///
95    /// # Arguments
96    /// * `permission_registry` - The registry used to request and cache permissions.
97    /// * `default_path` - The default directory to search if no path is provided.
98    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    /// Builds a permission request for searching files in a directory.
109    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    /// Get file modification time for sorting.
125    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            // ─────────────────────────────────────────────────────────────
159            // Step 1: Extract and validate parameters
160            // ─────────────────────────────────────────────────────────────
161            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            // Validate search path exists
189            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            // ─────────────────────────────────────────────────────────────
204            // Step 2: Build permission request
205            // ─────────────────────────────────────────────────────────────
206            let permission_request = Self::build_permission_request(&search_path_str, pattern);
207
208            // ─────────────────────────────────────────────────────────────
209            // Step 3: Check if permission is already granted for this session
210            // ─────────────────────────────────────────────────────────────
211            let already_granted = permission_registry
212                .is_granted(context.session_id, &permission_request)
213                .await;
214
215            if !already_granted {
216                // ─────────────────────────────────────────────────────────
217                // Step 4: Request permission from user
218                // This emits ControllerEvent::PermissionRequired to UI
219                // ─────────────────────────────────────────────────────────
220                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                // ─────────────────────────────────────────────────────────
231                // Step 5: Block until user responds
232                // ─────────────────────────────────────────────────────────
233                let response = response_rx
234                    .await
235                    .map_err(|_| "Permission request was cancelled".to_string())?;
236
237                // ─────────────────────────────────────────────────────────
238                // Step 6: Check if permission was granted
239                // ─────────────────────────────────────────────────────────
240                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            // ─────────────────────────────────────────────────────────────
252            // Step 7: Compile glob pattern
253            // ─────────────────────────────────────────────────────────────
254            let glob_matcher: GlobMatcher = Glob::new(pattern)
255                .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?
256                .compile_matcher();
257
258            // ─────────────────────────────────────────────────────────────
259            // Step 8: Walk directory and collect matches
260            // ─────────────────────────────────────────────────────────────
261            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                    // Skip hidden directories unless include_hidden is true
267                    // But always allow the root search path
268                    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            // ─────────────────────────────────────────────────────────────
284            // Step 9: Sort by modification time (most recent first)
285            // ─────────────────────────────────────────────────────────────
286            matches.sort_by(|a, b| Self::get_mtime(b).cmp(&Self::get_mtime(a)));
287
288            // ─────────────────────────────────────────────────────────────
289            // Step 10: Apply limit and format output
290            // ─────────────────────────────────────────────────────────────
291            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    /// Helper to create a permission registry for testing.
374    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        // Create test structure
384        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        // Grant permission in background
416        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        // Should not contain hidden files
505        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        // Should contain hidden files when include_hidden is true
547        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        // Should indicate more files available
623        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        // Invalid glob pattern (unclosed bracket)
669        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}