Skip to main content

dot/tools/
file.rs

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}