Skip to main content

robit_agent/tool/
find.rs

1//! `find` tool — searches for files matching a pattern.
2
3use async_trait::async_trait;
4use globset::{GlobBuilder, GlobSetBuilder};
5use serde::Deserialize;
6use serde_json::Value;
7
8use super::{resolve_path, Tool, ToolContext, ToolResult};
9use crate::error::Result;
10
11pub struct FindTool {
12    /// Max output bytes before truncation.
13    max_output_bytes: usize,
14}
15
16#[derive(Debug, Deserialize)]
17struct FindArgs {
18    pattern: String,
19    dir_path: Option<String>,
20    #[serde(default)]
21    file_only: bool,
22    #[serde(default)]
23    dir_only: bool,
24    #[serde(default)]
25    ignore_case: bool,
26}
27
28impl FindTool {
29    pub fn new(max_output_bytes: usize) -> Self {
30        Self { max_output_bytes }
31    }
32}
33
34#[async_trait]
35impl Tool for FindTool {
36    fn name(&self) -> &str {
37        "find"
38    }
39
40    fn description(&self) -> &str {
41        "Search for files/directories matching a pattern. Supports glob patterns: * matches any characters, ? matches single character, ** matches recursive directories."
42    }
43
44    fn parameters_schema(&self) -> Value {
45        serde_json::json!({
46            "type": "object",
47            "properties": {
48                "pattern": {
49                    "type": "string",
50                    "description": "Search pattern (glob format), e.g. *.rs, src/**/*.ts, test?.*"
51                },
52                "dir_path": {
53                    "type": "string",
54                    "description": "Directory to search (optional, defaults to current directory)"
55                },
56                "file_only": {
57                    "type": "boolean",
58                    "description": "Only search for files, default false"
59                },
60                "dir_only": {
61                    "type": "boolean",
62                    "description": "Only search for directories, default false"
63                },
64                "ignore_case": {
65                    "type": "boolean",
66                    "description": "Case-insensitive search, default false"
67                }
68            },
69            "required": ["pattern"]
70        })
71    }
72
73    fn requires_confirmation(&self) -> bool {
74        false
75    }
76
77    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
78        let parsed: FindArgs = match serde_json::from_value(args) {
79            Ok(a) => a,
80            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
81        };
82
83        // Resolve directory path
84        let search_dir = match parsed.dir_path {
85            Some(p) => resolve_path(&p, &ctx.working_dir),
86            None => ctx.working_dir.clone(),
87        };
88
89        // Check if search directory exists
90        if !search_dir.exists() {
91            return Ok(ToolResult::error(format!("Search directory not found: {}", search_dir.display())));
92        }
93        if !search_dir.is_dir() {
94            return Ok(ToolResult::error(format!("'{}' is not a directory", search_dir.display())));
95        }
96
97        // Build glob pattern
98        let glob = match GlobBuilder::new(&parsed.pattern)
99            .case_insensitive(parsed.ignore_case)
100            .build() {
101            Ok(g) => g,
102            Err(e) => {
103                return Ok(ToolResult::error(format!("Invalid glob pattern '{}': {}", parsed.pattern, e)));
104            }
105        };
106
107        let glob_set = {
108            let mut builder = GlobSetBuilder::new();
109            builder.add(glob);
110            match builder.build() {
111                Ok(g) => g,
112                Err(e) => {
113                    return Ok(ToolResult::error(format!("Invalid glob pattern '{}': {}", parsed.pattern, e)));
114                }
115            }
116        };
117
118        // Perform walk
119        let mut matches = Vec::new();
120        let mut walk_dir = vec![search_dir.clone()];
121
122        while let Some(dir) = walk_dir.pop() {
123            let mut entries = match tokio::fs::read_dir(&dir).await {
124                Ok(e) => e,
125                Err(_) => continue,
126            };
127
128            loop {
129                let entry = match entries.next_entry().await {
130                    Ok(Some(e)) => e,
131                    Ok(None) => break,
132                    Err(_) => continue,
133                };
134                let path = entry.path();
135                let rel_path = match path.strip_prefix(&ctx.working_dir) {
136                    Ok(p) => p.to_path_buf(),
137                    Err(_) => path.clone(),
138                };
139
140                let is_dir = path.is_dir();
141                let is_file = path.is_file();
142
143                // Check file/dir filters
144                if parsed.file_only && !is_file {
145                    continue;
146                }
147                if parsed.dir_only && !is_dir {
148                    continue;
149                }
150
151                // Check glob match
152                if let Some(file_name) = path.file_name() {
153                    if glob_set.is_match(file_name) {
154                        matches.push(rel_path.clone());
155                    }
156                }
157
158                // Continue walking directories recursively
159                if is_dir {
160                    walk_dir.push(path);
161                }
162            }
163        }
164
165        // Sort matches
166        matches.sort();
167
168        // Build output
169        let mut output = String::new();
170        output.push_str(&format!("Search: {}\n", parsed.pattern));
171        output.push_str(&format!("Directory: {}\n", search_dir.display()));
172        output.push_str(&format!("{}", "─".repeat(50)));
173
174        if matches.is_empty() {
175            output.push_str("\n(No matches found)");
176        } else {
177            output.push_str(&format!("\nFound {} matches:\n", matches.len()));
178
179            let mut byte_count = output.len();
180            for (i, m) in matches.iter().enumerate() {
181                let entry_str = format!("\n{:>4}. {}", i + 1, m.display());
182
183                if byte_count + entry_str.len() > self.max_output_bytes {
184                    output.push_str(&format!(
185                        "\n... (Output truncated, {} matches total, byte limit of {} bytes reached)",
186                        matches.len(), self.max_output_bytes
187                    ));
188                    break;
189                }
190
191                output.push_str(&entry_str);
192                byte_count += entry_str.len();
193            }
194        }
195
196        Ok(ToolResult::success(output))
197    }
198}