agent_sdk/primitive_tools/
glob.rs

1use 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
10/// Tool for finding files by glob pattern
11pub 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    /// Glob pattern to match files (e.g., "**/*.rs", "src/*.ts")
27    pattern: String,
28    /// Optional directory to search in (defaults to environment root)
29    #[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        // Build the full pattern
69        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        // Check read capability for the search path
78        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        // Execute glob
90        let matches = self
91            .ctx
92            .environment
93            .glob(&pattern)
94            .await
95            .context("Failed to execute glob")?;
96
97        // Filter out files that the agent can't read
98        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    // ===================
141    // Unit Tests
142    // ===================
143
144    #[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    // ===================
217    // Integration Tests
218    // ===================
219
220    #[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        // No read permission
226        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        // Allow src but deny secrets
245        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        // Full access with denied paths for config
267        let caps =
268            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
269
270        let tool = create_test_tool(fs, caps);
271
272        // Searching should return src files but not config
273        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    // ===================
284    // Edge Cases
285    // ===================
286
287    #[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        // Create 150 files
310        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        // Missing required pattern field
347        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}