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> Tool<()> 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<()>, 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 {
74 let base = self.ctx.environment.resolve_path(base_path);
75 format!("{}/{}", base.trim_end_matches('/'), input.pattern)
76 } else {
77 let root = self.ctx.environment.root();
78 format!("{}/{}", root.trim_end_matches('/'), input.pattern)
79 };
80
81 let search_path = input.path.as_ref().map_or_else(
83 || self.ctx.environment.root().to_string(),
84 |p| self.ctx.environment.resolve_path(p),
85 );
86
87 if !self.ctx.capabilities.can_read(&search_path) {
88 return Ok(ToolResult::error(format!(
89 "Permission denied: cannot search in '{search_path}'"
90 )));
91 }
92
93 let matches = self
95 .ctx
96 .environment
97 .glob(&pattern)
98 .await
99 .context("Failed to execute glob")?;
100
101 let accessible_matches: Vec<_> = matches
103 .into_iter()
104 .filter(|path| self.ctx.capabilities.can_read(path))
105 .collect();
106
107 if accessible_matches.is_empty() {
108 return Ok(ToolResult::success(format!(
109 "No files found matching pattern '{}'",
110 input.pattern
111 )));
112 }
113
114 let count = accessible_matches.len();
115 let output = if count > 100 {
116 format!(
117 "Found {count} files (showing first 100):\n{}",
118 accessible_matches[..100].join("\n")
119 )
120 } else {
121 format!("Found {count} files:\n{}", accessible_matches.join("\n"))
122 };
123
124 Ok(ToolResult::success(output))
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::{AgentCapabilities, InMemoryFileSystem};
132
133 fn create_test_tool(
134 fs: Arc<InMemoryFileSystem>,
135 capabilities: AgentCapabilities,
136 ) -> GlobTool<InMemoryFileSystem> {
137 GlobTool::new(fs, capabilities)
138 }
139
140 fn tool_ctx() -> ToolContext<()> {
141 ToolContext::new(())
142 }
143
144 #[tokio::test]
149 async fn test_glob_simple_pattern() -> anyhow::Result<()> {
150 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
151 fs.write_file("src/main.rs", "fn main() {}").await?;
152 fs.write_file("src/lib.rs", "pub mod foo;").await?;
153 fs.write_file("README.md", "# README").await?;
154
155 let tool = create_test_tool(fs, AgentCapabilities::full_access());
156 let result = tool
157 .execute(&tool_ctx(), json!({"pattern": "src/*.rs"}))
158 .await?;
159
160 assert!(result.success);
161 assert!(result.output.contains("Found 2 files"));
162 assert!(result.output.contains("main.rs"));
163 assert!(result.output.contains("lib.rs"));
164 Ok(())
165 }
166
167 #[tokio::test]
168 async fn test_glob_recursive_pattern() -> anyhow::Result<()> {
169 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
170 fs.write_file("src/main.rs", "fn main() {}").await?;
171 fs.write_file("src/lib/utils.rs", "pub fn util() {}")
172 .await?;
173 fs.write_file("tests/test.rs", "// test").await?;
174
175 let tool = create_test_tool(fs, AgentCapabilities::full_access());
176 let result = tool
177 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
178 .await?;
179
180 assert!(result.success);
181 assert!(result.output.contains("Found 3 files"));
182 Ok(())
183 }
184
185 #[tokio::test]
186 async fn test_glob_no_matches() -> anyhow::Result<()> {
187 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
188 fs.write_file("src/main.rs", "fn main() {}").await?;
189
190 let tool = create_test_tool(fs, AgentCapabilities::full_access());
191 let result = tool
192 .execute(&tool_ctx(), json!({"pattern": "*.py"}))
193 .await?;
194
195 assert!(result.success);
196 assert!(result.output.contains("No files found"));
197 Ok(())
198 }
199
200 #[tokio::test]
201 async fn test_glob_with_path() -> anyhow::Result<()> {
202 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
203 fs.write_file("src/main.rs", "fn main() {}").await?;
204 fs.write_file("tests/test.rs", "// test").await?;
205
206 let tool = create_test_tool(fs, AgentCapabilities::full_access());
207 let result = tool
208 .execute(
209 &tool_ctx(),
210 json!({"pattern": "*.rs", "path": "/workspace/src"}),
211 )
212 .await?;
213
214 assert!(result.success);
215 assert!(result.output.contains("Found 1 files"));
216 assert!(result.output.contains("main.rs"));
217 Ok(())
218 }
219
220 #[tokio::test]
225 async fn test_glob_permission_denied() -> anyhow::Result<()> {
226 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
227 fs.write_file("src/main.rs", "fn main() {}").await?;
228
229 let caps = AgentCapabilities::none();
231
232 let tool = create_test_tool(fs, caps);
233 let result = tool
234 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
235 .await?;
236
237 assert!(!result.success);
238 assert!(result.output.contains("Permission denied"));
239 Ok(())
240 }
241
242 #[tokio::test]
243 async fn test_glob_filters_inaccessible_files() -> anyhow::Result<()> {
244 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
245 fs.write_file("src/main.rs", "fn main() {}").await?;
246 fs.write_file("secrets/key.rs", "// secret").await?;
247
248 let caps =
250 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
251
252 let tool = create_test_tool(fs, caps);
253 let result = tool
254 .execute(&tool_ctx(), json!({"pattern": "**/*.rs"}))
255 .await?;
256
257 assert!(result.success);
258 assert!(result.output.contains("Found 1 files"));
259 assert!(result.output.contains("main.rs"));
260 assert!(!result.output.contains("key.rs"));
261 Ok(())
262 }
263
264 #[tokio::test]
265 async fn test_glob_allowed_paths_restriction() -> anyhow::Result<()> {
266 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
267 fs.write_file("src/main.rs", "fn main() {}").await?;
268 fs.write_file("config/settings.toml", "key = value").await?;
269
270 let caps =
272 AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
273
274 let tool = create_test_tool(fs, caps);
275
276 let result = tool
278 .execute(&tool_ctx(), json!({"pattern": "**/*"}))
279 .await?;
280
281 assert!(result.success);
282 assert!(result.output.contains("main.rs"));
283 assert!(!result.output.contains("settings.toml"));
284 Ok(())
285 }
286
287 #[tokio::test]
292 async fn test_glob_empty_directory() -> anyhow::Result<()> {
293 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
294 fs.create_dir("/workspace/empty").await?;
295
296 let tool = create_test_tool(fs, AgentCapabilities::full_access());
297 let result = tool
298 .execute(
299 &tool_ctx(),
300 json!({"pattern": "*", "path": "/workspace/empty"}),
301 )
302 .await?;
303
304 assert!(result.success);
305 assert!(result.output.contains("No files found"));
306 Ok(())
307 }
308
309 #[tokio::test]
310 async fn test_glob_many_files_truncated() -> anyhow::Result<()> {
311 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
312
313 for i in 0..150 {
315 fs.write_file(&format!("files/file{i}.txt"), "content")
316 .await?;
317 }
318
319 let tool = create_test_tool(fs, AgentCapabilities::full_access());
320 let result = tool
321 .execute(&tool_ctx(), json!({"pattern": "files/*.txt"}))
322 .await?;
323
324 assert!(result.success);
325 assert!(result.output.contains("Found 150 files"));
326 assert!(result.output.contains("showing first 100"));
327 Ok(())
328 }
329
330 #[tokio::test]
331 async fn test_glob_tool_metadata() {
332 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
333 let tool = create_test_tool(fs, AgentCapabilities::full_access());
334
335 assert_eq!(tool.name(), PrimitiveToolName::Glob);
336 assert_eq!(tool.tier(), ToolTier::Observe);
337 assert!(tool.description().contains("glob"));
338
339 let schema = tool.input_schema();
340 assert!(schema.get("properties").is_some());
341 assert!(schema["properties"].get("pattern").is_some());
342 assert!(schema["properties"].get("path").is_some());
343 }
344
345 #[tokio::test]
346 async fn test_glob_invalid_input() -> anyhow::Result<()> {
347 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
348 let tool = create_test_tool(fs, AgentCapabilities::full_access());
349
350 let result = tool.execute(&tool_ctx(), json!({})).await;
352 assert!(result.is_err());
353 Ok(())
354 }
355
356 #[tokio::test]
357 async fn test_glob_specific_file_extension() -> anyhow::Result<()> {
358 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
359 fs.write_file("main.rs", "fn main() {}").await?;
360 fs.write_file("main.go", "package main").await?;
361 fs.write_file("main.py", "def main(): pass").await?;
362
363 let tool = create_test_tool(fs, AgentCapabilities::full_access());
364 let result = tool
365 .execute(&tool_ctx(), json!({"pattern": "*.rs"}))
366 .await?;
367
368 assert!(result.success);
369 assert!(result.output.contains("Found 1 files"));
370 assert!(result.output.contains("main.rs"));
371 assert!(!result.output.contains("main.go"));
372 assert!(!result.output.contains("main.py"));
373 Ok(())
374 }
375}