Skip to main content

agent_sdk/primitive_tools/
glob.rs

1use 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
9/// Tool for finding files by glob pattern
10pub 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    /// Glob pattern to match files (e.g., "**/*.rs", "src/*.ts")
26    pattern: String,
27    /// Optional directory to search in (defaults to environment root)
28    #[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        // Build the full pattern
73        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        // Check read capability for the search path
82        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        // Execute glob
94        let matches = self
95            .ctx
96            .environment
97            .glob(&pattern)
98            .await
99            .context("Failed to execute glob")?;
100
101        // Filter out files that the agent can't read
102        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    // ===================
145    // Unit Tests
146    // ===================
147
148    #[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    // ===================
221    // Integration Tests
222    // ===================
223
224    #[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        // No read permission
230        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        // Allow src but deny secrets
249        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        // Full access with denied paths for config
271        let caps =
272            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
273
274        let tool = create_test_tool(fs, caps);
275
276        // Searching should return src files but not config
277        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    // ===================
288    // Edge Cases
289    // ===================
290
291    #[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        // Create 150 files
314        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        // Missing required pattern field
351        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}