1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use similar::{ChangeTag, TextDiff};
8use tokio::fs;
9
10pub 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 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 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 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 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}