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,
27 #[serde(default)]
29 path: Option<String>,
30}
31
32impl<E: Environment + 'static, Ctx: Send + Sync + 'static> Tool<Ctx> for GlobTool<E> {
33 type Name = PrimitiveToolName;
34
35 fn name(&self) -> PrimitiveToolName {
36 PrimitiveToolName::Glob
37 }
38
39 fn display_name(&self) -> &'static str {
40 "Find Files"
41 }
42
43 fn description(&self) -> &'static str {
44 "Find files matching a glob pattern. Supports ** for recursive matching."
45 }
46
47 fn tier(&self) -> ToolTier {
48 ToolTier::Observe
49 }
50
51 fn input_schema(&self) -> Value {
52 json!({
53 "type": "object",
54 "properties": {
55 "pattern": {
56 "type": "string",
57 "description": "Glob pattern to match files (e.g., '**/*.rs', 'src/**/*.ts')"
58 },
59 "path": {
60 "type": "string",
61 "description": "Directory to search in. Defaults to environment root."
62 }
63 },
64 "required": ["pattern"]
65 })
66 }
67
68 async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
69 let input: GlobInput =
70 serde_json::from_value(input).context("Invalid input for glob tool")?;
71
72 let pattern = if let Some(ref base_path) = input.path {
80 let base = self
81 .ctx
82 .environment
83 .resolve_path(base_path)
84 .replace('\\', "/");
85 format!("{}/{}", base.trim_end_matches('/'), input.pattern)
86 } else {
87 let root = self.ctx.environment.root().replace('\\', "/");
88 format!("{}/{}", root.trim_end_matches('/'), input.pattern)
89 };
90
91 let search_path = input.path.as_ref().map_or_else(
93 || self.ctx.environment.root().to_string(),
94 |p| self.ctx.environment.resolve_path(p),
95 );
96
97 if let Err(reason) = self.ctx.capabilities.check_read(&search_path) {
98 return Ok(ToolResult::error(format!(
99 "Permission denied: cannot search in '{search_path}': {reason}"
100 )));
101 }
102
103 let matches = self
105 .ctx
106 .environment
107 .glob(&pattern)
108 .await
109 .context("Failed to execute glob")?;
110
111 let accessible_matches: Vec<_> = matches
113 .into_iter()
114 .filter(|path| self.ctx.capabilities.check_read(path).is_ok())
115 .collect();
116
117 if accessible_matches.is_empty() {
118 return Ok(ToolResult::success(format!(
119 "No files found matching pattern '{}'",
120 input.pattern
121 )));
122 }
123
124 let count = accessible_matches.len();
125 let output = if count > 100 {
126 format!(
127 "Found {count} files (showing first 100):\n{}",
128 accessible_matches[..100].join("\n")
129 )
130 } else {
131 format!("Found {count} files:\n{}", accessible_matches.join("\n"))
132 };
133
134 Ok(ToolResult::success(output))
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::{AgentCapabilities, InMemoryFileSystem};
142
143 fn create_test_tool(
144 fs: Arc<InMemoryFileSystem>,
145 capabilities: AgentCapabilities,
146 ) -> GlobTool<InMemoryFileSystem> {
147 GlobTool::new(fs, capabilities)
148 }
149
150 fn tool_ctx() -> ToolContext<()> {
151 ToolContext::new(())
152 }
153
154 #[tokio::test]
159 async fn test_glob_simple_pattern() -> anyhow::Result<()> {
160 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
161 fs.write_file("src/main.rs", "fn main() {}").await?;
162 fs.write_file("src/lib.rs", "pub mod foo;").await?;
163 fs.write_file("README.md", "# README").await?;
164
165 let tool = create_test_tool(fs, AgentCapabilities::full_access());
166 let result = tool
167 .execute(&tool_ctx(), json!({"pattern": "src/*.rs"}))
168 .await?;
169
170 assert!(result.success);
171 assert!(result.output.contains("Found 2 files"));
172 assert!(result.output.contains("main.rs"));
173 assert!(result.output.contains("lib.rs"));
174 Ok(())
175 }
176
177 #[tokio::test]
178 async fn test_glob_recursive_pattern() -> anyhow::Result<()> {
179 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
180 fs.write_file("src/main.rs", "fn main() {}").await?;
181 fs.write_file("src/lib/utils.rs", "pub fn util() {}")
182 .await?;
183 fs.write_file("tests/test.rs", "// test").await?;
184
185 let tool = create_test_tool(fs, AgentCapabilities::full_access());
186 let result = tool
187 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
188 .await?;
189
190 assert!(result.success);
191 assert!(result.output.contains("Found 3 files"));
192 Ok(())
193 }
194
195 #[tokio::test]
196 async fn test_glob_no_matches() -> anyhow::Result<()> {
197 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
198 fs.write_file("src/main.rs", "fn main() {}").await?;
199
200 let tool = create_test_tool(fs, AgentCapabilities::full_access());
201 let result = tool
202 .execute(&tool_ctx(), json!({"pattern": "*.py"}))
203 .await?;
204
205 assert!(result.success);
206 assert!(result.output.contains("No files found"));
207 Ok(())
208 }
209
210 #[tokio::test]
211 async fn test_glob_with_path() -> anyhow::Result<()> {
212 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
213 fs.write_file("src/main.rs", "fn main() {}").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(
219 &tool_ctx(),
220 json!({"pattern": "*.rs", "path": "/workspace/src"}),
221 )
222 .await?;
223
224 assert!(result.success);
225 assert!(result.output.contains("Found 1 files"));
226 assert!(result.output.contains("main.rs"));
227 Ok(())
228 }
229
230 #[tokio::test]
235 async fn test_glob_permission_denied() -> anyhow::Result<()> {
236 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
237 fs.write_file("src/main.rs", "fn main() {}").await?;
238
239 let caps = AgentCapabilities::none();
241
242 let tool = create_test_tool(fs, caps);
243 let result = tool
244 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
245 .await?;
246
247 assert!(!result.success);
248 assert!(result.output.contains("Permission denied"));
249 Ok(())
250 }
251
252 #[tokio::test]
253 async fn test_glob_filters_inaccessible_files() -> anyhow::Result<()> {
254 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
255 fs.write_file("src/main.rs", "fn main() {}").await?;
256 fs.write_file("secrets/key.rs", "// secret").await?;
257
258 let caps =
260 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
261
262 let tool = create_test_tool(fs, caps);
263 let result = tool
264 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
265 .await?;
266
267 assert!(result.success);
268 assert!(result.output.contains("Found 1 files"));
269 assert!(result.output.contains("main.rs"));
270 assert!(!result.output.contains("key.rs"));
271 Ok(())
272 }
273
274 #[tokio::test]
275 async fn test_glob_allowed_paths_restriction() -> anyhow::Result<()> {
276 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
277 fs.write_file("src/main.rs", "fn main() {}").await?;
278 fs.write_file("config/settings.toml", "key = value").await?;
279
280 let caps =
282 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
283
284 let tool = create_test_tool(fs, caps);
285
286 let result = tool
288 .execute(&tool_ctx(), json!({"pattern": "**/*"}))
289 .await?;
290
291 assert!(result.success);
292 assert!(result.output.contains("main.rs"));
293 assert!(!result.output.contains("settings.toml"));
294 Ok(())
295 }
296
297 #[tokio::test]
302 async fn test_glob_empty_directory() -> anyhow::Result<()> {
303 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
304 fs.create_dir("/workspace/empty").await?;
305
306 let tool = create_test_tool(fs, AgentCapabilities::full_access());
307 let result = tool
308 .execute(
309 &tool_ctx(),
310 json!({"pattern": "*", "path": "/workspace/empty"}),
311 )
312 .await?;
313
314 assert!(result.success);
315 assert!(result.output.contains("No files found"));
316 Ok(())
317 }
318
319 #[tokio::test]
320 async fn test_glob_many_files_truncated() -> anyhow::Result<()> {
321 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
322
323 for i in 0..150 {
325 fs.write_file(&format!("files/file{i}.txt"), "content")
326 .await?;
327 }
328
329 let tool = create_test_tool(fs, AgentCapabilities::full_access());
330 let result = tool
331 .execute(&tool_ctx(), json!({"pattern": "files/*.txt"}))
332 .await?;
333
334 assert!(result.success);
335 assert!(result.output.contains("Found 150 files"));
336 assert!(result.output.contains("showing first 100"));
337 Ok(())
338 }
339
340 #[tokio::test]
341 async fn test_glob_tool_metadata() {
342 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
343 let tool = create_test_tool(fs, AgentCapabilities::full_access());
344
345 assert_eq!(Tool::<()>::name(&tool), PrimitiveToolName::Glob);
346 assert_eq!(Tool::<()>::tier(&tool), ToolTier::Observe);
347 assert!(Tool::<()>::description(&tool).contains("glob"));
348
349 let schema = Tool::<()>::input_schema(&tool);
350 assert!(schema.get("properties").is_some());
351 assert!(schema["properties"].get("pattern").is_some());
352 assert!(schema["properties"].get("path").is_some());
353 }
354
355 #[tokio::test]
356 async fn test_glob_invalid_input() -> anyhow::Result<()> {
357 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
358 let tool = create_test_tool(fs, AgentCapabilities::full_access());
359
360 let result = tool.execute(&tool_ctx(), json!({})).await;
362 assert!(result.is_err());
363 Ok(())
364 }
365
366 #[tokio::test]
367 async fn test_glob_specific_file_extension() -> anyhow::Result<()> {
368 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
369 fs.write_file("main.rs", "fn main() {}").await?;
370 fs.write_file("main.go", "package main").await?;
371 fs.write_file("main.py", "def main(): pass").await?;
372
373 let tool = create_test_tool(fs, AgentCapabilities::full_access());
374 let result = tool
375 .execute(&tool_ctx(), json!({"pattern": "*.rs"}))
376 .await?;
377
378 assert!(result.success);
379 assert!(result.output.contains("Found 1 files"));
380 assert!(result.output.contains("main.rs"));
381 assert!(!result.output.contains("main.go"));
382 assert!(!result.output.contains("main.py"));
383 Ok(())
384 }
385}