1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4use std::path::Path;
5
6use super::Tool;
7
8pub struct ReadFileTool;
9
10impl Tool for ReadFileTool {
11 fn name(&self) -> &str {
12 "read_file"
13 }
14
15 fn description(&self) -> &str {
16 "Read the contents of a file at the given path. Use this to examine existing files."
17 }
18
19 fn input_schema(&self) -> Value {
20 serde_json::json!({
21 "type": "object",
22 "properties": {
23 "path": {
24 "type": "string",
25 "description": "The file path to read"
26 }
27 },
28 "required": ["path"]
29 })
30 }
31
32 fn execute(&self, input: Value) -> Result<String> {
33 let path = input["path"]
34 .as_str()
35 .context("Missing required parameter 'path'")?;
36 tracing::debug!("read_file: {}", path);
37 let content =
38 fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?;
39 Ok(content)
40 }
41}
42
43pub struct WriteFileTool;
44
45impl Tool for WriteFileTool {
46 fn name(&self) -> &str {
47 "write_file"
48 }
49
50 fn description(&self) -> &str {
51 "Write content to a file at the given path. Creates the file if it doesn't exist, overwrites if it does."
52 }
53
54 fn input_schema(&self) -> Value {
55 serde_json::json!({
56 "type": "object",
57 "properties": {
58 "path": {
59 "type": "string",
60 "description": "The file path to write to"
61 },
62 "content": {
63 "type": "string",
64 "description": "The content to write"
65 }
66 },
67 "required": ["path", "content"]
68 })
69 }
70
71 fn execute(&self, input: Value) -> Result<String> {
72 let path = input["path"]
73 .as_str()
74 .context("Missing required parameter 'path'")?;
75 let content = input["content"]
76 .as_str()
77 .context("Missing required parameter 'content'")?;
78 tracing::debug!("write_file: {}", path);
79
80 if let Some(parent) = Path::new(path).parent()
81 && !parent.as_os_str().is_empty()
82 {
83 fs::create_dir_all(parent)
84 .with_context(|| format!("Failed to create parent directories for: {}", path))?;
85 }
86
87 fs::write(path, content).with_context(|| format!("Failed to write file: {}", path))?;
88
89 Ok(format!(
90 "Successfully wrote {} bytes to {}",
91 content.len(),
92 path
93 ))
94 }
95}
96
97pub struct ListDirectoryTool;
98
99impl Tool for ListDirectoryTool {
100 fn name(&self) -> &str {
101 "list_directory"
102 }
103
104 fn description(&self) -> &str {
105 "List the contents of a directory."
106 }
107
108 fn input_schema(&self) -> Value {
109 serde_json::json!({
110 "type": "object",
111 "properties": {
112 "path": {
113 "type": "string",
114 "description": "The directory path to list"
115 }
116 },
117 "required": ["path"]
118 })
119 }
120
121 fn execute(&self, input: Value) -> Result<String> {
122 let path = input["path"]
123 .as_str()
124 .context("Missing required parameter 'path'")?;
125 tracing::debug!("list_directory: {}", path);
126
127 let read_dir =
128 fs::read_dir(path).with_context(|| format!("Failed to read directory: {}", path))?;
129
130 let mut entries: Vec<String> = Vec::new();
131 for entry in read_dir {
132 let entry = entry.context("Failed to read directory entry")?;
133 let metadata = entry.metadata().context("Failed to read entry metadata")?;
134 let kind = if metadata.is_dir() { "dir" } else { "file" };
135 let size = if metadata.is_file() {
136 metadata.len()
137 } else {
138 0
139 };
140 let name = entry.file_name().to_string_lossy().to_string();
141
142 #[cfg(unix)]
143 let perms = {
144 use std::os::unix::fs::PermissionsExt;
145 format!("{:o}", metadata.permissions().mode() & 0o777)
146 };
147 #[cfg(not(unix))]
148 let perms = String::from("---");
149
150 entries.push(format!("{:<5} {:>10} {} {}", kind, size, perms, name));
151 }
152
153 entries.sort();
154
155 if entries.is_empty() {
156 Ok(format!("Directory '{}' is empty.", path))
157 } else {
158 Ok(format!("Contents of '{}':\n{}", path, entries.join("\n")))
159 }
160 }
161}
162
163pub struct SearchFilesTool;
164
165impl Tool for SearchFilesTool {
166 fn name(&self) -> &str {
167 "search_files"
168 }
169
170 fn description(&self) -> &str {
171 "Search for a pattern in files within a directory. Returns matching lines with file paths and line numbers."
172 }
173
174 fn input_schema(&self) -> Value {
175 serde_json::json!({
176 "type": "object",
177 "properties": {
178 "path": {
179 "type": "string",
180 "description": "The directory to search in"
181 },
182 "pattern": {
183 "type": "string",
184 "description": "The text pattern to search for"
185 },
186 "file_pattern": {
187 "type": "string",
188 "description": "Optional glob pattern to filter files (e.g., '*.rs')"
189 }
190 },
191 "required": ["path", "pattern"]
192 })
193 }
194
195 fn execute(&self, input: Value) -> Result<String> {
196 let path = input["path"]
197 .as_str()
198 .context("Missing required parameter 'path'")?;
199 let pattern = input["pattern"]
200 .as_str()
201 .context("Missing required parameter 'pattern'")?;
202 let file_pattern = input["file_pattern"].as_str().unwrap_or("");
203 tracing::debug!("search_files: {} for '{}'", path, pattern);
204
205 let mut results: Vec<String> = Vec::new();
206 search_recursive(Path::new(path), pattern, file_pattern, &mut results, 50)?;
207
208 if results.is_empty() {
209 Ok(format!("No matches found for '{}' in '{}'.", pattern, path))
210 } else {
211 let truncated = results.len() >= 50;
212 let mut output = results.join("\n");
213 if truncated {
214 output.push_str("\n... (output truncated at 50 matches)");
215 }
216 Ok(output)
217 }
218 }
219}
220
221fn search_recursive(
222 dir: &Path,
223 pattern: &str,
224 file_pattern: &str,
225 results: &mut Vec<String>,
226 max: usize,
227) -> Result<()> {
228 if results.len() >= max {
229 return Ok(());
230 }
231
232 let entries = match fs::read_dir(dir) {
233 Ok(e) => e,
234 Err(_) => return Ok(()),
235 };
236
237 for entry in entries {
238 if results.len() >= max {
239 return Ok(());
240 }
241
242 let entry = match entry {
243 Ok(e) => e,
244 Err(_) => continue,
245 };
246
247 let metadata = match entry.metadata() {
248 Ok(m) => m,
249 Err(_) => continue,
250 };
251
252 let path = entry.path();
253
254 if metadata.is_dir() {
255 let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
256 if dir_name.starts_with('.') || dir_name == "target" || dir_name == "node_modules" {
257 continue;
258 }
259 search_recursive(&path, pattern, file_pattern, results, max)?;
260 } else if metadata.is_file() {
261 let file_name = path
262 .file_name()
263 .unwrap_or_default()
264 .to_string_lossy()
265 .to_string();
266
267 if !file_pattern.is_empty() && !matches_file_pattern(&file_name, file_pattern) {
268 continue;
269 }
270
271 let content = match fs::read_to_string(&path) {
272 Ok(c) => c,
273 Err(_) => continue,
274 };
275
276 for (line_num, line) in content.lines().enumerate() {
277 if results.len() >= max {
278 return Ok(());
279 }
280 if line.contains(pattern) {
281 results.push(format!(
282 "{}:{}: {}",
283 path.display(),
284 line_num + 1,
285 line.trim()
286 ));
287 }
288 }
289 }
290 }
291
292 Ok(())
293}
294
295fn matches_file_pattern(filename: &str, pattern: &str) -> bool {
296 if let Some(ext_pattern) = pattern.strip_prefix("*.") {
297 filename.ends_with(&format!(".{}", ext_pattern))
298 } else {
299 filename == pattern
300 }
301}