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, 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        // Build the full pattern. The base directory is a literal filesystem
73        // path, but `glob` uses `/` as its separator on every platform and
74        // treats `\` as an escape character — so a Windows base such as
75        // `C:\Users\…` would be parsed as escape sequences. Normalise the
76        // base's separators to `/` before joining (a no-op on Unix, where the
77        // base never contains a backslash); the user's `pattern` is left as-is
78        // so its glob metacharacters keep their meaning.
79        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        // Check read capability for the search path
92        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        // Execute glob
104        let matches = self
105            .ctx
106            .environment
107            .glob(&pattern)
108            .await
109            .context("Failed to execute glob")?;
110
111        // Filter out files that the agent can't read
112        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    // ===================
155    // Unit Tests
156    // ===================
157
158    #[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    // ===================
231    // Integration Tests
232    // ===================
233
234    #[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        // No read permission
240        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        // Allow src but deny secrets
259        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        // Full access with denied paths for config
281        let caps =
282            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/config/**".into()]);
283
284        let tool = create_test_tool(fs, caps);
285
286        // Searching should return src files but not config
287        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    // ===================
298    // Edge Cases
299    // ===================
300
301    #[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        // Create 150 files
324        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        // Missing required pattern field
361        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}