Skip to main content

codetether_agent/tool/
file.rs

1//! File tools: read, write, list, glob
2
3use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::path::PathBuf;
8use std::time::Instant;
9use tokio::fs;
10
11use crate::telemetry::{FileChange, TOOL_EXECUTIONS, ToolExecution, record_persistent};
12
13/// Read file contents
14pub struct ReadTool;
15
16impl ReadTool {
17    pub fn new() -> Self {
18        Self
19    }
20}
21
22#[async_trait]
23impl Tool for ReadTool {
24    fn id(&self) -> &str {
25        "read"
26    }
27
28    fn name(&self) -> &str {
29        "Read File"
30    }
31
32    fn description(&self) -> &str {
33        "read(path: string, offset?: int, limit?: int) - Read the contents of a file. Provide the file path to read."
34    }
35
36    fn parameters(&self) -> Value {
37        json!({
38            "type": "object",
39            "properties": {
40                "path": {
41                    "type": "string",
42                    "description": "The path to the file to read"
43                },
44                "offset": {
45                    "type": "integer",
46                    "description": "Line number to start reading from (1-indexed)"
47                },
48                "limit": {
49                    "type": "integer",
50                    "description": "Maximum number of lines to read"
51                }
52            },
53            "required": ["path"],
54            "example": {
55                "path": "src/main.rs",
56                "offset": 1,
57                "limit": 100
58            }
59        })
60    }
61
62    async fn execute(&self, args: Value) -> Result<ToolResult> {
63        let start = Instant::now();
64
65        let path = match args["path"].as_str() {
66            Some(p) => p,
67            None => {
68                return Ok(ToolResult::structured_error(
69                    "INVALID_ARGUMENT",
70                    "read",
71                    "path is required",
72                    Some(vec!["path"]),
73                    Some(json!({"path": "src/main.rs"})),
74                ));
75            }
76        };
77        let offset = args["offset"].as_u64().map(|n| n as usize);
78        let limit = args["limit"].as_u64().map(|n| n as usize);
79
80        let content = fs::read_to_string(path).await?;
81
82        let lines: Vec<&str> = content.lines().collect();
83        let start_line = offset
84            .map(|o| o.saturating_sub(1))
85            .unwrap_or(0)
86            .min(lines.len());
87        let end_line = limit
88            .map(|l| (start_line + l).min(lines.len()))
89            .unwrap_or(lines.len());
90
91        let selected: String = lines[start_line..end_line]
92            .iter()
93            .enumerate()
94            .map(|(i, line)| format!("{:4} | {}", start_line + i + 1, line))
95            .collect::<Vec<_>>()
96            .join("\n");
97
98        let duration = start.elapsed();
99
100        // Record telemetry
101        let file_change = FileChange::read(path, Some((start_line as u32 + 1, end_line as u32)));
102
103        let mut exec = ToolExecution::start(
104            "read",
105            json!({
106                "path": path,
107                "offset": offset,
108                "limit": limit,
109            }),
110        );
111        exec.add_file_change(file_change);
112        let exec = exec.complete_success(
113            format!("Read {} lines from {}", end_line - start_line, path),
114            duration,
115        );
116        TOOL_EXECUTIONS.record(exec.clone());
117        record_persistent(exec);
118
119        Ok(ToolResult::success(selected)
120            .with_metadata("total_lines", json!(lines.len()))
121            .with_metadata("read_lines", json!(end_line - start_line)))
122    }
123}
124
125/// Write file contents
126pub struct WriteTool;
127
128impl WriteTool {
129    pub fn new() -> Self {
130        Self
131    }
132}
133
134#[async_trait]
135impl Tool for WriteTool {
136    fn id(&self) -> &str {
137        "write"
138    }
139
140    fn name(&self) -> &str {
141        "Write File"
142    }
143
144    fn description(&self) -> &str {
145        "write(path: string, content: string) - Write content to a file. Creates the file if it doesn't exist, or overwrites it."
146    }
147
148    fn parameters(&self) -> Value {
149        json!({
150            "type": "object",
151            "properties": {
152                "path": {
153                    "type": "string",
154                    "description": "The path to the file to write"
155                },
156                "content": {
157                    "type": "string",
158                    "description": "The content to write to the file"
159                }
160            },
161            "required": ["path", "content"],
162            "example": {
163                "path": "src/config.rs",
164                "content": "// Configuration module\n\npub struct Config {\n    pub debug: bool,\n}\n"
165            }
166        })
167    }
168
169    async fn execute(&self, args: Value) -> Result<ToolResult> {
170        let start = Instant::now();
171
172        let path = match args["path"].as_str() {
173            Some(p) => p,
174            None => {
175                return Ok(ToolResult::structured_error(
176                    "INVALID_ARGUMENT",
177                    "write",
178                    "path is required",
179                    Some(vec!["path"]),
180                    Some(json!({"path": "src/example.rs", "content": "// file content"})),
181                ));
182            }
183        };
184        let content = match args["content"].as_str() {
185            Some(c) => c,
186            None => {
187                return Ok(ToolResult::structured_error(
188                    "INVALID_ARGUMENT",
189                    "write",
190                    "content is required",
191                    Some(vec!["content"]),
192                    Some(json!({"path": path, "content": "// file content"})),
193                ));
194            }
195        };
196
197        // Create parent directories if needed
198        if let Some(parent) = PathBuf::from(path).parent() {
199            fs::create_dir_all(parent).await?;
200        }
201
202        // Check if file exists for telemetry
203        let existed = fs::metadata(path).await.is_ok();
204        let old_content = if existed {
205            fs::read_to_string(path).await.ok()
206        } else {
207            None
208        };
209
210        fs::write(path, content).await?;
211
212        let duration = start.elapsed();
213
214        // Record telemetry
215        let file_change = if existed {
216            FileChange::modify(
217                path,
218                old_content.as_deref().unwrap_or(""),
219                content,
220                Some((1, content.lines().count() as u32)),
221            )
222        } else {
223            FileChange::create(path, content)
224        };
225
226        let mut exec = ToolExecution::start(
227            "write",
228            json!({
229                "path": path,
230                "content_length": content.len(),
231            }),
232        );
233        exec.add_file_change(file_change);
234        let exec = exec.complete_success(
235            format!("Wrote {} bytes to {}", content.len(), path),
236            duration,
237        );
238        TOOL_EXECUTIONS.record(exec.clone());
239        record_persistent(exec);
240
241        Ok(ToolResult::success(format!(
242            "Wrote {} bytes to {}",
243            content.len(),
244            path
245        )))
246    }
247}
248
249/// List directory contents
250pub struct ListTool;
251
252impl ListTool {
253    pub fn new() -> Self {
254        Self
255    }
256}
257
258#[async_trait]
259impl Tool for ListTool {
260    fn id(&self) -> &str {
261        "list"
262    }
263
264    fn name(&self) -> &str {
265        "List Directory"
266    }
267
268    fn description(&self) -> &str {
269        "list(path: string) - List the contents of a directory."
270    }
271
272    fn parameters(&self) -> Value {
273        json!({
274            "type": "object",
275            "properties": {
276                "path": {
277                    "type": "string",
278                    "description": "The path to the directory to list"
279                }
280            },
281            "required": ["path"],
282            "example": {
283                "path": "src/"
284            }
285        })
286    }
287
288    async fn execute(&self, args: Value) -> Result<ToolResult> {
289        let path = match args["path"].as_str() {
290            Some(p) => p,
291            None => {
292                return Ok(ToolResult::structured_error(
293                    "INVALID_ARGUMENT",
294                    "list",
295                    "path is required",
296                    Some(vec!["path"]),
297                    Some(json!({"path": "src/"})),
298                ));
299            }
300        };
301
302        let mut entries = fs::read_dir(path).await?;
303        let mut items = Vec::new();
304
305        while let Some(entry) = entries.next_entry().await? {
306            let name = entry.file_name().to_string_lossy().to_string();
307            let file_type = entry.file_type().await?;
308
309            let suffix = if file_type.is_dir() {
310                "/"
311            } else if file_type.is_symlink() {
312                "@"
313            } else {
314                ""
315            };
316
317            items.push(format!("{}{}", name, suffix));
318        }
319
320        items.sort();
321        Ok(ToolResult::success(items.join("\n")).with_metadata("count", json!(items.len())))
322    }
323}
324
325/// Find files using glob patterns
326pub struct GlobTool;
327
328impl GlobTool {
329    pub fn new() -> Self {
330        Self
331    }
332}
333
334#[async_trait]
335impl Tool for GlobTool {
336    fn id(&self) -> &str {
337        "glob"
338    }
339
340    fn name(&self) -> &str {
341        "Glob Search"
342    }
343
344    fn description(&self) -> &str {
345        "glob(pattern: string, limit?: int) - Find files matching a glob pattern (e.g., **/*.rs, src/**/*.ts)"
346    }
347
348    fn parameters(&self) -> Value {
349        json!({
350            "type": "object",
351            "properties": {
352                "pattern": {
353                    "type": "string",
354                    "description": "The glob pattern to match files"
355                },
356                "limit": {
357                    "type": "integer",
358                    "description": "Maximum number of results to return"
359                }
360            },
361            "required": ["pattern"],
362            "example": {
363                "pattern": "src/**/*.rs",
364                "limit": 50
365            }
366        })
367    }
368
369    async fn execute(&self, args: Value) -> Result<ToolResult> {
370        let pattern = match args["pattern"].as_str() {
371            Some(p) => p,
372            None => {
373                return Ok(ToolResult::structured_error(
374                    "INVALID_ARGUMENT",
375                    "glob",
376                    "pattern is required",
377                    Some(vec!["pattern"]),
378                    Some(json!({"pattern": "src/**/*.rs"})),
379                ));
380            }
381        };
382        let limit = args["limit"].as_u64().unwrap_or(100) as usize;
383
384        let mut matches = Vec::new();
385
386        // Determine the base directory from the pattern for WalkBuilder.
387        // E.g. "src/**/*.rs" → walk "src/", "**/*.ts" → walk ".".
388        let (base_dir, match_pattern) = {
389            let p = std::path::Path::new(pattern);
390            let mut prefix = std::path::PathBuf::new();
391            let mut found_meta = false;
392            for component in p.components() {
393                let s = component.as_os_str().to_string_lossy();
394                if !found_meta && glob::Pattern::escape(&s) == *s {
395                    prefix.push(component);
396                } else {
397                    found_meta = true;
398                }
399            }
400            let base = if prefix.as_os_str().is_empty() {
401                ".".to_string()
402            } else {
403                prefix.display().to_string()
404            };
405            (base, pattern.to_string())
406        };
407
408        let compiled = glob::Pattern::new(&match_pattern)?;
409
410        let walker = ignore::WalkBuilder::new(&base_dir)
411            .hidden(false)
412            .git_ignore(true)
413            .follow_links(false) // prevent symlink loops
414            .max_depth(Some(30)) // hard depth cap
415            .build();
416
417        for entry in walker {
418            if matches.len() >= limit {
419                break;
420            }
421            let entry = match entry {
422                Ok(e) => e,
423                Err(_) => continue,
424            };
425            let path = entry.path();
426            if compiled.matches_path(path) || compiled.matches(path.to_string_lossy().as_ref()) {
427                matches.push(path.display().to_string());
428            }
429        }
430
431        let truncated = matches.len() >= limit;
432        let output = matches.join("\n");
433
434        Ok(ToolResult::success(output)
435            .with_metadata("count", json!(matches.len()))
436            .with_metadata("truncated", json!(truncated)))
437    }
438}