1use 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: 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 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 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 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 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 if parsed.file_only && !is_file {
145 continue;
146 }
147 if parsed.dir_only && !is_dir {
148 continue;
149 }
150
151 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 if is_dir {
160 walk_dir.push(path);
161 }
162 }
163 }
164
165 matches.sort();
167
168 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}