agent_sdk/primitive_tools/
glob.rs1use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7use super::PrimitiveToolContext;
8
9pub struct GlobTool<E: Environment> {
11 ctx: PrimitiveToolContext<E>,
12}
13
14impl<E: Environment> GlobTool<E> {
15 #[must_use]
16 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
17 Self {
18 ctx: PrimitiveToolContext::new(environment, capabilities),
19 }
20 }
21}
22
23#[derive(Debug, Deserialize)]
24struct GlobInput {
25 pattern: String,
29 #[serde(default)]
31 path: Option<String>,
32}
33
34impl<E: Environment + 'static, Ctx: Send + Sync + 'static> Tool<Ctx> for GlobTool<E> {
35 type Name = PrimitiveToolName;
36
37 fn name(&self) -> PrimitiveToolName {
38 PrimitiveToolName::Glob
39 }
40
41 fn display_name(&self) -> &'static str {
42 "Find Files"
43 }
44
45 fn description(&self) -> &'static str {
46 "Find files matching a glob pattern. Supports ** for recursive matching."
47 }
48
49 fn tier(&self) -> ToolTier {
50 ToolTier::Observe
51 }
52
53 fn input_schema(&self) -> Value {
54 json!({
55 "type": "object",
56 "properties": {
57 "pattern": {
58 "type": "string",
59 "description": "Glob pattern to match files (e.g., '**/*.rs', 'src/**/*.ts'). Relative to 'path' (or the environment root); an absolute pattern starting with '/' is used as-is. Must not contain '..' segments."
60 },
61 "path": {
62 "type": "string",
63 "description": "Directory to search in. Defaults to environment root."
64 }
65 },
66 "required": ["pattern"]
67 })
68 }
69
70 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
71 let input: GlobInput = GlobInput::deserialize(&input)
72 .with_context(|| format!("Invalid input for glob tool: {input}"))?;
73
74 if pattern_has_parent_segment(&input.pattern) {
79 return Ok(ToolResult::error(
80 "pattern must not contain '..' path segments; use the 'path' parameter to choose a search directory",
81 ));
82 }
83
84 let pattern = if input.pattern.starts_with('/') {
100 input.pattern.clone()
101 } else if let Some(ref base_path) = input.path {
102 let base = self
103 .ctx
104 .environment
105 .resolve_path(base_path)
106 .replace('\\', "/");
107 format!("{}/{}", base.trim_end_matches('/'), input.pattern)
108 } else {
109 let root = self.ctx.environment.root().replace('\\', "/");
110 format!("{}/{}", root.trim_end_matches('/'), input.pattern)
111 };
112
113 let search_path = input.path.as_ref().map_or_else(
115 || self.ctx.environment.root().to_string(),
116 |p| self.ctx.environment.resolve_path(p),
117 );
118
119 if let Err(reason) = self.ctx.capabilities.check_read(&search_path) {
120 return Ok(ToolResult::error(format!(
121 "Permission denied: cannot search in '{search_path}': {reason}"
122 )));
123 }
124
125 let matches = match self.ctx.environment.glob(&pattern).await {
128 Ok(matches) => matches,
129 Err(err) => {
130 return Ok(ToolResult::error(format!(
131 "Invalid glob pattern '{}': {err:#}",
132 input.pattern
133 )));
134 }
135 };
136
137 let accessible_matches: Vec<_> = matches
139 .into_iter()
140 .filter(|path| self.ctx.capabilities.check_read(path).is_ok())
141 .collect();
142
143 if accessible_matches.is_empty() {
144 return Ok(ToolResult::success(format!(
145 "No files found matching pattern '{}'",
146 input.pattern
147 )));
148 }
149
150 let count = accessible_matches.len();
151 let output = if count > 100 {
152 format!(
153 "Found {count} files (showing first 100):\n{}",
154 accessible_matches[..100].join("\n")
155 )
156 } else {
157 format!("Found {count} files:\n{}", accessible_matches.join("\n"))
158 };
159
160 Ok(ToolResult::success(output))
161 }
162}
163
164fn pattern_has_parent_segment(pattern: &str) -> bool {
166 pattern.split('/').any(|segment| segment == "..")
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::{AgentCapabilities, InMemoryFileSystem};
173
174 fn create_test_tool(
175 fs: Arc<InMemoryFileSystem>,
176 capabilities: AgentCapabilities,
177 ) -> GlobTool<InMemoryFileSystem> {
178 GlobTool::new(fs, capabilities)
179 }
180
181 fn tool_ctx() -> ToolContext<()> {
182 ToolContext::new(())
183 }
184
185 #[tokio::test]
190 async fn test_glob_simple_pattern() -> anyhow::Result<()> {
191 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
192 fs.write_file("src/main.rs", "fn main() {}").await?;
193 fs.write_file("src/lib.rs", "pub mod foo;").await?;
194 fs.write_file("README.md", "# README").await?;
195
196 let tool = create_test_tool(fs, AgentCapabilities::full_access());
197 let result = tool
198 .execute(&tool_ctx(), json!({"pattern": "src/*.rs"}))
199 .await?;
200
201 assert!(result.success);
202 assert!(result.output.contains("Found 2 files"));
203 assert!(result.output.contains("main.rs"));
204 assert!(result.output.contains("lib.rs"));
205 Ok(())
206 }
207
208 #[tokio::test]
209 async fn test_glob_recursive_pattern() -> anyhow::Result<()> {
210 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
211 fs.write_file("src/main.rs", "fn main() {}").await?;
212 fs.write_file("src/lib/utils.rs", "pub fn util() {}")
213 .await?;
214 fs.write_file("tests/test.rs", "// test").await?;
215
216 let tool = create_test_tool(fs, AgentCapabilities::full_access());
217 let result = tool
218 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
219 .await?;
220
221 assert!(result.success);
222 assert!(result.output.contains("Found 3 files"));
223 Ok(())
224 }
225
226 #[tokio::test]
227 async fn test_glob_no_matches() -> anyhow::Result<()> {
228 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
229 fs.write_file("src/main.rs", "fn main() {}").await?;
230
231 let tool = create_test_tool(fs, AgentCapabilities::full_access());
232 let result = tool
233 .execute(&tool_ctx(), json!({"pattern": "*.py"}))
234 .await?;
235
236 assert!(result.success);
237 assert!(result.output.contains("No files found"));
238 Ok(())
239 }
240
241 #[tokio::test]
242 async fn test_glob_with_path() -> anyhow::Result<()> {
243 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
244 fs.write_file("src/main.rs", "fn main() {}").await?;
245 fs.write_file("tests/test.rs", "// test").await?;
246
247 let tool = create_test_tool(fs, AgentCapabilities::full_access());
248 let result = tool
249 .execute(
250 &tool_ctx(),
251 json!({"pattern": "*.rs", "path": "/workspace/src"}),
252 )
253 .await?;
254
255 assert!(result.success);
256 assert!(result.output.contains("Found 1 files"));
257 assert!(result.output.contains("main.rs"));
258 Ok(())
259 }
260
261 #[tokio::test]
266 async fn test_glob_permission_denied() -> anyhow::Result<()> {
267 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
268 fs.write_file("src/main.rs", "fn main() {}").await?;
269
270 let caps = AgentCapabilities::none();
272
273 let tool = create_test_tool(fs, caps);
274 let result = tool
275 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
276 .await?;
277
278 assert!(!result.success);
279 assert!(result.output.contains("Permission denied"));
280 Ok(())
281 }
282
283 #[tokio::test]
284 async fn test_glob_filters_inaccessible_files() -> anyhow::Result<()> {
285 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
286 fs.write_file("src/main.rs", "fn main() {}").await?;
287 fs.write_file("secrets/key.rs", "// secret").await?;
288
289 let caps =
291 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
292
293 let tool = create_test_tool(fs, caps);
294 let result = tool
295 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
296 .await?;
297
298 assert!(result.success);
299 assert!(result.output.contains("Found 1 files"));
300 assert!(result.output.contains("main.rs"));
301 assert!(!result.output.contains("key.rs"));
302 Ok(())
303 }
304
305 #[tokio::test]
306 async fn test_glob_allowed_paths_restriction() -> anyhow::Result<()> {
307 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
308 fs.write_file("src/main.rs", "fn main() {}").await?;
309 fs.write_file("config/settings.toml", "key = value").await?;
310
311 let caps =
313 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
314
315 let tool = create_test_tool(fs, caps);
316
317 let result = tool
319 .execute(&tool_ctx(), json!({"pattern": "**/*"}))
320 .await?;
321
322 assert!(result.success);
323 assert!(result.output.contains("main.rs"));
324 assert!(!result.output.contains("settings.toml"));
325 Ok(())
326 }
327
328 #[tokio::test]
333 async fn test_glob_empty_directory() -> anyhow::Result<()> {
334 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
335 fs.create_dir("/workspace/empty").await?;
336
337 let tool = create_test_tool(fs, AgentCapabilities::full_access());
338 let result = tool
339 .execute(
340 &tool_ctx(),
341 json!({"pattern": "*", "path": "/workspace/empty"}),
342 )
343 .await?;
344
345 assert!(result.success);
346 assert!(result.output.contains("No files found"));
347 Ok(())
348 }
349
350 #[tokio::test]
351 async fn test_glob_many_files_truncated() -> anyhow::Result<()> {
352 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
353
354 for i in 0..150 {
356 fs.write_file(&format!("files/file{i}.txt"), "content")
357 .await?;
358 }
359
360 let tool = create_test_tool(fs, AgentCapabilities::full_access());
361 let result = tool
362 .execute(&tool_ctx(), json!({"pattern": "files/*.txt"}))
363 .await?;
364
365 assert!(result.success);
366 assert!(result.output.contains("Found 150 files"));
367 assert!(result.output.contains("showing first 100"));
368 Ok(())
369 }
370
371 #[tokio::test]
372 async fn test_glob_tool_metadata() {
373 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
374 let tool = create_test_tool(fs, AgentCapabilities::full_access());
375
376 assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Glob);
377 assert_eq!(Tool::<()>::tier(&tool), ToolTier::Observe);
378 assert!(Tool::<()>::description(&tool).contains("glob"));
379
380 let schema = Tool::<()>::input_schema(&tool);
381 assert!(schema.get("properties").is_some());
382 assert!(schema["properties"].get("pattern").is_some());
383 assert!(schema["properties"].get("path").is_some());
384 }
385
386 #[tokio::test]
387 async fn test_glob_invalid_input() -> anyhow::Result<()> {
388 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
389 let tool = create_test_tool(fs, AgentCapabilities::full_access());
390
391 let result = tool.execute(&tool_ctx(), json!({})).await;
393 assert!(result.is_err());
394 Ok(())
395 }
396
397 #[tokio::test]
398 async fn test_glob_absolute_pattern() -> anyhow::Result<()> {
399 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
400 fs.write_file("src/main.rs", "fn main() {}").await?;
401 fs.write_file("src/lib.rs", "pub mod foo;").await?;
402 fs.write_file("README.md", "# README").await?;
403
404 let tool = create_test_tool(fs, AgentCapabilities::full_access());
405 let result = tool
408 .execute(&tool_ctx(), json!({"pattern": "/workspace/src/*.rs"}))
409 .await?;
410
411 assert!(result.success);
412 assert!(result.output.contains("Found 2 files"));
413 assert!(result.output.contains("main.rs"));
414 assert!(result.output.contains("lib.rs"));
415 Ok(())
416 }
417
418 #[tokio::test]
419 async fn test_glob_rejects_parent_segment() -> anyhow::Result<()> {
420 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421 fs.write_file("src/main.rs", "fn main() {}").await?;
422
423 let tool = create_test_tool(fs, AgentCapabilities::full_access());
424 let result = tool
425 .execute(&tool_ctx(), json!({"pattern": "../*.rs"}))
426 .await?;
427
428 assert!(!result.success);
429 assert!(result.output.contains(".."));
430 Ok(())
431 }
432
433 #[tokio::test]
434 async fn test_glob_invalid_pattern() -> anyhow::Result<()> {
435 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
436 fs.write_file("src/main.rs", "fn main() {}").await?;
437
438 let tool = create_test_tool(fs, AgentCapabilities::full_access());
439 let result = tool
442 .execute(&tool_ctx(), json!({"pattern": "[unclosed"}))
443 .await?;
444
445 assert!(!result.success);
446 assert!(result.output.contains("Invalid glob pattern"));
447 Ok(())
448 }
449
450 #[tokio::test]
451 async fn test_glob_specific_file_extension() -> anyhow::Result<()> {
452 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
453 fs.write_file("main.rs", "fn main() {}").await?;
454 fs.write_file("main.go", "package main").await?;
455 fs.write_file("main.py", "def main(): pass").await?;
456
457 let tool = create_test_tool(fs, AgentCapabilities::full_access());
458 let result = tool
459 .execute(&tool_ctx(), json!({"pattern": "*.rs"}))
460 .await?;
461
462 assert!(result.success);
463 assert!(result.output.contains("Found 1 files"));
464 assert!(result.output.contains("main.rs"));
465 assert!(!result.output.contains("main.go"));
466 assert!(!result.output.contains("main.py"));
467 Ok(())
468 }
469}