Skip to main content

oxi_agent/tools/
find.rs

1use super::path_security::PathGuard;
2/// Find tool - find files by name or pattern
3use 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
11/// FindTool.
12pub struct FindTool {
13    root_dir: Option<PathBuf>,
14}
15
16impl FindTool {
17    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
18    pub fn new() -> Self {
19        Self { root_dir: None }
20    }
21
22    /// Create with a specific working directory (overrides ToolContext).
23    pub fn with_cwd(cwd: PathBuf) -> Self {
24        Self {
25            root_dir: Some(cwd),
26        }
27    }
28
29    /// Check if a filename matches a simple glob pattern
30    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                    // Handle patterns that can match the entire string
38                    // e.g., "*_test.txt" matches "test.txt"
39                    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                    // Multi-wildcard: simple sequential matching
49                    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    /// Check if a path matches any of the exclude patterns
81    fn matches_exclude(path: &Path, patterns: &[String]) -> bool {
82        let path_str = path.to_string_lossy();
83        for pattern in patterns {
84            // Try to match as glob pattern
85            if let Ok(glob) = Pattern::new(pattern) {
86                // Check full path match
87                if glob.matches(&path_str) {
88                    return true;
89                }
90                // Also check just the filename
91                if let Some(file_name) = path.file_name() {
92                    if glob.matches(&file_name.to_string_lossy()) {
93                        return true;
94                    }
95                }
96                // Check with directory prefix pattern (e.g., "node_modules")
97                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        // Security: validate path with PathGuard
117        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        // Check depth limit
167        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            // Skip hidden entries (unless explicitly excluded)
190            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            // Handle symlinks
200            let is_symlink = metadata.file_type().is_symlink();
201            let (is_dir, is_file) = if is_symlink && follow_symlinks {
202                // Follow symlink to determine actual type
203                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                // Don't follow symlinks - skip them
209                continue;
210            } else {
211                (metadata.is_dir(), metadata.is_file())
212            };
213
214            // Check exclude patterns
215            if Self::matches_exclude(&entry_path, exclude) {
216                // If it's a directory, skip descending into it
217                if is_dir {
218                    continue;
219                }
220                // If it's a file, skip it entirely
221                continue;
222            }
223
224            // Apply type filter
225            let type_match = match file_type {
226                Some("file") => is_file,
227                Some("dir" | "directory") => is_dir,
228                _ => true, // "all" or None
229            };
230
231            // Apply name filter
232            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            // Recurse into directories
247            if is_dir {
248                // Skip common non-searchable dirs unless excluded
249                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        // Parse exclude patterns
377        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        // Use root_dir if set, else ctx.root()
393        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        // *_test.txt matches files ending with _test.txt
439        assert!(FindTool::matches_pattern("file_test.txt", "*_test.txt"));
440        assert!(FindTool::matches_pattern("my_test.txt", "*_test.txt"));
441        // test.txt does NOT match *_test.txt because it ends with .txt, not _test.txt
442        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}