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}