use super::error::{Result, ToolError, validate_file_path};
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::fs;
pub struct EditTool;
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "operation")]
enum EditOperation {
#[serde(rename = "replace")]
Replace { old_text: String, new_text: String },
#[serde(rename = "replace_lines")]
ReplaceLines {
start_line: usize,
end_line: usize,
new_text: String,
},
#[serde(rename = "insert_line")]
InsertLine { line: usize, text: String },
#[serde(rename = "delete_lines")]
DeleteLines { start_line: usize, end_line: usize },
#[serde(rename = "regex_replace")]
RegexReplace {
pattern: String,
replacement: String,
},
}
#[derive(Debug, Deserialize, Serialize)]
struct EditInput {
path: String,
#[serde(flatten)]
operation: EditOperation,
}
impl EditTool {
fn normalize_input(mut value: Value) -> Value {
let Some(obj) = value.as_object_mut() else {
return value;
};
if !obj.contains_key("old_text")
&& let Some(v) = obj.remove("old_string")
{
obj.insert("old_text".to_string(), v);
}
if !obj.contains_key("new_text")
&& let Some(v) = obj.remove("new_string")
{
obj.insert("new_text".to_string(), v);
}
if !obj.contains_key("operation") {
let inferred = if obj.contains_key("old_text") && obj.contains_key("new_text") {
Some("replace")
} else if obj.contains_key("start_line")
&& obj.contains_key("end_line")
&& obj.contains_key("new_text")
{
Some("replace_lines")
} else if obj.contains_key("line") && obj.contains_key("text") {
Some("insert_line")
} else if obj.contains_key("start_line") && obj.contains_key("end_line") {
Some("delete_lines")
} else if obj.contains_key("pattern") && obj.contains_key("replacement") {
Some("regex_replace")
} else {
None
};
if let Some(op) = inferred {
obj.insert("operation".to_string(), Value::String(op.to_string()));
}
}
value
}
}
#[async_trait]
impl Tool for EditTool {
fn name(&self) -> &str {
"edit_file"
}
fn description(&self) -> &str {
"Edit a file intelligently using various operations: replace text, replace lines, insert lines, delete lines, or regex replace."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"operation": {
"type": "string",
"description": "Type of edit operation",
"enum": ["replace", "replace_lines", "insert_line", "delete_lines", "regex_replace"]
},
"old_text": {
"type": "string",
"description": "Text to find and replace (for 'replace' operation)"
},
"new_text": {
"type": "string",
"description": "Replacement text (for 'replace' and 'replace_lines' operations)"
},
"start_line": {
"type": "integer",
"description": "Starting line number (0-indexed, for line operations)",
"minimum": 0
},
"end_line": {
"type": "integer",
"description": "Ending line number (0-indexed, inclusive, for line operations)",
"minimum": 0
},
"line": {
"type": "integer",
"description": "Line number to insert at (0-indexed, for 'insert_line')",
"minimum": 0
},
"text": {
"type": "string",
"description": "Text to insert (for 'insert_line')"
},
"pattern": {
"type": "string",
"description": "Regex pattern to match (for 'regex_replace')"
},
"replacement": {
"type": "string",
"description": "Replacement text (for 'regex_replace')"
},
},
"required": ["path"],
"description": "If 'operation' is omitted but 'old_text' and 'new_text' are provided, 'replace' is inferred (Claude-style Edit shape)."
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::ReadFiles,
ToolCapability::WriteFiles,
ToolCapability::SystemModification,
]
}
fn requires_approval(&self) -> bool {
true }
fn validate_input(&self, input: &Value) -> Result<()> {
let normalized = Self::normalize_input(input.clone());
let _: EditInput = serde_json::from_value(normalized)
.map_err(|e| ToolError::InvalidInput(format!("Invalid input: {}", e)))?;
Ok(())
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let input: EditInput = serde_json::from_value(Self::normalize_input(input))?;
let path = match validate_file_path(&input.path, &context.working_dir()) {
Ok(p) => p,
Err(msg) => return Ok(ToolResult::error(msg)),
};
if super::brain_file_safety::is_protected_path(&path) {
return Ok(ToolResult::error(format!(
"Refusing to edit protected brain file '{}' with generic edit_file. \
Use the `write_opencrabs_file` tool instead. It enforces append-only \
writes, dedup-aware shrinking, and saves a `.bak` snapshot before every \
change.",
path.display()
)));
}
let content = fs::read_to_string(&path).await.map_err(ToolError::Io)?;
let new_content = match input.operation {
EditOperation::Replace { old_text, new_text } => {
if content.contains(&old_text) {
let count = content.matches(&old_text).count();
if count > 1 {
return Ok(ToolResult::error(format!(
"old_text appears {count} times as exact substring. \
Include more context to make a unique match."
)));
}
content.replacen(&old_text, &new_text, 1)
} else {
match super::fuzzy::fuzzy_replace_once(&content, &old_text, &new_text) {
Ok(new_content) => new_content,
Err(msg) => return Ok(ToolResult::error(msg)),
}
}
}
EditOperation::ReplaceLines {
start_line,
end_line,
new_text,
} => {
let lines: Vec<&str> = content.lines().collect();
if start_line >= lines.len() || end_line >= lines.len() {
return Ok(ToolResult::error(format!(
"Line range {}-{} out of bounds (file has {} lines)",
start_line,
end_line,
lines.len()
)));
}
if start_line > end_line {
return Ok(ToolResult::error(
"start_line must be <= end_line".to_string(),
));
}
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..start_line]);
new_lines.push(&new_text);
if end_line + 1 < lines.len() {
new_lines.extend_from_slice(&lines[end_line + 1..]);
}
new_lines.join("\n")
}
EditOperation::InsertLine { line, text } => {
let lines: Vec<&str> = content.lines().collect();
if line > lines.len() {
return Ok(ToolResult::error(format!(
"Line {} out of bounds (file has {} lines)",
line,
lines.len()
)));
}
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..line]);
new_lines.push(&text);
new_lines.extend_from_slice(&lines[line..]);
new_lines.join("\n")
}
EditOperation::DeleteLines {
start_line,
end_line,
} => {
let lines: Vec<&str> = content.lines().collect();
if start_line >= lines.len() || end_line >= lines.len() {
return Ok(ToolResult::error(format!(
"Line range {}-{} out of bounds (file has {} lines)",
start_line,
end_line,
lines.len()
)));
}
if start_line > end_line {
return Ok(ToolResult::error(
"start_line must be <= end_line".to_string(),
));
}
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..start_line]);
if end_line + 1 < lines.len() {
new_lines.extend_from_slice(&lines[end_line + 1..]);
}
new_lines.join("\n")
}
EditOperation::RegexReplace {
pattern,
replacement,
} => {
let regex = regex::Regex::new(&pattern)
.map_err(|e| ToolError::InvalidInput(format!("Invalid regex: {}", e)))?;
if !regex.is_match(&content) {
return Ok(ToolResult::error(format!(
"Pattern not found in file: '{}'",
pattern
)));
}
regex
.replace_all(&content, replacement.as_str())
.to_string()
}
};
fs::write(&path, &new_content)
.await
.map_err(ToolError::Io)?;
let lines_before = content.lines().count();
let lines_after = new_content.lines().count();
let diff = build_edit_diff(&content, &new_content);
let mut output = format!(
"Successfully edited {}. Lines: {} → {}\n",
path.display(),
lines_before,
lines_after
);
output.push_str(&diff);
Ok(ToolResult::success(output))
}
}
pub(crate) fn build_edit_diff(old: &str, new: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut diff = String::new();
let mut diff_lines = 0usize;
let max_diff_lines = 40;
let mut i = 0;
let mut j = 0;
while i < old_lines.len() || j < new_lines.len() {
if diff_lines >= max_diff_lines {
diff.push_str("... (diff truncated)\n");
break;
}
if i < old_lines.len() && j < new_lines.len() && old_lines[i] == new_lines[j] {
i += 1;
j += 1;
} else {
let new_ahead = new_lines[j..]
.iter()
.position(|l| i < old_lines.len() && *l == old_lines[i]);
let old_ahead = old_lines[i..]
.iter()
.position(|l| j < new_lines.len() && *l == new_lines[j]);
match (new_ahead, old_ahead) {
(Some(na), Some(oa)) if na <= oa => {
for line in &new_lines[j..j + na] {
diff.push_str(&format!("+ {}\n", line));
diff_lines += 1;
if diff_lines >= max_diff_lines {
break;
}
}
j += na;
}
(Some(_), Some(oa)) => {
for line in &old_lines[i..i + oa] {
diff.push_str(&format!("- {}\n", line));
diff_lines += 1;
if diff_lines >= max_diff_lines {
break;
}
}
i += oa;
}
(Some(na), None) => {
for line in &new_lines[j..j + na] {
diff.push_str(&format!("+ {}\n", line));
diff_lines += 1;
if diff_lines >= max_diff_lines {
break;
}
}
j += na;
}
(None, Some(oa)) => {
for line in &old_lines[i..i + oa] {
diff.push_str(&format!("- {}\n", line));
diff_lines += 1;
if diff_lines >= max_diff_lines {
break;
}
}
i += oa;
}
(None, None) => {
if i < old_lines.len() {
diff.push_str(&format!("- {}\n", old_lines[i]));
diff_lines += 1;
i += 1;
}
if diff_lines < max_diff_lines && j < new_lines.len() {
diff.push_str(&format!("+ {}\n", new_lines[j]));
diff_lines += 1;
j += 1;
}
}
}
}
}
diff
}