use super::{Tool, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use similar::{ChangeTag, TextDiff};
use tokio::fs;
pub struct EditTool;
impl Default for EditTool {
fn default() -> Self {
Self::new()
}
}
impl EditTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for EditTool {
fn id(&self) -> &str {
"edit"
}
fn name(&self) -> &str {
"Edit File"
}
fn description(&self) -> &str {
"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. \
Morph backend is available only when explicitly enabled with CODETETHER_MORPH_TOOL_BACKEND=1. \
Optional instruction/update can guide Morph behavior."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to edit"
},
"old_string": {
"type": "string",
"description": "The exact string to replace (must match exactly, including whitespace)"
},
"new_string": {
"type": "string",
"description": "The string to replace old_string with"
},
"instruction": {
"type": "string",
"description": "Optional Morph instruction."
},
"update": {
"type": "string",
"description": "Optional Morph update snippet."
}
},
"required": ["path"],
"example": {
"path": "src/main.rs",
"old_string": "fn old_function() {\n println!(\"old\");\n}",
"new_string": "fn new_function() {\n println!(\"new\");\n}",
"instruction": "Refactor while preserving behavior",
"update": "fn new_function() {\n// ...existing code...\n}"
}
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let path = match args["path"].as_str() {
Some(p) => p,
None => {
return Ok(ToolResult::structured_error(
"INVALID_ARGUMENT",
"edit",
"path is required",
Some(vec!["path"]),
Some(
json!({"path": "src/main.rs", "old_string": "old text", "new_string": "new text"}),
),
));
}
};
let old_string = args["old_string"].as_str();
let new_string = args["new_string"].as_str();
let instruction = args["instruction"].as_str();
let update = args["update"].as_str();
let morph_enabled = super::morph_backend::should_use_morph_backend();
let morph_requested = instruction.is_some() || update.is_some();
let has_replacement_pair = old_string.is_some() && new_string.is_some();
let use_morph = morph_enabled && morph_requested;
let content = fs::read_to_string(path).await?;
if use_morph {
let inferred_instruction = instruction
.map(str::to_string)
.or_else(|| {
old_string.zip(new_string).map(|(old, new)| {
format!(
"Replace the target snippet exactly once while preserving behavior.\nOld snippet:\n{old}\n\nNew snippet:\n{new}"
)
})
})
.unwrap_or_else(|| {
"Apply the requested update precisely and return only the updated file."
.to_string()
});
let inferred_update = update
.map(str::to_string)
.or_else(|| {
old_string.zip(new_string).map(|(old, new)| {
format!(
"// Replace this snippet:\n{old}\n// With this snippet:\n{new}\n// ...existing code..."
)
})
})
.unwrap_or_else(|| "// ...existing code...".to_string());
let morph_result = match super::morph_backend::apply_edit_with_morph(
&content,
&inferred_instruction,
&inferred_update,
)
.await
{
Ok(updated) => Some(updated),
Err(e) => {
if has_replacement_pair {
tracing::warn!(
path = %path,
error = %e,
"Morph backend failed for edit; falling back to exact replacement"
);
None
} else {
return Ok(ToolResult::structured_error(
"MORPH_BACKEND_FAILED",
"edit",
&e.to_string(),
None,
Some(json!({
"hint": "Configure OpenRouter provider credentials in Vault/provider registry, or supply old_string/new_string for exact replacement fallback"
})),
));
}
}
};
if let Some(new_content) = morph_result {
if new_content == content {
return Ok(ToolResult::structured_error(
"NO_OP",
"edit",
"Morph backend returned unchanged content.",
None,
Some(json!({"path": path})),
));
}
let diff = TextDiff::from_lines(&content, &new_content);
let mut diff_output = String::new();
let mut added = 0;
let mut removed = 0;
for change in diff.iter_all_changes() {
let (sign, style) = match change.tag() {
ChangeTag::Delete => {
removed += 1;
("-", "red")
}
ChangeTag::Insert => {
added += 1;
("+", "green")
}
ChangeTag::Equal => (" ", "default"),
};
let line = format!("{}{}", sign, change);
if style == "red" {
diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
} else if style == "green" {
diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
} else {
diff_output.push_str(line.trim_end());
}
diff_output.push('\n');
}
let mut metadata = std::collections::HashMap::new();
metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
metadata.insert("added_lines".to_string(), serde_json::json!(added));
metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
metadata.insert("path".to_string(), serde_json::json!(path));
metadata.insert("old_string".to_string(), serde_json::json!(content));
metadata.insert("new_string".to_string(), serde_json::json!(new_content));
metadata.insert("backend".to_string(), serde_json::json!("morph"));
return Ok(ToolResult {
output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
success: true,
metadata,
});
}
}
let old_string = match old_string {
Some(s) => s,
None => {
return Ok(ToolResult::structured_error(
"INVALID_ARGUMENT",
"edit",
"old_string is required unless Morph backend is enabled and instruction/update are provided",
Some(vec!["old_string"]),
Some(json!({"path": path, "old_string": "old text", "new_string": "new text"})),
));
}
};
let new_string = match new_string {
Some(s) => s,
None => {
return Ok(ToolResult::structured_error(
"INVALID_ARGUMENT",
"edit",
"new_string is required unless Morph backend is enabled and instruction/update are provided",
Some(vec!["new_string"]),
Some(json!({"path": path, "old_string": old_string, "new_string": "new text"})),
));
}
};
let count = content.matches(old_string).count();
if count == 0 {
return Ok(ToolResult::structured_error(
"NOT_FOUND",
"edit",
"old_string not found in file. Make sure it matches exactly, including whitespace.",
None,
Some(json!({
"hint": "Use the 'read' tool first to see the exact content of the file",
"path": path,
"old_string": "<copy exact text from file including whitespace>",
"new_string": "replacement text"
})),
));
}
if count > 1 {
return Ok(ToolResult::structured_error(
"AMBIGUOUS_MATCH",
"edit",
&format!(
"old_string found {} times. Include more context to uniquely identify the location.",
count
),
None,
Some(json!({
"hint": "Include 3+ lines of context before and after the target text",
"matches_found": count
})),
));
}
let new_content = content.replacen(old_string, new_string, 1);
let diff = TextDiff::from_lines(&content, &new_content);
let mut diff_output = String::new();
let mut added = 0;
let mut removed = 0;
for change in diff.iter_all_changes() {
let (sign, style) = match change.tag() {
ChangeTag::Delete => {
removed += 1;
("-", "red")
}
ChangeTag::Insert => {
added += 1;
("+", "green")
}
ChangeTag::Equal => (" ", "default"),
};
let line = format!("{}{}", sign, change);
if style == "red" {
diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
} else if style == "green" {
diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
} else {
diff_output.push_str(line.trim_end());
}
diff_output.push('\n');
}
let mut metadata = std::collections::HashMap::new();
metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
metadata.insert("added_lines".to_string(), serde_json::json!(added));
metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
metadata.insert("path".to_string(), serde_json::json!(path));
metadata.insert("old_string".to_string(), serde_json::json!(old_string));
metadata.insert("new_string".to_string(), serde_json::json!(new_string));
Ok(ToolResult {
output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
success: true,
metadata,
})
}
}