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