claude_agent/tools/
glob.rs1use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use super::SchemaTool;
8use super::context::ExecutionContext;
9use crate::types::ToolResult;
10
11#[derive(Debug, Deserialize, JsonSchema)]
13#[schemars(deny_unknown_fields)]
14pub struct GlobInput {
15 pub pattern: String,
17 #[serde(default)]
21 pub path: Option<String>,
22}
23
24#[derive(Debug, Clone, Copy, Default)]
25pub struct GlobTool;
26
27#[async_trait]
28impl SchemaTool for GlobTool {
29 type Input = GlobInput;
30
31 const NAME: &'static str = "Glob";
32 const DESCRIPTION: &'static str = r#"- Fast file pattern matching tool that works with any codebase size
33- Supports glob patterns like "**/*.js" or "src/**/*.ts"
34- Returns matching file paths sorted by modification time
35- Use this tool when you need to find files by name patterns
36- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
37- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful."#;
38
39 async fn handle(&self, input: GlobInput, context: &ExecutionContext) -> ToolResult {
40 let base_path = match context.try_resolve_or_root_for(Self::NAME, input.path.as_deref()) {
41 Ok(path) => path,
42 Err(e) => return e,
43 };
44
45 let full_pattern = base_path.join(&input.pattern);
46 let pattern_str = full_pattern.to_string_lossy().to_string();
47
48 let glob_result = tokio::task::spawn_blocking(move || {
49 glob::glob(&pattern_str).map(|paths| {
50 paths
51 .filter_map(|r| r.ok())
52 .filter_map(|p| {
53 std::fs::canonicalize(&p).ok().and_then(|canonical| {
54 canonical
55 .metadata()
56 .ok()
57 .and_then(|m| m.modified().ok())
58 .map(|mtime| (canonical, mtime))
59 })
60 })
61 .collect::<Vec<_>>()
62 })
63 })
64 .await;
65
66 let all_entries = match glob_result {
67 Ok(Ok(entries)) => entries,
68 Ok(Err(e)) => return ToolResult::error(format!("Invalid pattern: {}", e)),
69 Err(e) => return ToolResult::error(format!("Glob task failed: {}", e)),
70 };
71
72 let mut entries: Vec<_> = all_entries
73 .into_iter()
74 .filter(|(p, _)| context.is_within(p))
75 .collect();
76
77 if entries.is_empty() {
78 return ToolResult::success("No files matched the pattern");
79 }
80
81 entries.sort_by(|a, b| b.1.cmp(&a.1));
82
83 let output: Vec<String> = entries
84 .into_iter()
85 .map(|(p, _)| p.display().to_string())
86 .collect();
87
88 ToolResult::success(output.join("\n"))
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::tools::Tool;
96 use crate::types::ToolOutput;
97 use tempfile::tempdir;
98 use tokio::fs;
99
100 #[tokio::test]
101 async fn test_glob_pattern() {
102 let dir = tempdir().unwrap();
103 let root = std::fs::canonicalize(dir.path()).unwrap();
104 fs::write(root.join("test1.txt"), "").await.unwrap();
105 fs::write(root.join("test2.txt"), "").await.unwrap();
106 fs::write(root.join("other.rs"), "").await.unwrap();
107
108 let test_context = ExecutionContext::from_path(&root).unwrap();
109 let tool = GlobTool;
110
111 let result = tool
112 .execute(serde_json::json!({"pattern": "*.txt"}), &test_context)
113 .await;
114
115 match &result.output {
116 ToolOutput::Success(content) => {
117 assert!(content.contains("test1.txt"));
118 assert!(content.contains("test2.txt"));
119 assert!(!content.contains("other.rs"));
120 }
121 _ => panic!("Expected success"),
122 }
123 }
124
125 #[tokio::test]
126 async fn test_glob_recursive_pattern() {
127 let dir = tempdir().unwrap();
128 let root = std::fs::canonicalize(dir.path()).unwrap();
129
130 let subdir = root.join("src");
131 fs::create_dir_all(&subdir).await.unwrap();
132 fs::write(root.join("main.rs"), "fn main() {}")
133 .await
134 .unwrap();
135 fs::write(subdir.join("lib.rs"), "pub mod lib;")
136 .await
137 .unwrap();
138 fs::write(subdir.join("utils.rs"), "pub fn util() {}")
139 .await
140 .unwrap();
141
142 let test_context = ExecutionContext::from_path(&root).unwrap();
143 let tool = GlobTool;
144
145 let result = tool
146 .execute(serde_json::json!({"pattern": "**/*.rs"}), &test_context)
147 .await;
148
149 match &result.output {
150 ToolOutput::Success(content) => {
151 assert!(content.contains("main.rs"));
152 assert!(content.contains("lib.rs"));
153 assert!(content.contains("utils.rs"));
154 }
155 _ => panic!("Expected success"),
156 }
157 }
158
159 #[tokio::test]
160 async fn test_glob_no_matches() {
161 let dir = tempdir().unwrap();
162 let root = std::fs::canonicalize(dir.path()).unwrap();
163 fs::write(root.join("test.txt"), "").await.unwrap();
164
165 let test_context = ExecutionContext::from_path(&root).unwrap();
166 let tool = GlobTool;
167
168 let result = tool
169 .execute(serde_json::json!({"pattern": "*.py"}), &test_context)
170 .await;
171
172 match &result.output {
173 ToolOutput::Success(content) => {
174 assert!(content.contains("No files matched"));
175 }
176 _ => panic!("Expected success with no matches message"),
177 }
178 }
179
180 #[tokio::test]
181 async fn test_glob_with_path() {
182 let dir = tempdir().unwrap();
183 let root = std::fs::canonicalize(dir.path()).unwrap();
184
185 let subdir = root.join("nested");
186 fs::create_dir_all(&subdir).await.unwrap();
187 fs::write(root.join("root.txt"), "").await.unwrap();
188 fs::write(subdir.join("nested.txt"), "").await.unwrap();
189
190 let test_context = ExecutionContext::from_path(&root).unwrap();
191 let tool = GlobTool;
192
193 let result = tool
194 .execute(
195 serde_json::json!({"pattern": "*.txt", "path": "nested"}),
196 &test_context,
197 )
198 .await;
199
200 match &result.output {
201 ToolOutput::Success(content) => {
202 assert!(content.contains("nested.txt"));
203 assert!(!content.contains("root.txt"));
204 }
205 _ => panic!("Expected success"),
206 }
207 }
208
209 #[tokio::test]
210 async fn test_glob_invalid_pattern() {
211 let dir = tempdir().unwrap();
212 let root = std::fs::canonicalize(dir.path()).unwrap();
213
214 let test_context = ExecutionContext::from_path(&root).unwrap();
215 let tool = GlobTool;
216
217 let result = tool
218 .execute(serde_json::json!({"pattern": "[invalid"}), &test_context)
219 .await;
220
221 match &result.output {
222 ToolOutput::Error(e) => {
223 assert!(e.to_string().contains("Invalid pattern"));
224 }
225 _ => panic!("Expected error for invalid pattern"),
226 }
227 }
228
229 #[tokio::test]
230 async fn test_glob_sorted_by_mtime() {
231 let dir = tempdir().unwrap();
232 let root = std::fs::canonicalize(dir.path()).unwrap();
233
234 fs::write(root.join("old.txt"), "old").await.unwrap();
235 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
236 fs::write(root.join("new.txt"), "new").await.unwrap();
237
238 let test_context = ExecutionContext::from_path(&root).unwrap();
239 let tool = GlobTool;
240
241 let result = tool
242 .execute(serde_json::json!({"pattern": "*.txt"}), &test_context)
243 .await;
244
245 match &result.output {
246 ToolOutput::Success(content) => {
247 let new_pos = content.find("new.txt").unwrap();
248 let old_pos = content.find("old.txt").unwrap();
249 assert!(new_pos < old_pos, "Newer file should appear first");
250 }
251 _ => panic!("Expected success"),
252 }
253 }
254
255 #[test]
256 fn test_glob_input_parsing() {
257 let input: GlobInput = serde_json::from_value(serde_json::json!({
258 "pattern": "**/*.rs",
259 "path": "src"
260 }))
261 .unwrap();
262 assert_eq!(input.pattern, "**/*.rs");
263 assert_eq!(input.path, Some("src".to_string()));
264 }
265
266 #[tokio::test]
267 async fn test_glob_path_traversal_blocked() {
268 let parent = tempdir().unwrap();
270 let parent_path = std::fs::canonicalize(parent.path()).unwrap();
271
272 let working_dir = parent_path.join("sandbox");
273 std::fs::create_dir_all(&working_dir).unwrap();
274 let sandbox_path = std::fs::canonicalize(&working_dir).unwrap();
275
276 std::fs::write(parent_path.join("secret.txt"), "SECRET").unwrap();
278 std::fs::write(sandbox_path.join("allowed.txt"), "allowed").unwrap();
279
280 let test_context = ExecutionContext::from_path(&sandbox_path).unwrap();
282 let tool = GlobTool;
283
284 let result = tool
286 .execute(serde_json::json!({"pattern": "../*.txt"}), &test_context)
287 .await;
288
289 match &result.output {
290 ToolOutput::Success(content) => {
291 assert!(
293 !content.contains("secret.txt"),
294 "Path traversal should be blocked! Found: {}",
295 content
296 );
297 }
298 ToolOutput::Error(_) => {
299 }
301 _ => panic!("Unexpected result"),
302 }
303 }
304}