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