1use super::path_security::PathGuard;
2use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
4use async_trait::async_trait;
5use glob::Pattern;
6use serde_json::{json, Value};
7use std::path::{Path, PathBuf};
8use tokio::fs;
9use tokio::sync::oneshot;
10
11pub struct FindTool {
13 root_dir: Option<PathBuf>,
14}
15
16impl FindTool {
17 pub fn new() -> Self {
19 Self { root_dir: None }
20 }
21
22 pub fn with_cwd(cwd: PathBuf) -> Self {
24 Self {
25 root_dir: Some(cwd),
26 }
27 }
28
29 fn matches_pattern(file_name: &str, pattern: &str) -> bool {
31 if pattern.contains('*') {
32 let parts: Vec<&str> = pattern.split('*').collect();
33 match parts.len() {
34 1 => file_name == parts[0],
35 2 => {
36 let (prefix, suffix) = (parts[0], parts[1]);
37 if prefix.is_empty() {
40 file_name.ends_with(suffix)
41 } else if suffix.is_empty() {
42 file_name.starts_with(prefix)
43 } else {
44 file_name.starts_with(prefix) && file_name.ends_with(suffix)
45 }
46 }
47 _ => {
48 let mut idx = 0;
50 for (i, part) in parts.iter().enumerate() {
51 if part.is_empty() {
52 continue;
53 }
54 match file_name[idx..].find(part) {
55 Some(pos) => {
56 if i == 0 && pos != 0 {
57 return false;
58 }
59 idx += pos + part.len();
60 }
61 None => return false,
62 }
63 }
64 if let Some(last) = parts.last() {
65 if !last.is_empty() {
66 file_name.ends_with(last)
67 } else {
68 true
69 }
70 } else {
71 true
72 }
73 }
74 }
75 } else {
76 file_name == pattern
77 }
78 }
79
80 fn matches_exclude(path: &Path, patterns: &[String]) -> bool {
82 let path_str = path.to_string_lossy();
83 for pattern in patterns {
84 if let Ok(glob) = Pattern::new(pattern) {
86 if glob.matches(&path_str) {
88 return true;
89 }
90 if let Some(file_name) = path.file_name() {
92 if glob.matches(&file_name.to_string_lossy()) {
93 return true;
94 }
95 }
96 if path_str.contains(pattern) {
98 return true;
99 }
100 }
101 }
102 false
103 }
104
105 #[allow(clippy::too_many_arguments)]
106 async fn find_impl(
107 root_dir: &Path,
108 path: &str,
109 name: Option<&str>,
110 file_type: Option<&str>,
111 max_depth: Option<usize>,
112 max_results: usize,
113 exclude: &[String],
114 follow_symlinks: bool,
115 ) -> Result<String, ToolError> {
116 let guard = PathGuard::new(root_dir);
118 let root = guard
119 .validate_traversal(Path::new(path))
120 .map_err(|e| e.to_string())?;
121
122 if !root.is_dir() {
123 return Err(format!("Path is not a directory: {}", path));
124 }
125
126 let mut results: Vec<String> = Vec::new();
127 Self::find_walk(
128 &root,
129 &root,
130 name,
131 file_type,
132 max_depth,
133 0,
134 &mut results,
135 max_results,
136 exclude,
137 follow_symlinks,
138 )
139 .await?;
140
141 if results.is_empty() {
142 Ok("No files found".to_string())
143 } else {
144 let header = format!("Found {} results:\n", results.len());
145 Ok(header + &results.join("\n"))
146 }
147 }
148
149 #[allow(clippy::too_many_arguments)]
150 async fn find_walk(
151 root: &Path,
152 current: &Path,
153 name: Option<&str>,
154 file_type: Option<&str>,
155 max_depth: Option<usize>,
156 current_depth: usize,
157 results: &mut Vec<String>,
158 max_results: usize,
159 exclude: &[String],
160 follow_symlinks: bool,
161 ) -> Result<(), ToolError> {
162 if results.len() >= max_results {
163 return Ok(());
164 }
165
166 if let Some(max) = max_depth {
168 if current_depth > max {
169 return Ok(());
170 }
171 }
172
173 let mut entries = fs::read_dir(current)
174 .await
175 .map_err(|e| format!("Cannot read directory {}: {}", current.display(), e))?;
176
177 while let Some(entry) = entries
178 .next_entry()
179 .await
180 .map_err(|e| format!("Error reading entry: {}", e))?
181 {
182 if results.len() >= max_results {
183 return Ok(());
184 }
185
186 let entry_path = entry.path();
187 let file_name = entry.file_name().to_string_lossy().to_string();
188
189 if file_name.starts_with('.') {
191 continue;
192 }
193
194 let metadata = entry
195 .metadata()
196 .await
197 .map_err(|e| format!("Cannot read metadata: {}", e))?;
198
199 let is_symlink = metadata.file_type().is_symlink();
201 let (is_dir, is_file) = if is_symlink && follow_symlinks {
202 match fs::metadata(&entry_path).await {
204 Ok(meta) => (meta.is_dir(), meta.is_file()),
205 Err(_) => (false, metadata.is_file()),
206 }
207 } else if is_symlink {
208 continue;
210 } else {
211 (metadata.is_dir(), metadata.is_file())
212 };
213
214 if Self::matches_exclude(&entry_path, exclude) {
216 if is_dir {
218 continue;
219 }
220 continue;
222 }
223
224 let type_match = match file_type {
226 Some("file") => is_file,
227 Some("dir" | "directory") => is_dir,
228 _ => true, };
230
231 let name_match = match name {
233 Some(pattern) => Self::matches_pattern(&file_name, pattern),
234 None => true,
235 };
236
237 if type_match && name_match {
238 let relative = entry_path
239 .strip_prefix(root)
240 .unwrap_or(&entry_path)
241 .display();
242 let suffix = if is_dir { "/" } else { "" };
243 results.push(format!("{}{}", relative, suffix));
244 }
245
246 if is_dir {
248 if matches!(
250 file_name.as_str(),
251 "node_modules"
252 | "target"
253 | ".git"
254 | "dist"
255 | "build"
256 | "__pycache__"
257 | ".venv"
258 | "venv"
259 ) && !Self::matches_exclude(&entry_path, exclude)
260 {
261 continue;
262 }
263
264 Box::pin(Self::find_walk(
265 root,
266 &entry_path,
267 name,
268 file_type,
269 max_depth,
270 current_depth + 1,
271 results,
272 max_results,
273 exclude,
274 follow_symlinks,
275 ))
276 .await?;
277 }
278 }
279
280 Ok(())
281 }
282}
283
284impl Default for FindTool {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290#[async_trait]
291impl AgentTool for FindTool {
292 fn name(&self) -> &str {
293 "find"
294 }
295
296 fn label(&self) -> &str {
297 "Find"
298 }
299
300 fn essential(&self) -> bool {
301 true
302 }
303 fn description(&self) -> &str {
304 "Find files and directories by name pattern and type. Searches recursively from the given path."
305 }
306
307 fn parameters_schema(&self) -> Value {
308 json!({
309 "type": "object",
310 "properties": {
311 "path": {
312 "type": "string",
313 "description": "The directory to search in",
314 "default": "."
315 },
316 "name": {
317 "type": "string",
318 "description": "Glob pattern to match file names (e.g., '*.rs', 'test_*.py')"
319 },
320 "type": {
321 "type": "string",
322 "description": "Filter by type: 'file', 'dir', or 'all'",
323 "enum": ["file", "dir", "all"],
324 "default": "all"
325 },
326 "max_depth": {
327 "type": "integer",
328 "description": "Maximum directory depth to search"
329 },
330 "max_results": {
331 "type": "integer",
332 "description": "Maximum number of results to return",
333 "default": 100
334 },
335 "exclude": {
336 "type": "array",
337 "items": {
338 "type": "string"
339 },
340 "description": "Array of glob patterns to exclude (e.g., ['*.log', 'temp/**', '.git'])",
341 "default": []
342 },
343 "follow_symlinks": {
344 "type": "boolean",
345 "description": "Whether to follow symbolic links",
346 "default": false
347 }
348 },
349 "required": ["path"]
350 })
351 }
352
353 async fn execute(
354 &self,
355 _tool_call_id: &str,
356 params: Value,
357 _signal: Option<oneshot::Receiver<()>>,
358 ctx: &ToolContext,
359 ) -> Result<AgentToolResult, ToolError> {
360 let path = params
361 .get("path")
362 .and_then(|v: &Value| v.as_str())
363 .ok_or_else(|| "Missing required parameter: path".to_string())?;
364
365 let name = params.get("name").and_then(|v: &Value| v.as_str());
366 let file_type = params.get("type").and_then(|v: &Value| v.as_str());
367 let max_depth = params
368 .get("max_depth")
369 .and_then(|v: &Value| v.as_u64())
370 .map(|d| d as usize);
371 let max_results = params
372 .get("max_results")
373 .and_then(|v: &Value| v.as_u64())
374 .unwrap_or(100) as usize;
375
376 let exclude: Vec<String> = params
378 .get("exclude")
379 .and_then(|v: &Value| v.as_array())
380 .map(|arr| {
381 arr.iter()
382 .filter_map(|v| v.as_str().map(String::from))
383 .collect()
384 })
385 .unwrap_or_default();
386
387 let follow_symlinks = params
388 .get("follow_symlinks")
389 .and_then(|v: &Value| v.as_bool())
390 .unwrap_or(false);
391
392 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
394
395 match Self::find_impl(
396 root,
397 path,
398 name,
399 file_type,
400 max_depth,
401 max_results,
402 &exclude,
403 follow_symlinks,
404 )
405 .await
406 {
407 Ok(output) => Ok(AgentToolResult::success(output)),
408 Err(e) => Ok(AgentToolResult::error(e)),
409 }
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_matches_pattern_simple() {
419 assert!(FindTool::matches_pattern("test.rs", "test.rs"));
420 assert!(!FindTool::matches_pattern("test.txt", "test.rs"));
421 }
422
423 #[test]
424 fn test_matches_pattern_single_wildcard() {
425 assert!(FindTool::matches_pattern("test.rs", "*.rs"));
426 assert!(FindTool::matches_pattern("example.txt", "*.txt"));
427 assert!(!FindTool::matches_pattern("test.rs", "*.txt"));
428 }
429
430 #[test]
431 fn test_matches_pattern_prefix() {
432 assert!(FindTool::matches_pattern("test_file.rs", "test_*"));
433 assert!(FindTool::matches_pattern("test_file", "test_*"));
434 }
435
436 #[test]
437 fn test_matches_pattern_suffix() {
438 assert!(FindTool::matches_pattern("file_test.txt", "*_test.txt"));
440 assert!(FindTool::matches_pattern("my_test.txt", "*_test.txt"));
441 assert!(!FindTool::matches_pattern("test.txt", "*_test.txt"));
443 }
444
445 #[test]
446 fn test_matches_pattern_multi_wildcard() {
447 assert!(FindTool::matches_pattern(
448 "test_file_backup.txt",
449 "test*backup.txt"
450 ));
451 assert!(FindTool::matches_pattern(
452 "abcxyzbackup.txt",
453 "abc*xyz*backup.txt"
454 ));
455 }
456
457 #[test]
458 fn test_matches_exclude() {
459 let patterns = vec![
460 "*.log".to_string(),
461 "*.tmp".to_string(),
462 "node_modules".to_string(),
463 ];
464
465 let path = Path::new("debug.log");
466 assert!(FindTool::matches_exclude(path, &patterns));
467
468 let path = Path::new("temp.tmp");
469 assert!(FindTool::matches_exclude(path, &patterns));
470
471 let path = Path::new("/path/to/node_modules/file.txt");
472 assert!(FindTool::matches_exclude(path, &patterns));
473
474 let path = Path::new("source.rs");
475 assert!(!FindTool::matches_exclude(path, &patterns));
476 }
477}