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