Skip to main content

adk_devtools/tools/
glob.rs

1//! `glob` — list workspace files matching a glob pattern.
2
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6use adk_core::{Result, Tool, ToolContext};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use walkdir::WalkDir;
10
11use crate::error::DevToolError;
12use crate::tools::read::require_str;
13use crate::workspace::Workspace;
14
15const MAX_RESULTS: usize = 1000;
16
17/// Lists files matching a glob pattern (e.g. `src/**/*.rs`), relative to the
18/// workspace root or an optional sub-directory.
19pub struct GlobTool {
20    workspace: Workspace,
21}
22
23impl GlobTool {
24    /// Create a `glob` tool bound to `workspace`.
25    pub fn new(workspace: Workspace) -> Self {
26        Self { workspace }
27    }
28}
29
30#[async_trait]
31impl Tool for GlobTool {
32    fn name(&self) -> &str {
33        "glob"
34    }
35
36    fn description(&self) -> &str {
37        "List files matching a glob pattern (e.g. 'src/**/*.rs'). Returns paths \
38         relative to the workspace root. The pattern is relative to the workspace \
39         and may not contain '..' or an absolute path."
40    }
41
42    fn is_read_only(&self) -> bool {
43        true
44    }
45
46    fn is_concurrency_safe(&self) -> bool {
47        true
48    }
49
50    fn parameters_schema(&self) -> Option<Value> {
51        Some(json!({
52            "type": "object",
53            "properties": {
54                "pattern": { "type": "string", "description": "Glob pattern, e.g. '**/*.toml'." },
55                "path": { "type": "string", "description": "Optional sub-directory to search within." }
56            },
57            "required": ["pattern"]
58        }))
59    }
60
61    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
62        let pattern = require_str(&args, "pattern")?;
63        // `glob` follows `..` and absolute prefixes literally, so either would list
64        // directories outside the workspace.
65        let escapes = Path::new(&pattern).components().any(|component| match component {
66            Component::Prefix(_) | Component::RootDir | Component::ParentDir => true,
67            Component::CurDir | Component::Normal(_) => false,
68        });
69        if escapes {
70            return Err(DevToolError::PathEscape(pattern).into());
71        }
72        let base = match args.get("path").and_then(Value::as_str) {
73            Some(sub) => self.workspace.resolve(sub)?,
74            None => self.workspace.root().to_path_buf(),
75        };
76
77        let matcher = glob::Pattern::new(&pattern)
78            .map_err(|e| DevToolError::Other(format!("invalid glob pattern: {e}")))?;
79        // `*` and `?` stay within one component, as `glob::glob` treats them, so `src/*.rs`
80        // cannot reach `src/a/b.rs`; `**` still spans directories.
81        let options = glob::MatchOptions { require_literal_separator: true, ..Default::default() };
82
83        // Walk from the base and match the path relative to it. Expanding the base through
84        // `glob::glob` broke on Windows, where a canonical root carries the `\\?\` verbatim
85        // prefix and `?` is a wildcard. The pattern's literal leading components narrow the
86        // walk, so `src/**/*.rs` never visits `target/`.
87        let literal: PathBuf = Path::new(&pattern)
88            .components()
89            .take_while(|component| match component {
90                Component::Normal(part) => !part.to_string_lossy().contains(['*', '?', '[']),
91                Component::Prefix(_)
92                | Component::RootDir
93                | Component::CurDir
94                | Component::ParentDir => false,
95            })
96            .collect();
97        let start = base.join(literal);
98
99        let mut matches = Vec::new();
100        let mut truncated = false;
101        if start.exists() {
102            // Symlinked directories are listed but not descended: one that points outside
103            // the workspace would otherwise be walked before containment is checked.
104            for entry in WalkDir::new(&start).sort_by_file_name().into_iter().flatten() {
105                let path = entry.path();
106                let Ok(relative) = path.strip_prefix(&base) else { continue };
107                if relative.as_os_str().is_empty() || !matcher.matches_path_with(relative, options)
108                {
109                    continue;
110                }
111                // A symlink can point anywhere.
112                let contained = std::fs::canonicalize(path)
113                    .is_ok_and(|canonical| canonical.starts_with(self.workspace.root()));
114                if !contained {
115                    continue;
116                }
117                if matches.len() >= MAX_RESULTS {
118                    truncated = true;
119                    break;
120                }
121                matches.push(self.workspace.display(path));
122            }
123        }
124
125        Ok(json!({
126            "pattern": pattern,
127            "matches": matches,
128            "count": matches.len(),
129            "truncated": truncated,
130        }))
131    }
132}