Skip to main content

codetether_agent/tool/
edit.rs

1//! Edit tool: replace strings in files
2
3use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use similar::{ChangeTag, TextDiff};
8use tokio::fs;
9
10/// Edit files by replacing strings
11pub struct EditTool;
12
13impl EditTool {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19#[async_trait]
20impl Tool for EditTool {
21    fn id(&self) -> &str {
22        "edit"
23    }
24
25    fn name(&self) -> &str {
26        "Edit File"
27    }
28
29    fn description(&self) -> &str {
30        "edit(path: string, old_string: string, new_string: string) - Edit a file by replacing an exact string with new content. Include enough context (3+ lines before and after) to uniquely identify the location."
31    }
32
33    fn parameters(&self) -> Value {
34        json!({
35            "type": "object",
36            "properties": {
37                "path": {
38                    "type": "string",
39                    "description": "The path to the file to edit"
40                },
41                "old_string": {
42                    "type": "string",
43                    "description": "The exact string to replace (must match exactly, including whitespace)"
44                },
45                "new_string": {
46                    "type": "string",
47                    "description": "The string to replace old_string with"
48                }
49            },
50            "required": ["path", "old_string", "new_string"],
51            "example": {
52                "path": "src/main.rs",
53                "old_string": "fn old_function() {\n    println!(\"old\");\n}",
54                "new_string": "fn new_function() {\n    println!(\"new\");\n}"
55            }
56        })
57    }
58
59    async fn execute(&self, args: Value) -> Result<ToolResult> {
60        let path = match args["path"].as_str() {
61            Some(p) => p,
62            None => {
63                return Ok(ToolResult::structured_error(
64                    "INVALID_ARGUMENT",
65                    "edit",
66                    "path is required",
67                    Some(vec!["path"]),
68                    Some(
69                        json!({"path": "src/main.rs", "old_string": "old text", "new_string": "new text"}),
70                    ),
71                ));
72            }
73        };
74        let old_string = match args["old_string"].as_str() {
75            Some(s) => s,
76            None => {
77                return Ok(ToolResult::structured_error(
78                    "INVALID_ARGUMENT",
79                    "edit",
80                    "old_string is required",
81                    Some(vec!["old_string"]),
82                    Some(json!({"path": path, "old_string": "old text", "new_string": "new text"})),
83                ));
84            }
85        };
86        let new_string = match args["new_string"].as_str() {
87            Some(s) => s,
88            None => {
89                return Ok(ToolResult::structured_error(
90                    "INVALID_ARGUMENT",
91                    "edit",
92                    "new_string is required",
93                    Some(vec!["new_string"]),
94                    Some(json!({"path": path, "old_string": old_string, "new_string": "new text"})),
95                ));
96            }
97        };
98
99        // Read the file
100        let content = fs::read_to_string(path).await?;
101
102        // Count occurrences
103        let count = content.matches(old_string).count();
104
105        if count == 0 {
106            return Ok(ToolResult::structured_error(
107                "NOT_FOUND",
108                "edit",
109                "old_string not found in file. Make sure it matches exactly, including whitespace.",
110                None,
111                Some(json!({
112                    "hint": "Use the 'read' tool first to see the exact content of the file",
113                    "path": path,
114                    "old_string": "<copy exact text from file including whitespace>",
115                    "new_string": "replacement text"
116                })),
117            ));
118        }
119
120        if count > 1 {
121            return Ok(ToolResult::structured_error(
122                "AMBIGUOUS_MATCH",
123                "edit",
124                &format!(
125                    "old_string found {} times. Include more context to uniquely identify the location.",
126                    count
127                ),
128                None,
129                Some(json!({
130                    "hint": "Include 3+ lines of context before and after the target text",
131                    "matches_found": count
132                })),
133            ));
134        }
135
136        // Generate preview diff
137        let new_content = content.replacen(old_string, new_string, 1);
138        let diff = TextDiff::from_lines(&content, &new_content);
139
140        let mut diff_output = String::new();
141        let mut added = 0;
142        let mut removed = 0;
143
144        for change in diff.iter_all_changes() {
145            let (sign, style) = match change.tag() {
146                ChangeTag::Delete => {
147                    removed += 1;
148                    ("-", "red")
149                }
150                ChangeTag::Insert => {
151                    added += 1;
152                    ("+", "green")
153                }
154                ChangeTag::Equal => (" ", "default"),
155            };
156
157            let line = format!("{}{}", sign, change);
158            if style == "red" {
159                diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
160            } else if style == "green" {
161                diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
162            } else {
163                diff_output.push_str(&line.trim_end());
164            }
165            diff_output.push('\n');
166        }
167
168        // Instead of applying changes immediately, return confirmation prompt
169        let mut metadata = std::collections::HashMap::new();
170        metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
171        metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
172        metadata.insert("added_lines".to_string(), serde_json::json!(added));
173        metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
174        metadata.insert("path".to_string(), serde_json::json!(path));
175        metadata.insert("old_string".to_string(), serde_json::json!(old_string));
176        metadata.insert("new_string".to_string(), serde_json::json!(new_string));
177
178        Ok(ToolResult {
179            output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
180            success: true,
181            metadata,
182        })
183    }
184}