Skip to main content

capo_agent/tools/
find.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use globset::Glob;
8use ignore::WalkBuilder;
9use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
10use serde_json::{json, Value};
11
12use crate::tools::ls::resolve_in_cwd;
13use crate::tools::ToolCtx;
14
15const MAX_RESULTS: usize = 1000;
16
17pub struct FindTool {
18    ctx: Arc<ToolCtx>,
19}
20
21impl FindTool {
22    pub fn new(ctx: Arc<ToolCtx>) -> Self {
23        Self { ctx }
24    }
25}
26
27impl Tool for FindTool {
28    fn def(&self) -> ToolDef {
29        ToolDef {
30            name: "find".to_string(),
31            description: "List files or directories under a path, optionally filtered by a glob pattern. Respects .gitignore.".to_string(),
32            input_schema: json!({
33                "type": "object",
34                "properties": {
35                    "pattern": { "type": "string", "description": "Glob matched against the file name, e.g. `*.rs`. Defaults to all." },
36                    "path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
37                    "type": { "type": "string", "enum": ["file", "dir", "any"], "description": "Entry kind filter. Defaults to `any`." }
38                }
39            }),
40        }
41    }
42
43    fn call(
44        &self,
45        args: Value,
46        _ctx: &ToolContext,
47    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
48        let ctx = Arc::clone(&self.ctx);
49        Box::pin(async move {
50            let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
51            let root = resolve_in_cwd(&ctx.cwd, rel);
52            if !root.starts_with(&ctx.cwd) {
53                return ToolResult::error(format!(
54                    "path {} is outside the working directory",
55                    root.display()
56                ));
57            }
58            let kind = args
59                .get("type")
60                .and_then(|v| v.as_str())
61                .unwrap_or("any")
62                .to_string();
63            let matcher = match args.get("pattern").and_then(|v| v.as_str()) {
64                Some(p) => match Glob::new(p) {
65                    Ok(g) => Some(g.compile_matcher()),
66                    Err(e) => return ToolResult::error(format!("invalid glob `{p}`: {e}")),
67                },
68                None => None,
69            };
70
71            let cwd = ctx.cwd.clone();
72            let listing = tokio::task::spawn_blocking(move || {
73                let mut out: Vec<String> = Vec::new();
74                for entry in WalkBuilder::new(&root).build().flatten() {
75                    if out.len() >= MAX_RESULTS {
76                        out.push(format!("... (truncated at {MAX_RESULTS} entries)"));
77                        break;
78                    }
79                    let path = entry.path();
80                    if path == root {
81                        continue;
82                    }
83                    let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
84                    match kind.as_str() {
85                        "file" if is_dir => continue,
86                        "dir" if !is_dir => continue,
87                        _ => {}
88                    }
89                    if let Some(m) = &matcher {
90                        let name = path.file_name().unwrap_or_default();
91                        if !m.is_match(name) {
92                            continue;
93                        }
94                    }
95                    let display = path.strip_prefix(&cwd).unwrap_or(path);
96                    out.push(display.display().to_string());
97                }
98                out
99            })
100            .await;
101
102            match listing {
103                Ok(mut lines) => {
104                    lines.sort();
105                    ToolResult::text(lines.join("\n"))
106                }
107                Err(e) => ToolResult::error(format!("find walk failed: {e}")),
108            }
109        })
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::permissions::NoOpPermissionGate;
117    use tempfile::tempdir;
118    use tokio::sync::mpsc;
119
120    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
121        let (tx, _rx) = mpsc::channel(8);
122        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
123    }
124
125    #[tokio::test]
126    async fn finds_files_by_glob() {
127        let dir = tempdir().expect("tempdir");
128        tokio::fs::write(dir.path().join("a.rs"), "x")
129            .await
130            .unwrap();
131        tokio::fs::write(dir.path().join("b.rs"), "x")
132            .await
133            .unwrap();
134        tokio::fs::write(dir.path().join("c.txt"), "x")
135            .await
136            .unwrap();
137
138        let tool = FindTool::new(test_ctx(dir.path()));
139        let result = tool
140            .call(json!({ "pattern": "*.rs" }), &ToolContext::default())
141            .await;
142        let text = result.as_text().unwrap_or_default();
143        assert!(text.contains("a.rs"));
144        assert!(text.contains("b.rs"));
145        assert!(!text.contains("c.txt"));
146    }
147
148    #[tokio::test]
149    async fn type_dir_lists_only_directories() {
150        let dir = tempdir().expect("tempdir");
151        tokio::fs::write(dir.path().join("file.txt"), "x")
152            .await
153            .unwrap();
154        tokio::fs::create_dir(dir.path().join("subdir"))
155            .await
156            .unwrap();
157
158        let tool = FindTool::new(test_ctx(dir.path()));
159        let result = tool
160            .call(json!({ "type": "dir" }), &ToolContext::default())
161            .await;
162        let text = result.as_text().unwrap_or_default();
163        assert!(text.contains("subdir"));
164        assert!(!text.contains("file.txt"));
165    }
166
167    #[tokio::test]
168    async fn respects_gitignore() {
169        let dir = tempdir().expect("tempdir");
170        tokio::fs::create_dir(dir.path().join(".git"))
171            .await
172            .unwrap();
173        tokio::fs::write(dir.path().join(".gitignore"), "ignored.txt\n")
174            .await
175            .unwrap();
176        tokio::fs::write(dir.path().join("ignored.txt"), "x")
177            .await
178            .unwrap();
179        tokio::fs::write(dir.path().join("kept.txt"), "x")
180            .await
181            .unwrap();
182
183        let tool = FindTool::new(test_ctx(dir.path()));
184        let result = tool.call(json!({}), &ToolContext::default()).await;
185        let text = result.as_text().unwrap_or_default();
186        assert!(text.contains("kept.txt"));
187        assert!(
188            !text.contains("ignored.txt"),
189            "gitignored file leaked: {text}"
190        );
191    }
192
193    #[tokio::test]
194    async fn rejects_path_outside_cwd() {
195        let dir = tempdir().expect("tempdir");
196        let tool = FindTool::new(test_ctx(dir.path()));
197        let result = tool
198            .call(json!({ "path": "../.." }), &ToolContext::default())
199            .await;
200        assert!(result.is_error);
201    }
202
203    #[tokio::test]
204    async fn invalid_glob_errors() {
205        let dir = tempdir().expect("tempdir");
206        let tool = FindTool::new(test_ctx(dir.path()));
207        let result = tool
208            .call(json!({ "pattern": "[" }), &ToolContext::default())
209            .await;
210        assert!(result.is_error);
211    }
212}