adk_devtools/tools/
glob.rs1use 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
17pub struct GlobTool {
20 workspace: Workspace,
21}
22
23impl GlobTool {
24 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 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 let options = glob::MatchOptions { require_literal_separator: true, ..Default::default() };
82
83 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 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 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}