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 Default for EditTool {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl EditTool {
20    pub fn new() -> Self {
21        Self
22    }
23}
24
25#[async_trait]
26impl Tool for EditTool {
27    fn id(&self) -> &str {
28        "edit"
29    }
30
31    fn name(&self) -> &str {
32        "Edit File"
33    }
34
35    fn description(&self) -> &str {
36        "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. \
37            Morph backend is available only when explicitly enabled with CODETETHER_MORPH_TOOL_BACKEND=1. \
38            Optional instruction/update can guide Morph behavior."
39    }
40
41    fn parameters(&self) -> Value {
42        json!({
43            "type": "object",
44            "properties": {
45                "path": {
46                    "type": "string",
47                    "description": "The path to the file to edit"
48                },
49                "old_string": {
50                    "type": "string",
51                    "description": "The exact string to replace (must match exactly, including whitespace)"
52                },
53                "new_string": {
54                    "type": "string",
55                    "description": "The string to replace old_string with"
56                },
57                "instruction": {
58                    "type": "string",
59                    "description": "Optional Morph instruction."
60                },
61                "update": {
62                    "type": "string",
63                    "description": "Optional Morph update snippet."
64                }
65            },
66            "required": ["path"],
67            "example": {
68                "path": "src/main.rs",
69                "old_string": "fn old_function() {\n    println!(\"old\");\n}",
70                "new_string": "fn new_function() {\n    println!(\"new\");\n}",
71                "instruction": "Refactor while preserving behavior",
72                "update": "fn new_function() {\n// ...existing code...\n}"
73            }
74        })
75    }
76
77    async fn execute(&self, args: Value) -> Result<ToolResult> {
78        let path = match args["path"].as_str() {
79            Some(p) => p,
80            None => {
81                return Ok(ToolResult::structured_error(
82                    "INVALID_ARGUMENT",
83                    "edit",
84                    "path is required",
85                    Some(vec!["path"]),
86                    Some(
87                        json!({"path": "src/main.rs", "old_string": "old text", "new_string": "new text"}),
88                    ),
89                ));
90            }
91        };
92        let old_string = args["old_string"].as_str();
93        let new_string = args["new_string"].as_str();
94        let instruction = args["instruction"].as_str();
95        let update = args["update"].as_str();
96        let morph_enabled = super::morph_backend::should_use_morph_backend();
97        let morph_requested = instruction.is_some() || update.is_some();
98        let has_replacement_pair = old_string.is_some() && new_string.is_some();
99        let use_morph = morph_enabled && morph_requested;
100
101        // Read the file
102        let content = fs::read_to_string(path).await?;
103
104        if use_morph {
105            let inferred_instruction = instruction
106                .map(str::to_string)
107                .or_else(|| {
108                    old_string.zip(new_string).map(|(old, new)| {
109                        format!(
110                            "Replace the target snippet exactly once while preserving behavior.\nOld snippet:\n{old}\n\nNew snippet:\n{new}"
111                        )
112                    })
113                })
114                .unwrap_or_else(|| {
115                    "Apply the requested update precisely and return only the updated file."
116                        .to_string()
117                });
118            let inferred_update = update
119                .map(str::to_string)
120                .or_else(|| {
121                    old_string.zip(new_string).map(|(old, new)| {
122                        format!(
123                            "// Replace this snippet:\n{old}\n// With this snippet:\n{new}\n// ...existing code..."
124                        )
125                    })
126                })
127                .unwrap_or_else(|| "// ...existing code...".to_string());
128
129            let morph_result = match super::morph_backend::apply_edit_with_morph(
130                &content,
131                &inferred_instruction,
132                &inferred_update,
133            )
134            .await
135            {
136                Ok(updated) => Some(updated),
137                Err(e) => {
138                    if has_replacement_pair {
139                        tracing::warn!(
140                            path = %path,
141                            error = %e,
142                            "Morph backend failed for edit; falling back to exact replacement"
143                        );
144                        None
145                    } else {
146                        return Ok(ToolResult::structured_error(
147                            "MORPH_BACKEND_FAILED",
148                            "edit",
149                            &e.to_string(),
150                            None,
151                            Some(json!({
152                                "hint": "Configure OpenRouter provider credentials in Vault/provider registry, or supply old_string/new_string for exact replacement fallback"
153                            })),
154                        ));
155                    }
156                }
157            };
158
159            if let Some(new_content) = morph_result {
160                if new_content == content {
161                    return Ok(ToolResult::structured_error(
162                        "NO_OP",
163                        "edit",
164                        "Morph backend returned unchanged content.",
165                        None,
166                        Some(json!({"path": path})),
167                    ));
168                }
169
170                let diff = TextDiff::from_lines(&content, &new_content);
171                let mut diff_output = String::new();
172                let mut added = 0;
173                let mut removed = 0;
174                for change in diff.iter_all_changes() {
175                    let (sign, style) = match change.tag() {
176                        ChangeTag::Delete => {
177                            removed += 1;
178                            ("-", "red")
179                        }
180                        ChangeTag::Insert => {
181                            added += 1;
182                            ("+", "green")
183                        }
184                        ChangeTag::Equal => (" ", "default"),
185                    };
186                    let line = format!("{}{}", sign, change);
187                    if style == "red" {
188                        diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
189                    } else if style == "green" {
190                        diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
191                    } else {
192                        diff_output.push_str(line.trim_end());
193                    }
194                    diff_output.push('\n');
195                }
196
197                let mut metadata = std::collections::HashMap::new();
198                metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
199                metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
200                metadata.insert("added_lines".to_string(), serde_json::json!(added));
201                metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
202                metadata.insert("path".to_string(), serde_json::json!(path));
203                metadata.insert("old_string".to_string(), serde_json::json!(content));
204                metadata.insert("new_string".to_string(), serde_json::json!(new_content));
205                metadata.insert("backend".to_string(), serde_json::json!("morph"));
206
207                return Ok(ToolResult {
208                    output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
209                    success: true,
210                    metadata,
211                });
212            }
213        }
214
215        let old_string = match old_string {
216            Some(s) => s,
217            None => {
218                return Ok(ToolResult::structured_error(
219                    "INVALID_ARGUMENT",
220                    "edit",
221                    "old_string is required unless Morph backend is enabled and instruction/update are provided",
222                    Some(vec!["old_string"]),
223                    Some(json!({"path": path, "old_string": "old text", "new_string": "new text"})),
224                ));
225            }
226        };
227        let new_string = match new_string {
228            Some(s) => s,
229            None => {
230                return Ok(ToolResult::structured_error(
231                    "INVALID_ARGUMENT",
232                    "edit",
233                    "new_string is required unless Morph backend is enabled and instruction/update are provided",
234                    Some(vec!["new_string"]),
235                    Some(json!({"path": path, "old_string": old_string, "new_string": "new text"})),
236                ));
237            }
238        };
239
240        // Count occurrences
241        let count = content.matches(old_string).count();
242
243        if count == 0 {
244            return Ok(ToolResult::structured_error(
245                "NOT_FOUND",
246                "edit",
247                "old_string not found in file. Make sure it matches exactly, including whitespace.",
248                None,
249                Some(json!({
250                    "hint": "Use the 'read' tool first to see the exact content of the file",
251                    "path": path,
252                    "old_string": "<copy exact text from file including whitespace>",
253                    "new_string": "replacement text"
254                })),
255            ));
256        }
257
258        if count > 1 {
259            return Ok(ToolResult::structured_error(
260                "AMBIGUOUS_MATCH",
261                "edit",
262                &format!(
263                    "old_string found {} times. Include more context to uniquely identify the location.",
264                    count
265                ),
266                None,
267                Some(json!({
268                    "hint": "Include 3+ lines of context before and after the target text",
269                    "matches_found": count
270                })),
271            ));
272        }
273
274        // Generate preview diff
275        let new_content = content.replacen(old_string, new_string, 1);
276        let diff = TextDiff::from_lines(&content, &new_content);
277
278        let mut diff_output = String::new();
279        let mut added = 0;
280        let mut removed = 0;
281
282        for change in diff.iter_all_changes() {
283            let (sign, style) = match change.tag() {
284                ChangeTag::Delete => {
285                    removed += 1;
286                    ("-", "red")
287                }
288                ChangeTag::Insert => {
289                    added += 1;
290                    ("+", "green")
291                }
292                ChangeTag::Equal => (" ", "default"),
293            };
294
295            let line = format!("{}{}", sign, change);
296            if style == "red" {
297                diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
298            } else if style == "green" {
299                diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
300            } else {
301                diff_output.push_str(line.trim_end());
302            }
303            diff_output.push('\n');
304        }
305
306        // Instead of applying changes immediately, return confirmation prompt
307        let mut metadata = std::collections::HashMap::new();
308        metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
309        metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
310        metadata.insert("added_lines".to_string(), serde_json::json!(added));
311        metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
312        metadata.insert("path".to_string(), serde_json::json!(path));
313        metadata.insert("old_string".to_string(), serde_json::json!(old_string));
314        metadata.insert("new_string".to_string(), serde_json::json!(new_string));
315
316        Ok(ToolResult {
317            output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
318            success: true,
319            metadata,
320        })
321    }
322}