Skip to main content

agent_air_runtime/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::types::{
18    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
19};
20use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
21
22/// Glob tool name constant.
23pub const GLOB_TOOL_NAME: &str = "glob";
24
25/// Glob tool description constant.
26pub 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
48/// Glob tool JSON schema constant.
49pub 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
72/// Tool that finds files matching glob patterns with permission checks.
73pub struct GlobTool {
74    /// Reference to the permission registry for requesting read permissions.
75    permission_registry: Arc<PermissionRegistry>,
76    /// Default search path if none provided.
77    default_path: Option<PathBuf>,
78}
79
80impl GlobTool {
81    /// Create a new GlobTool with the given permission registry.
82    ///
83    /// # Arguments
84    /// * `permission_registry` - The registry used to request and cache permissions.
85    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
86        Self {
87            permission_registry,
88            default_path: None,
89        }
90    }
91
92    /// Create a new GlobTool with a default search path.
93    ///
94    /// # Arguments
95    /// * `permission_registry` - The registry used to request and cache permissions.
96    /// * `default_path` - The default directory to search if no path is provided.
97    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    /// Builds a permission request for searching files in a directory.
108    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), // recursive for glob
113            PermissionLevel::Read,
114            format!("Glob search in: {}", path),
115        )
116        .with_reason(reason)
117        .with_tool(GLOB_TOOL_NAME)
118    }
119
120    /// Get file modification time for sorting.
121    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            // ─────────────────────────────────────────────────────────────
155            // Step 1: Extract and validate parameters
156            // ─────────────────────────────────────────────────────────────
157            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            // Validate search path exists
183            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            // ─────────────────────────────────────────────────────────────
195            // Step 2: Request permission if not pre-approved by batch executor
196            // ─────────────────────────────────────────────────────────────
197            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            // ─────────────────────────────────────────────────────────────
226            // Step 3: Compile glob pattern
227            // ─────────────────────────────────────────────────────────────
228            let glob_matcher: GlobMatcher = Glob::new(pattern)
229                .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?
230                .compile_matcher();
231
232            // ─────────────────────────────────────────────────────────────
233            // Step 8: Walk directory and collect matches
234            // ─────────────────────────────────────────────────────────────
235            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                    // Skip hidden directories unless include_hidden is true
241                    // But always allow the root search path
242                    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            // ─────────────────────────────────────────────────────────────
256            // Step 9: Sort by modification time (most recent first)
257            // ─────────────────────────────────────────────────────────────
258            matches.sort_by(|a, b| Self::get_mtime(b).cmp(&Self::get_mtime(a)));
259
260            // ─────────────────────────────────────────────────────────────
261            // Step 10: Apply limit and format output
262            // ─────────────────────────────────────────────────────────────
263            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        // Extract pattern - required parameter
336        let pattern = input.get("pattern").and_then(|v| v.as_str())?;
337
338        // Extract path or use default_path, mirroring execute() logic
339        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        // Build permission request using the helper method
349        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    /// Helper to create a permission registry for testing.
366    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        // Create test structure
392        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        // Grant permission in background
425        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        // Should not contain hidden files
516        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        // Should contain hidden files when include_hidden is true
556        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        // Should indicate more files available
631        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        // Invalid glob pattern (unclosed bracket)
678        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}