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 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/// Typed arguments for [`FindTool`].
15#[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
34/// FindTool.
35pub struct FindTool {
36    root_dir: Option<PathBuf>,
37}
38
39impl FindTool {
40    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
41    pub fn new() -> Self {
42        Self { root_dir: None }
43    }
44
45    /// Create with a specific working directory (overrides ToolContext).
46    pub fn with_cwd(cwd: PathBuf) -> Self {
47        Self {
48            root_dir: Some(cwd),
49        }
50    }
51
52    /// Check if a filename matches a simple glob pattern
53    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                    // Handle patterns that can match the entire string
61                    // e.g., "*_test.txt" matches "test.txt"
62                    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                    // Multi-wildcard: simple sequential matching
72                    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    /// Check if a path matches any of the exclude patterns
104    fn matches_exclude(path: &Path, patterns: &[String]) -> bool {
105        let path_str = path.to_string_lossy();
106        for pattern in patterns {
107            // Try to match as glob pattern
108            if let Ok(glob) = Pattern::new(pattern) {
109                // Check full path match
110                if glob.matches(&path_str) {
111                    return true;
112                }
113                // Also check just the filename
114                if let Some(file_name) = path.file_name()
115                    && glob.matches(&file_name.to_string_lossy())
116                {
117                    return true;
118                }
119                // Check with directory prefix pattern (e.g., "node_modules")
120                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        // Security: validate path with PathGuard
140        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        // Check depth limit
190        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            // Skip hidden entries (unless explicitly excluded)
213            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            // Handle symlinks
223            let is_symlink = metadata.file_type().is_symlink();
224            let (is_dir, is_file) = if is_symlink && follow_symlinks {
225                // Follow symlink to determine actual type
226                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                // Don't follow symlinks - skip them
232                continue;
233            } else {
234                (metadata.is_dir(), metadata.is_file())
235            };
236
237            // Check exclude patterns
238            if Self::matches_exclude(&entry_path, exclude) {
239                // If it's a directory, skip descending into it
240                if is_dir {
241                    continue;
242                }
243                // If it's a file, skip it entirely
244                continue;
245            }
246
247            // Apply type filter
248            let type_match = match file_type {
249                Some("file") => is_file,
250                Some("dir" | "directory") => is_dir,
251                _ => true, // "all" or None
252            };
253
254            // Apply name filter
255            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            // Recurse into directories
270            if is_dir {
271                // Skip common non-searchable dirs unless excluded
272                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        // *_test.txt matches files ending with _test.txt
452        assert!(FindTool::matches_pattern("file_test.txt", "*_test.txt"));
453        assert!(FindTool::matches_pattern("my_test.txt", "*_test.txt"));
454        // test.txt does NOT match *_test.txt because it ends with .txt, not _test.txt
455        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}