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.map(|o| o.saturating_sub(1)).unwrap_or(0);
84        let end_line = limit
85            .map(|l| (start_line + l).min(lines.len()))
86            .unwrap_or(lines.len());
87
88        let selected: String = lines[start_line..end_line]
89            .iter()
90            .enumerate()
91            .map(|(i, line)| format!("{:4} | {}", start_line + i + 1, line))
92            .collect::<Vec<_>>()
93            .join("\n");
94
95        let duration = start.elapsed();
96
97        // Record telemetry
98        let file_change = FileChange::read(path, Some((start_line as u32 + 1, end_line as u32)));
99
100        let mut exec = ToolExecution::start(
101            "read",
102            json!({
103                "path": path,
104                "offset": offset,
105                "limit": limit,
106            }),
107        );
108        exec.add_file_change(file_change);
109        let exec = exec.complete_success(
110            format!("Read {} lines from {}", end_line - start_line, path),
111            duration,
112        );
113        TOOL_EXECUTIONS.record(exec.clone());
114        record_persistent(exec);
115
116        Ok(ToolResult::success(selected)
117            .with_metadata("total_lines", json!(lines.len()))
118            .with_metadata("read_lines", json!(end_line - start_line)))
119    }
120}
121
122/// Write file contents
123pub struct WriteTool;
124
125impl WriteTool {
126    pub fn new() -> Self {
127        Self
128    }
129}
130
131#[async_trait]
132impl Tool for WriteTool {
133    fn id(&self) -> &str {
134        "write"
135    }
136
137    fn name(&self) -> &str {
138        "Write File"
139    }
140
141    fn description(&self) -> &str {
142        "write(path: string, content: string) - Write content to a file. Creates the file if it doesn't exist, or overwrites it."
143    }
144
145    fn parameters(&self) -> Value {
146        json!({
147            "type": "object",
148            "properties": {
149                "path": {
150                    "type": "string",
151                    "description": "The path to the file to write"
152                },
153                "content": {
154                    "type": "string",
155                    "description": "The content to write to the file"
156                }
157            },
158            "required": ["path", "content"],
159            "example": {
160                "path": "src/config.rs",
161                "content": "// Configuration module\n\npub struct Config {\n    pub debug: bool,\n}\n"
162            }
163        })
164    }
165
166    async fn execute(&self, args: Value) -> Result<ToolResult> {
167        let start = Instant::now();
168
169        let path = match args["path"].as_str() {
170            Some(p) => p,
171            None => {
172                return Ok(ToolResult::structured_error(
173                    "INVALID_ARGUMENT",
174                    "write",
175                    "path is required",
176                    Some(vec!["path"]),
177                    Some(json!({"path": "src/example.rs", "content": "// file content"})),
178                ));
179            }
180        };
181        let content = match args["content"].as_str() {
182            Some(c) => c,
183            None => {
184                return Ok(ToolResult::structured_error(
185                    "INVALID_ARGUMENT",
186                    "write",
187                    "content is required",
188                    Some(vec!["content"]),
189                    Some(json!({"path": path, "content": "// file content"})),
190                ));
191            }
192        };
193
194        // Create parent directories if needed
195        if let Some(parent) = PathBuf::from(path).parent() {
196            fs::create_dir_all(parent).await?;
197        }
198
199        // Check if file exists for telemetry
200        let existed = fs::metadata(path).await.is_ok();
201        let old_content = if existed {
202            fs::read_to_string(path).await.ok()
203        } else {
204            None
205        };
206
207        fs::write(path, content).await?;
208
209        let duration = start.elapsed();
210
211        // Record telemetry
212        let file_change = if existed {
213            FileChange::modify(
214                path,
215                old_content.as_deref().unwrap_or(""),
216                content,
217                Some((1, content.lines().count() as u32)),
218            )
219        } else {
220            FileChange::create(path, content)
221        };
222
223        let mut exec = ToolExecution::start(
224            "write",
225            json!({
226                "path": path,
227                "content_length": content.len(),
228            }),
229        );
230        exec.add_file_change(file_change);
231        let exec = exec.complete_success(
232            format!("Wrote {} bytes to {}", content.len(), path),
233            duration,
234        );
235        TOOL_EXECUTIONS.record(exec.clone());
236        record_persistent(exec);
237
238        Ok(ToolResult::success(format!(
239            "Wrote {} bytes to {}",
240            content.len(),
241            path
242        )))
243    }
244}
245
246/// List directory contents
247pub struct ListTool;
248
249impl ListTool {
250    pub fn new() -> Self {
251        Self
252    }
253}
254
255#[async_trait]
256impl Tool for ListTool {
257    fn id(&self) -> &str {
258        "list"
259    }
260
261    fn name(&self) -> &str {
262        "List Directory"
263    }
264
265    fn description(&self) -> &str {
266        "list(path: string) - List the contents of a directory."
267    }
268
269    fn parameters(&self) -> Value {
270        json!({
271            "type": "object",
272            "properties": {
273                "path": {
274                    "type": "string",
275                    "description": "The path to the directory to list"
276                }
277            },
278            "required": ["path"],
279            "example": {
280                "path": "src/"
281            }
282        })
283    }
284
285    async fn execute(&self, args: Value) -> Result<ToolResult> {
286        let path = match args["path"].as_str() {
287            Some(p) => p,
288            None => {
289                return Ok(ToolResult::structured_error(
290                    "INVALID_ARGUMENT",
291                    "list",
292                    "path is required",
293                    Some(vec!["path"]),
294                    Some(json!({"path": "src/"})),
295                ));
296            }
297        };
298
299        let mut entries = fs::read_dir(path).await?;
300        let mut items = Vec::new();
301
302        while let Some(entry) = entries.next_entry().await? {
303            let name = entry.file_name().to_string_lossy().to_string();
304            let file_type = entry.file_type().await?;
305
306            let suffix = if file_type.is_dir() {
307                "/"
308            } else if file_type.is_symlink() {
309                "@"
310            } else {
311                ""
312            };
313
314            items.push(format!("{}{}", name, suffix));
315        }
316
317        items.sort();
318        Ok(ToolResult::success(items.join("\n")).with_metadata("count", json!(items.len())))
319    }
320}
321
322/// Find files using glob patterns
323pub struct GlobTool;
324
325impl GlobTool {
326    pub fn new() -> Self {
327        Self
328    }
329}
330
331#[async_trait]
332impl Tool for GlobTool {
333    fn id(&self) -> &str {
334        "glob"
335    }
336
337    fn name(&self) -> &str {
338        "Glob Search"
339    }
340
341    fn description(&self) -> &str {
342        "glob(pattern: string, limit?: int) - Find files matching a glob pattern (e.g., **/*.rs, src/**/*.ts)"
343    }
344
345    fn parameters(&self) -> Value {
346        json!({
347            "type": "object",
348            "properties": {
349                "pattern": {
350                    "type": "string",
351                    "description": "The glob pattern to match files"
352                },
353                "limit": {
354                    "type": "integer",
355                    "description": "Maximum number of results to return"
356                }
357            },
358            "required": ["pattern"],
359            "example": {
360                "pattern": "src/**/*.rs",
361                "limit": 50
362            }
363        })
364    }
365
366    async fn execute(&self, args: Value) -> Result<ToolResult> {
367        let pattern = match args["pattern"].as_str() {
368            Some(p) => p,
369            None => {
370                return Ok(ToolResult::structured_error(
371                    "INVALID_ARGUMENT",
372                    "glob",
373                    "pattern is required",
374                    Some(vec!["pattern"]),
375                    Some(json!({"pattern": "src/**/*.rs"})),
376                ));
377            }
378        };
379        let limit = args["limit"].as_u64().unwrap_or(100) as usize;
380
381        let mut matches = Vec::new();
382
383        for entry in glob::glob(pattern)? {
384            if matches.len() >= limit {
385                break;
386            }
387            if let Ok(path) = entry {
388                matches.push(path.display().to_string());
389            }
390        }
391
392        let truncated = matches.len() >= limit;
393        let output = matches.join("\n");
394
395        Ok(ToolResult::success(output)
396            .with_metadata("count", json!(matches.len()))
397            .with_metadata("truncated", json!(truncated)))
398    }
399}