Skip to main content

agent_core_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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
18use super::types::{
19    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
20};
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(
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), // recursive for glob
117            PermissionLevel::Read,
118            &format!("Glob search in: {}", path),
119        )
120        .with_reason(reason)
121        .with_tool(GLOB_TOOL_NAME)
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: Request permission if not pre-approved by batch executor
205            // ─────────────────────────────────────────────────────────────
206            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            // ─────────────────────────────────────────────────────────────
231            // Step 3: Compile glob pattern
232            // ─────────────────────────────────────────────────────────────
233            let glob_matcher: GlobMatcher = Glob::new(pattern)
234                .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?
235                .compile_matcher();
236
237            // ─────────────────────────────────────────────────────────────
238            // Step 8: Walk directory and collect matches
239            // ─────────────────────────────────────────────────────────────
240            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                    // Skip hidden directories unless include_hidden is true
246                    // But always allow the root search path
247                    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            // ─────────────────────────────────────────────────────────────
263            // Step 9: Sort by modification time (most recent first)
264            // ─────────────────────────────────────────────────────────────
265            matches.sort_by(|a, b| Self::get_mtime(b).cmp(&Self::get_mtime(a)));
266
267            // ─────────────────────────────────────────────────────────────
268            // Step 10: Apply limit and format output
269            // ─────────────────────────────────────────────────────────────
270            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        // Extract pattern - required parameter
348        let pattern = input.get("pattern").and_then(|v| v.as_str())?;
349
350        // Extract path or use default_path, mirroring execute() logic
351        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        // Build permission request using the helper method
363        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    /// Helper to create a permission registry for testing.
380    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        // Create test structure
398        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        // Grant permission in background
431        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        // Should not contain hidden files
522        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        // Should contain hidden files when include_hidden is true
565        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        // Should indicate more files available
640        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        // Invalid glob pattern (unclosed bracket)
687        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}