use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
fn deserialize_lenient_usize<'de, D>(
deserializer: D,
) -> std::result::Result<Option<usize>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct LenientUsize;
impl<'de> de::Visitor<'de> for LenientUsize {
type Value = Option<usize>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a usize or a string containing a usize")
}
fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
Ok(Some(v as usize))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
if v >= 0 {
Ok(Some(v as usize))
} else {
Err(de::Error::custom("negative line number"))
}
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
Ok(Some(v as usize))
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
v.trim()
.parse::<usize>()
.map(Some)
.map_err(de::Error::custom)
}
}
deserializer.deserialize_any(LenientUsize)
}
async fn atomic_write(path: &str, content: &str) -> Result<()> {
let temp = format!("{}.atomcode.tmp", path);
tokio::fs::write(&temp, content)
.await
.with_context(|| format!("Failed to write temp file {}", temp))?;
match tokio::fs::rename(&temp, path).await {
Ok(()) => Ok(()),
Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
match tokio::fs::rename(&temp, path).await {
Ok(()) => Ok(()),
Err(_) => {
let _ = tokio::fs::remove_file(&temp).await;
tokio::fs::write(path, content)
.await
.with_context(|| format!("Failed to write {}", path))?;
Ok(())
}
}
}
}
}
use super::auto_fix;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
async fn validate_write_check(
content: &str,
file_path: &str,
new_string: &str,
original_content: &str,
result: ToolResult,
ctx: &ToolContext,
) -> Result<(ToolResult, String)> {
if !result.success {
return Ok((result, content.to_string()));
}
let validated =
auto_fix::validate_and_fix(content, file_path, new_string, original_content).await;
if validated.rejected {
let errors = validated.warnings.join("\n");
return Ok((
ToolResult {
call_id: result.call_id,
output: format!(
"EDIT REJECTED — duplicate code detected:\n{}\n\
Fix your new_string and retry. The file was NOT modified.",
errors
),
success: false,
},
content.to_string(),
));
}
atomic_write(file_path, &validated.fixed_content).await?;
let raw_path = std::path::Path::new(file_path);
let canon_path = tokio::fs::canonicalize(raw_path)
.await
.unwrap_or_else(|_| raw_path.to_path_buf());
ctx.notify_lsp_file_changed(&canon_path, &validated.fixed_content)
.await;
ctx.file_store.write().await.invalidate(&canon_path);
ctx.read_cache
.write()
.await
.retain(|(p, _, _), _| p != &canon_path);
let syntax_warn = auto_fix::post_edit_syntax_check(file_path).await;
let mut all_warnings: Vec<String> = validated.warnings;
if !syntax_warn.is_empty() {
all_warnings.push(syntax_warn);
}
let context_snippet = build_edit_context(&validated.fixed_content, new_string);
let result = ToolResult {
output: format!("{}{}", result.output, context_snippet),
..result
};
if all_warnings.is_empty() {
Ok((result, validated.fixed_content))
} else {
let combined = all_warnings.join("");
Ok((
ToolResult {
output: format!("{}{}", result.output, combined),
..result
},
validated.fixed_content,
))
}
}
pub struct EditFileTool;
#[derive(Deserialize)]
struct EditFileArgs {
file_path: String,
#[serde(default)]
old_string: Option<String>,
#[serde(default)]
new_string: Option<String>,
#[serde(default)]
replace_all: bool,
#[serde(default)]
symbol: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_usize")]
start_line: Option<usize>,
#[serde(default, deserialize_with = "deserialize_lenient_usize")]
end_line: Option<usize>,
#[serde(default)]
edits: Option<Vec<SingleEdit>>,
}
#[derive(Deserialize)]
struct SingleEdit {
#[serde(default, deserialize_with = "deserialize_lenient_usize")]
start_line: Option<usize>,
#[serde(default, deserialize_with = "deserialize_lenient_usize")]
end_line: Option<usize>,
#[serde(default)]
old_string: Option<String>,
new_string: String,
}
#[async_trait]
impl Tool for EditFileTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "edit_file",
description: "Replace text in a file. ALWAYS prefer this over write_file for existing files.\n\
Two modes:\n\
1. Line mode: use start_line + end_line + new_string. Line numbers from read_file or grep output.\n\
2. Text mode: use old_string + new_string. old_string must match exactly.\n\
Both modes work. Use whichever is faster — if grep already showed the code, edit directly.\n\
For multiple changes in one file: make separate edit_file calls, one per region.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to edit"
},
"old_string": {
"type": "string",
"description": "Text mode: exact text to find and replace. Include enough context to be unique."
},
"new_string": {
"type": "string",
"description": "Replacement text. Use empty string to delete."
},
"start_line": {
"type": "integer",
"description": "Line mode: first line to replace (1-indexed, from read_file output)"
},
"end_line": {
"type": "integer",
"description": "Line mode: last line to replace (inclusive)"
},
"replace_all": {
"type": "boolean",
"description": "Replace ALL occurrences (default: first only). Only for text mode."
}
},
"required": ["file_path"]
}),
}
}
fn validate_args(&self, args: &str) -> std::result::Result<(), String> {
super::diagnose_args(
"edit_file",
args,
&[&["file_path"]],
"edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"}) \
— text mode; or use start_line+end_line+new_string for line mode",
)?;
let parsed: EditFileArgs = serde_json::from_str(args).map_err(|e| {
format!(
"edit_file: {e}. Check that file_path is a string, line numbers are integers, \
and old_string/new_string are strings."
)
})?;
let has_string_mode = parsed.old_string.is_some() || parsed.new_string.is_some();
let has_line_mode = parsed.start_line.is_some() || parsed.end_line.is_some();
let has_edits = parsed.edits.is_some();
let has_symbol = parsed.symbol.is_some();
if !has_string_mode && !has_line_mode && !has_edits && !has_symbol {
return Err(
"edit_file arguments missing edit content. Provide `old_string`+`new_string`, \
`start_line`+`end_line`+`new_string`, an `edits` array, or `symbol`+`new_string`."
.to_string(),
);
}
Ok(())
}
fn approval(&self, args: &str) -> ApprovalRequirement {
let parsed = match serde_json::from_str::<EditFileArgs>(args) {
Ok(p) => p,
Err(_) => {
return ApprovalRequirement::RequireApproval(
"Could not parse edit_file arguments for safety check.".to_string(),
);
}
};
if super::is_sensitive_input_path(&parsed.file_path) {
return ApprovalRequirement::RequireApproval(
format!("Editing sensitive system path: {}", parsed.file_path),
);
}
ApprovalRequirement::AutoApprove
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
let base = self.approval(args);
let parsed = match serde_json::from_str::<EditFileArgs>(args) {
Ok(parsed) => parsed,
Err(_) => return base,
};
let working_dir = match ctx.working_dir.try_read() {
Ok(wd) => wd.clone(),
Err(_) => return base,
};
match super::approval_for_path(
&parsed.file_path,
&working_dir,
super::ExternalPathAction::Write,
) {
Ok(ApprovalRequirement::RequireApprovalAlways(reason)) => {
ApprovalRequirement::RequireApprovalAlways(reason)
}
Ok(ApprovalRequirement::RequireApproval(reason)) => {
ApprovalRequirement::RequireApproval(reason)
}
Ok(ApprovalRequirement::AutoApprove) => match base {
ApprovalRequirement::RequireApproval(reason) => {
ApprovalRequirement::RequireApprovalAlways(reason)
}
other => other,
},
Err(_) => base,
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
if let Err(msg) = super::diagnose_args(
"edit_file",
args,
&[&["file_path"]],
"edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"})",
) {
return Ok(ToolResult {
call_id: String::new(),
output: msg,
success: false,
});
}
let parsed: EditFileArgs = match serde_json::from_str(args) {
Ok(p) => p,
Err(e) => {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"edit_file: {e}. Check that file_path is a string, line numbers are \
integers, and old_string/new_string are strings."
),
success: false,
});
}
};
let working_dir = ctx.working_dir.read().await.clone();
let file_path = match super::inspect_path_access(&parsed.file_path, &working_dir) {
Ok(access) => access.path,
Err(err) => {
return Ok(ToolResult {
call_id: String::new(),
output: err.to_string(),
success: false,
});
}
};
let file_path_str = file_path.to_string_lossy().to_string();
ctx.file_history
.lock()
.await
.backup_before_write(&file_path_str)
.await;
let content = tokio::fs::read_to_string(&file_path)
.await
.with_context(|| format!("Failed to read {}", file_path.display()))?;
if let Some(edits) = parsed.edits {
if edits.is_empty() {
return Ok(ToolResult {
call_id: String::new(),
output: "Error: edits array is empty.".to_string(),
success: false,
});
}
return self
.execute_multi_edit(&file_path_str, &content, edits, ctx)
.await;
}
let new_string = match parsed.new_string {
Some(s) => s,
None => {
return Ok(ToolResult {
call_id: String::new(),
output: "Error: missing new_string.\n\
To REPLACE: edit_file({file_path, old_string: \"old code\", new_string: \"new code\"})\n\
To DELETE: edit_file({file_path, old_string: \"old code\", new_string: \"\"})\n\
You MUST include new_string in every edit_file call.".to_string(),
success: false,
});
}
};
if let (Some(mut start), Some(mut end)) = (parsed.start_line, parsed.end_line) {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
if end < start {
std::mem::swap(&mut start, &mut end);
}
if start == 0 || start > total {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Invalid line range: {}-{} (file has {} lines)",
start, end, total
),
success: false,
});
}
let mut end = end.min(total);
let ns_lines: Vec<&str> = new_string.lines().collect();
if !ns_lines.is_empty() {
let mut extra = 0usize;
for i in 0..ns_lines.len() {
let ns_idx = ns_lines.len() - 1 - i;
let orig_idx = end + extra;
if orig_idx >= total {
break;
}
if ns_lines[ns_idx].trim() == lines[orig_idx].trim()
&& !ns_lines[ns_idx].trim().is_empty()
{
extra += 1;
} else {
break;
}
}
if extra > 0 {
end = (end + extra).min(total);
}
}
if !ns_lines.is_empty() {
let mut extra = 0usize;
for i in 0..ns_lines.len() {
if start <= 1 + extra {
break;
}
let orig_idx = start - 2 - extra;
if ns_lines[i].trim() == lines[orig_idx].trim()
&& !ns_lines[i].trim().is_empty()
{
extra += 1;
} else {
break;
}
}
if extra > 0 {
start = start.saturating_sub(extra).max(1);
}
}
let old_text: String = lines[start - 1..end].join("\n");
let removed = end - start + 1;
let added = new_string.lines().count();
let ext = parsed.file_path.rsplit('.').next().unwrap_or("");
let _large_edit_warning =
if removed > 50 && matches!(ext, "vue" | "html" | "svelte" | "tsx" | "jsx") {
format!(
"\n⚠ Large edit ({} lines replaced). Verify HTML tag balance after this edit.",
removed
)
} else {
String::new()
};
let mut new_lines: Vec<&str> = Vec::with_capacity(total);
new_lines.extend_from_slice(&lines[..start - 1]);
let new_content_lines: Vec<&str> = new_string.lines().collect();
new_lines.extend_from_slice(&new_content_lines);
if end < total {
new_lines.extend_from_slice(&lines[end..]);
}
let new_content = if content.ends_with('\n') {
format!("{}\n", new_lines.join("\n"))
} else {
new_lines.join("\n")
};
let diff = build_compact_diff(&old_text, &new_string);
let _new_end = start + added.saturating_sub(1);
let result = ToolResult {
call_id: String::new(),
output: format!(
"Edited {} lines {}-{} (-{} +{} lines).\n{}",
parsed.file_path, start, end, removed, added, diff
),
success: true,
};
let (result, _final_content) = validate_write_check(
&new_content,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
return Ok(result);
}
let old_string = match parsed.old_string {
Some(ref s) if !s.is_empty() => s.clone(),
_ => {
return Ok(ToolResult {
call_id: String::new(),
output: "Error: old_string is required for editing existing files. \
Provide the exact text you want to replace, or use start_line/end_line for line-based editing.".to_string(),
success: false,
});
}
};
if let Some(ref symbol_name) = parsed.symbol {
let path = file_path.as_path();
let mut searcher = ctx.semantic.lock().await;
if let Some(slice) = searcher.extract_symbol(path, symbol_name) {
let sym_text = &content[slice.start_byte..slice.end_byte];
let sym_count = sym_text.matches(&old_string).count();
if sym_count == 0 {
let (hint, _) = find_closest_match_with_suggestion(sym_text, &old_string);
let reread = auto_reread_content(&content, &old_string);
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error: old_string not found in symbol '{}' (lines {}-{}).\n{}\n{}\n\
[HINT: Copy the EXACT text from the returned content as your new old_string.]",
symbol_name, slice.start_line, slice.end_line, hint, reread
),
success: false,
});
}
if !parsed.replace_all && sym_count > 1 {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error: old_string found {} times in symbol '{}'. Use replace_all=true or provide more context.",
sym_count, symbol_name
),
success: false,
});
}
let new_sym_text = if parsed.replace_all {
sym_text.replace(&old_string, &new_string)
} else {
sym_text.replacen(&old_string, &new_string, 1)
};
let new_content = format!(
"{}{}{}",
&content[..slice.start_byte],
new_sym_text,
&content[slice.end_byte..]
);
let diff = build_compact_diff(&old_string, &new_string);
let label = if parsed.replace_all {
format!("replaced {} occurrences in {}", sym_count, symbol_name)
} else {
format!(
"in {} (lines {}-{})",
symbol_name, slice.start_line, slice.end_line
)
};
let result = ToolResult {
call_id: String::new(),
output: format!("Edited {} {}.\n{}", parsed.file_path, label, diff),
success: true,
};
let (result, _final_content) = validate_write_check(
&new_content,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
drop(searcher);
let mut searcher = ctx.semantic.lock().await;
searcher.invalidate(path);
return Ok(result);
} else {
let hint = match searcher.list_symbols(path) {
Some(syms) => {
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
format!(
"Symbol '{}' not found. Available: {}",
symbol_name,
names.join(", ")
)
}
None => format!("Symbol '{}' not found in {}", symbol_name, parsed.file_path),
};
return Ok(ToolResult {
call_id: String::new(),
output: hint,
success: false,
});
}
}
let count = content.matches(&old_string).count();
if count == 0 {
if let Some((fuzzy_result, fuzzy_count)) =
try_fuzzy_replace(&content, &old_string, &new_string, parsed.replace_all)
{
let diff = build_compact_diff(&old_string, &new_string);
let result = ToolResult {
call_id: String::new(),
output: format!(
"Edited {} (fuzzy match, {} occurrence{}).\n{}",
parsed.file_path,
fuzzy_count,
if fuzzy_count > 1 { "s" } else { "" },
diff
),
success: true,
};
let (result, _final_content) = validate_write_check(
&fuzzy_result,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
return Ok(result);
}
let (hint, _suggested_old) = find_closest_match_with_suggestion(&content, &old_string);
let line_hint = {
let old_first = old_string
.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim());
let lines: Vec<&str> = content.lines().collect();
old_first
.and_then(|needle| {
lines
.iter()
.position(|l| l.trim().contains(needle))
.map(|center| {
let old_line_count = old_string.lines().count();
let start = center + 1;
let end = (center + old_line_count).min(lines.len());
format!(
"\n[TIP: Use line mode instead — edit_file(file_path=\"{}\", \
start_line={}, end_line={}, new_string=\"...\")]",
parsed.file_path, start, end
)
})
})
.unwrap_or_default()
};
let reread = auto_reread_content(&content, &old_string);
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error: old_string not found in {}.\n{}{}\n{}\n\
[HINT: old_string did not match. The file content around your target has been \
returned above. Copy the EXACT text from the returned content as your new old_string.]\n\
[Do not fall back to shell file modification (in-place editors, redirects, \
write scripts) — re-issue edit_file with the corrected old_string so the \
change is tracked and reversible via /undo.]",
parsed.file_path, hint, line_hint, reread
),
success: false,
});
}
let old_lines = old_string.lines().count();
let new_lines = new_string.lines().count();
let net_deleted = old_lines.saturating_sub(new_lines);
let _deletion_warning = if net_deleted > 10 {
format!(
"\nWARNING: You removed {} more lines than you added. If you only meant to ADD a skeleton/loading section, \
use v-if/v-else to show it ALONGSIDE the existing content, not INSTEAD of it.",
net_deleted
)
} else {
String::new()
};
if parsed.replace_all {
let _replace_warning = if count > 10 {
format!(
"\nWARNING: Replaced {} occurrences. This many replacements may have changed structural \
elements (tags, brackets) that should not be bulk-replaced. Verify the file structure.",
count
)
} else {
String::new()
};
let new_content = content.replace(&old_string, &new_string);
let diff = build_compact_diff(&old_string, &new_string);
let result = ToolResult {
call_id: String::new(),
output: format!(
"Edited {} (replaced {} occurrence{}).\n{}",
parsed.file_path,
count,
if count > 1 { "s" } else { "" },
diff,
),
success: true,
};
let (result, _final_content) = validate_write_check(
&new_content,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
Ok(result)
} else {
if count > 1 {
let path = file_path.as_path();
let mut searcher = ctx.semantic.lock().await;
if let Some(symbols) = searcher.list_symbols(path) {
let matching_syms: Vec<&crate::semantic::Symbol> = symbols
.iter()
.filter(|sym| {
let sym_text =
&content[sym.start_byte..sym.end_byte.min(content.len())];
sym_text.contains(&*old_string)
})
.collect();
if matching_syms.len() == 1 {
let sym = matching_syms[0];
let sym_text = &content[sym.start_byte..sym.end_byte.min(content.len())];
let new_sym = sym_text.replacen(&*old_string, &new_string, 1);
let new_content = format!(
"{}{}{}",
&content[..sym.start_byte],
new_sym,
&content[sym.end_byte.min(content.len())..]
);
drop(searcher);
let diff = build_compact_diff(&old_string, &new_string);
let result = ToolResult {
call_id: String::new(),
output: format!(
"Edited {} in {}() (auto-scoped, {} global matches).\n{}",
parsed.file_path, sym.name, count, diff
),
success: true,
};
let (result, _final_content) = validate_write_check(
&new_content,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
return Ok(result);
}
}
drop(searcher);
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error: old_string found {} times in {}. Use replace_all=true to replace all, or provide more context to make it unique.",
count, parsed.file_path
),
success: false,
});
}
let new_content = content.replacen(&old_string, &new_string, 1);
let removed = old_string.lines().count();
let added = new_string.lines().count();
let diff = build_compact_diff(&old_string, &new_string);
let result = ToolResult {
call_id: String::new(),
output: format!(
"Edited {} (-{} +{} lines).\n{}",
parsed.file_path, removed, added, diff,
),
success: true,
};
let (result, _final_content) = validate_write_check(
&new_content,
&file_path_str,
&new_string,
&content,
result,
ctx,
)
.await?;
Ok(result)
}
}
}
impl EditFileTool {
async fn execute_multi_edit(
&self,
file_path: &str,
content: &str,
edits: Vec<SingleEdit>,
ctx: &ToolContext,
) -> Result<ToolResult> {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let mut resolved: Vec<(usize, usize, String)> = Vec::with_capacity(edits.len());
for (i, edit) in edits.iter().enumerate() {
if let (Some(start), Some(end)) = (edit.start_line, edit.end_line) {
let (start, end) = if end < start {
(end, start)
} else {
(start, end)
};
if start == 0 || start > total {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error in edit #{}: invalid line range {}-{} (file has {} lines)",
i + 1,
start,
end,
total
),
success: false,
});
}
resolved.push((start, end.min(total), edit.new_string.clone()));
} else if let Some(ref old_str) = edit.old_string {
if old_str.is_empty() {
return Ok(ToolResult {
call_id: String::new(),
output: format!("Error in edit #{}: old_string is empty", i + 1),
success: false,
});
}
match find_text_line_range(content, old_str) {
Some((start, end)) => {
resolved.push((start, end, edit.new_string.clone()));
}
None => {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error in edit #{}: old_string not found in {}.\nSearched for: {:?}",
i + 1, file_path, old_str.lines().next().unwrap_or("")
),
success: false,
});
}
}
} else {
return Ok(ToolResult {
call_id: String::new(),
output: format!(
"Error in edit #{}: must specify start_line/end_line or old_string",
i + 1
),
success: false,
});
}
}
for (start, end, new_str) in &mut resolved {
let new_lines: Vec<&str> = new_str.lines().collect();
if new_lines.is_empty() {
continue;
}
let mut trail_extra = 0usize;
for i in 0..new_lines.len() {
let new_idx = new_lines.len() - 1 - i;
let orig_idx = *end + trail_extra;
if orig_idx >= total {
break;
}
if new_lines[new_idx].trim() == lines[orig_idx].trim()
&& !new_lines[new_idx].trim().is_empty()
{
trail_extra += 1;
} else {
break;
}
}
if trail_extra > 0 {
*end = (*end + trail_extra).min(total);
}
let mut lead_extra = 0usize;
for i in 0..new_lines.len() {
if *start <= 1 + lead_extra {
break;
}
let orig_idx = *start - 2 - lead_extra;
if new_lines[i].trim() == lines[orig_idx].trim() && !new_lines[i].trim().is_empty()
{
lead_extra += 1;
} else {
break;
}
}
if lead_extra > 0 {
*start = start.saturating_sub(lead_extra).max(1);
}
}
resolved.sort_by_key(|(start, _, _)| *start);
let mut merged: Vec<(usize, usize, String)> = Vec::new();
for edit in resolved {
if let Some(last) = merged.last_mut() {
if edit.0 <= last.1 {
if edit.1 >= last.1 {
last.1 = edit.1;
last.2 = edit.2;
}
continue;
}
}
merged.push(edit);
}
let mut resolved = merged;
resolved.sort_by(|a, b| b.0.cmp(&a.0));
let mut result_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
let mut summary_parts: Vec<String> = Vec::new();
let _ext = file_path.rsplit('.').next().unwrap_or("");
if false {
}
for (start, end, new_str) in &resolved {
let removed = end - start + 1;
let new_edit_lines: Vec<String> = new_str.lines().map(|l| l.to_string()).collect();
let added = new_edit_lines.len();
result_lines.splice((start - 1)..*end, new_edit_lines);
summary_parts.push(format!("L{}-{} (-{} +{})", start, end, removed, added));
}
summary_parts.reverse();
let new_content = if content.ends_with('\n') {
format!("{}\n", result_lines.join("\n"))
} else {
result_lines.join("\n")
};
let edit_count = resolved.len();
let all_new_strings: String = edits
.iter()
.map(|e| e.new_string.as_str())
.collect::<Vec<_>>()
.join("\n");
let short_name = std::path::Path::new(file_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
let result = ToolResult {
call_id: String::new(),
output: format!(
"Multi-edit: {} edits applied to {} [{}].\n\u{2713} {} updated. Proceed to your next file.",
edit_count, file_path, summary_parts.join(", "), short_name),
success: true,
};
let (result, _final_content) =
validate_write_check(&new_content, file_path, &all_new_strings, content, result, ctx)
.await?;
Ok(result)
}
}
fn find_text_line_range(content: &str, needle: &str) -> Option<(usize, usize)> {
let needle_lines: Vec<&str> = needle.lines().collect();
if needle_lines.is_empty() {
return None;
}
let content_lines: Vec<&str> = content.lines().collect();
let mut matches: Vec<usize> = Vec::new();
for i in 0..content_lines.len().saturating_sub(needle_lines.len() - 1) {
if content_lines[i..i + needle_lines.len()] == needle_lines[..] {
matches.push(i);
}
}
if matches.is_empty() {
let needle_trimmed: Vec<&str> = needle_lines.iter().map(|l| l.trim()).collect();
for i in 0..content_lines.len().saturating_sub(needle_trimmed.len() - 1) {
let window: Vec<&str> = content_lines[i..i + needle_trimmed.len()]
.iter()
.map(|l| l.trim())
.collect();
if window == needle_trimmed {
matches.push(i);
}
}
}
if matches.len() == 1 {
let start = matches[0] + 1;
let end = start + needle_lines.len() - 1;
Some((start, end))
} else {
None
}
}
fn try_fuzzy_replace(
content: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Option<(String, usize)> {
let old_normalized: Vec<&str> = old_string.lines().map(|l| l.trim()).collect();
if old_normalized.is_empty() || old_normalized.iter().all(|l| l.is_empty()) {
return None;
}
let content_lines: Vec<&str> = content.lines().collect();
let has_trailing_newline = content.ends_with('\n');
let mut matches: Vec<(usize, usize)> = Vec::new();
let total_non_ws: usize = old_normalized.iter().map(|l| l.len()).sum();
if total_non_ws < 10 {
return None;
}
let mut i = 0;
while i + old_normalized.len() <= content_lines.len() {
let window: Vec<&str> = content_lines[i..i + old_normalized.len()]
.iter()
.map(|l| l.trim())
.collect();
if window == old_normalized {
matches.push((i, i + old_normalized.len()));
i += old_normalized.len();
} else {
i += 1;
}
}
if matches.is_empty() {
return None;
}
if !replace_all && matches.len() > 1 {
return None;
}
let new_lines: Vec<&str> = new_string.lines().collect();
let new_base_indent = new_lines.iter()
.find(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.unwrap_or(0);
let mut result_lines: Vec<String> = content_lines.iter().map(|l| l.to_string()).collect();
let to_replace = if replace_all {
&matches[..]
} else {
&matches[..1]
};
for &(start, end) in to_replace.iter().rev() {
let original_line = content_lines[start];
let file_indent = original_line.len() - original_line.trim_start().len();
let file_indent_str: String = original_line.chars().take(file_indent).collect();
let replacement: Vec<String> = new_lines.iter().map(|l| {
if l.trim().is_empty() {
String::new()
} else {
let line_indent = l.len() - l.trim_start().len();
let signed_relative = line_indent as isize - new_base_indent as isize;
let total_indent = if signed_relative >= 0 {
format!("{}{}", file_indent_str, " ".repeat(signed_relative as usize))
} else {
let drop = (-signed_relative) as usize;
let keep = file_indent.saturating_sub(drop);
file_indent_str.chars().take(keep).collect()
};
format!("{}{}", total_indent, l.trim())
}
}).collect();
result_lines.splice(start..end, replacement);
}
let mut result = result_lines.join("\n");
if has_trailing_newline && !result.ends_with('\n') {
result.push('\n');
}
let count = if replace_all { matches.len() } else { 1 };
Some((result, count))
}
fn build_compact_diff(old: &str, new: &str) -> String {
let mut diff = String::new();
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let max_show = 4;
for (i, line) in old_lines.iter().take(max_show).enumerate() {
diff.push_str(&format!("- {}\n", line));
if i == max_show - 1 && old_lines.len() > max_show {
diff.push_str(&format!(
" ... ({} more removed)\n",
old_lines.len() - max_show
));
}
}
for (i, line) in new_lines.iter().take(max_show).enumerate() {
diff.push_str(&format!("+ {}\n", line));
if i == max_show - 1 && new_lines.len() > max_show {
diff.push_str(&format!(
" ... ({} more added)\n",
new_lines.len() - max_show
));
}
}
diff.trim_end().to_string()
}
fn build_edit_context(content: &str, new_string: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= 20 {
return String::new();
}
let new_trimmed = new_string.trim();
if new_trimmed.is_empty() {
return String::new();
}
let search_line = new_trimmed
.lines()
.find(|l| l.trim().len() >= 5)
.unwrap_or("");
if search_line.is_empty() {
return String::new();
}
let center = match lines.iter().position(|l| l.contains(search_line.trim())) {
Some(idx) => idx,
None => return String::new(),
};
let ctx = 4;
let new_lines_count = new_string.lines().count();
let start = center.saturating_sub(ctx);
let end = (center + new_lines_count + ctx).min(lines.len());
let mut snippet = format!("\n[File after edit, lines {}-{}:]\n", start + 1, end);
for (i, line) in lines[start..end].iter().enumerate() {
snippet.push_str(&format!("{:>4}| {}\n", start + i + 1, line));
}
snippet
}
fn auto_reread_content(content: &str, old_string: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
if total == 0 {
return String::new();
}
let mut out = String::new();
let target_line = old_string
.lines()
.find(|l| !l.trim().is_empty())
.map(|first| first.trim());
let center = target_line
.and_then(|needle| lines.iter().position(|l| l.trim().contains(needle)))
.unwrap_or(0);
if total <= 100 {
out.push_str(&format!(
"\n[Edit failed. Full file ({} lines) — copy EXACT text for old_string:]\n",
total
));
for (i, line) in lines.iter().enumerate() {
out.push_str(&format!("{:>4}| {}\n", i + 1, line));
}
} else {
let half = if total <= 300 { 15 } else { 7 };
let start = center.saturating_sub(half);
let end = (center + half + 1).min(total);
out.push_str(&format!(
"\n[Edit failed. Lines {}-{} of {} (use EXACT text from below as old_string):]\n",
start + 1,
end,
total
));
for i in start..end {
out.push_str(&format!("{:>4}| {}\n", i + 1, lines[i]));
}
}
out
}
fn find_closest_match_with_suggestion(content: &str, old_string: &str) -> (String, Option<String>) {
let old_lines: Vec<&str> = old_string.lines().collect();
let content_lines: Vec<&str> = content.lines().collect();
if old_lines.is_empty() {
return (
"old_string is empty. Use read_file to re-read the file.".to_string(),
None,
);
}
let old_first_trimmed = old_lines[0].trim();
if old_first_trimmed.is_empty() && old_lines.len() > 1 {
let hint = find_closest_match(content, old_string);
return (hint, None);
}
for (i, line) in content_lines.iter().enumerate() {
if line.trim() == old_first_trimmed {
let end = (i + old_lines.len()).min(content_lines.len());
let actual_lines = &content_lines[i..end];
let matching = actual_lines
.iter()
.zip(old_lines.iter())
.filter(|(a, b)| a.trim() == b.trim())
.count();
if matching >= old_lines.len() / 3 || matching >= 2 {
let suggested = actual_lines.join("\n");
let hint = find_closest_match(content, old_string);
return (hint, Some(suggested));
}
}
}
let hint = find_closest_match(content, old_string);
(hint, None)
}
fn find_closest_match(content: &str, old_string: &str) -> String {
let old_lines: Vec<&str> = old_string.lines().collect();
let content_lines: Vec<&str> = content.lines().collect();
if old_lines.is_empty() {
return "old_string is empty. Use read_file to re-read the file.".to_string();
}
let old_first_trimmed = old_lines[0].trim();
if old_first_trimmed.is_empty() && old_lines.len() > 1 {
return find_closest_match_inner(content, &content_lines, old_lines[1].trim(), &old_lines);
}
find_closest_match_inner(content, &content_lines, old_first_trimmed, &old_lines)
}
fn find_closest_match_inner(
_content: &str,
content_lines: &[&str],
first_line_trimmed: &str,
old_lines: &[&str],
) -> String {
if first_line_trimmed.is_empty() {
return "old_string appears empty after trimming. Use read_file to re-read the file."
.to_string();
}
let mut candidates: Vec<(usize, usize)> = Vec::new();
for (i, line) in content_lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == first_line_trimmed {
let mut match_count = 1;
for j in 1..old_lines.len() {
if i + j >= content_lines.len() {
break;
}
if content_lines[i + j].trim() == old_lines[j].trim() {
match_count += 1;
} else {
break;
}
}
candidates.push((i, match_count));
}
else if trimmed.len() >= 4
&& first_line_trimmed.len() >= 4
&& (trimmed.contains(first_line_trimmed) || first_line_trimmed.contains(trimmed))
{
candidates.push((i, 0));
}
else if trimmed.len() > 15
&& first_line_trimmed.len() > 15
&& trimmed.chars().take(25).collect::<String>()
== first_line_trimmed.chars().take(25).collect::<String>()
{
candidates.push((i, 0));
}
}
candidates.sort_by(|a, b| b.1.cmp(&a.1));
if let Some(&(best_idx, match_count)) = candidates.first() {
let start = best_idx.saturating_sub(1);
let end = (best_idx + old_lines.len().min(18) + 2).min(content_lines.len());
let mut snippet = String::new();
for i in start..end {
snippet.push_str(&format!("{:>4}| {}\n", i + 1, content_lines[i]));
}
if best_idx + old_lines.len() + 2 > end {
snippet.push_str(&format!(
" ... ({} more lines in file)\n",
content_lines.len() - end
));
}
if match_count > 0
&& match_count < old_lines.len()
&& best_idx + match_count < content_lines.len()
{
let diverge_idx = match_count;
let file_line = content_lines[best_idx + diverge_idx].trim();
let old_line = old_lines[diverge_idx].trim();
let file_indent =
content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
let mut hint = format!(
"First {} line(s) match (trimmed) but line {} diverges:\n\
YOUR old_string line {}: \"{}\"\n\
ACTUAL file line {}: \"{}\"\n",
match_count,
diverge_idx + 1,
diverge_idx + 1,
old_line,
best_idx + diverge_idx + 1,
file_line,
);
if file_indent != old_indent {
hint.push_str(&format!(
"INDENTATION MISMATCH: file uses {} spaces, your old_string uses {} spaces.\n",
file_indent, old_indent,
));
}
return format!(
"Partial match at lines {}-{} ({}/{} lines match).\n{}\n{}\n\
Copy the EXACT text from above (including indentation) for old_string.",
best_idx + 1,
end,
match_count,
old_lines.len(),
snippet,
hint
);
}
if match_count == 0 {
let file_indent =
content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
if file_indent != old_indent && content_lines[best_idx].trim() == old_lines[0].trim() {
return format!(
"INDENTATION MISMATCH at line {}. File uses {} spaces, your old_string uses {} spaces.\n\
Actual file content:\n{}\n\
Copy the EXACT text (with correct indentation) for old_string.",
best_idx + 1, file_indent, old_indent, snippet
);
}
}
return format!(
"Closest match found near line {}:\n{}\n\
Copy the EXACT text from above for old_string (preserve indentation).",
best_idx + 1,
snippet
);
}
let keywords: Vec<&str> = first_line_trimmed
.split_whitespace()
.filter(|w| {
w.len() > 3
&& !matches!(
*w,
"const"
| "let"
| "var"
| "this"
| "self"
| "return"
| "from"
| "import"
| "function"
)
})
.take(3)
.collect();
if !keywords.is_empty() {
for (i, line) in content_lines.iter().enumerate() {
let lower = line.to_lowercase();
if keywords.iter().all(|kw| lower.contains(&kw.to_lowercase())) {
let start = i.saturating_sub(2);
let end = (i + 5).min(content_lines.len());
let mut snippet = String::new();
for j in start..end {
snippet.push_str(&format!("{:>4}| {}\n", j + 1, content_lines[j]));
}
return format!(
"No exact match, but keywords [{}] found near line {}:\n{}\n\
Use read_file with offset={} limit=20 to see the exact content.",
keywords.join(", "),
i + 1,
snippet,
start + 1
);
}
}
}
format!(
"No similar text found in the file ({} lines total). \
The content may have changed. Use read_file to re-read the file.",
content_lines.len()
)
}
#[cfg(test)]
mod security_tests {
use super::*;
use crate::tool::{ApprovalRequirement, Tool, ToolContext};
use serial_test::serial;
use tempfile::TempDir;
#[test]
fn edit_file_requires_approval_for_sensitive_paths() {
let tool = EditFileTool;
let args = serde_json::json!({
"file_path": "/etc/hosts",
"old_string": "old",
"new_string": "new"
})
.to_string();
assert!(matches!(
tool.approval(&args),
ApprovalRequirement::RequireApproval(_)
));
}
#[test]
fn edit_file_auto_approves_regular_paths() {
let tool = EditFileTool;
let args = serde_json::json!({
"file_path": "src/main.rs",
"old_string": "old",
"new_string": "new"
})
.to_string();
assert!(matches!(tool.approval(&args), ApprovalRequirement::AutoApprove));
}
#[test]
fn edit_file_requires_approval_when_args_do_not_parse() {
let tool = EditFileTool;
assert!(matches!(
tool.approval("{not valid json"),
ApprovalRequirement::RequireApproval(_)
));
}
#[test]
fn edit_file_sensitive_in_workspace_path_returns_require_approval_always() {
let workspace = TempDir::new().unwrap();
let dotenv = workspace.path().join(".env");
let args = serde_json::json!({
"file_path": dotenv.to_string_lossy(),
"old_string": "old",
"new_string": "new"
})
.to_string();
let ctx = ToolContext::new(workspace.path().to_path_buf());
assert!(
matches!(
EditFileTool.approval_with_context(&args, &ctx),
ApprovalRequirement::RequireApprovalAlways(_)
),
"sensitive in-workspace path (.env) must return RequireApprovalAlways so a \
session grant on edit_file cannot bypass approval",
);
}
#[test]
fn edit_file_sensitive_path_through_store_with_session_grant_asks() {
use crate::tool::{PermissionDecision, PermissionStore};
let workspace = TempDir::new().unwrap();
let dotenv = workspace.path().join(".env");
let args = serde_json::json!({
"file_path": dotenv.to_string_lossy(),
"old_string": "old",
"new_string": "new"
})
.to_string();
let ctx = ToolContext::new(workspace.path().to_path_buf());
let mut store = PermissionStore::new();
store.grant_session("edit_file");
let approval = EditFileTool.approval_with_context(&args, &ctx);
let decision = store.check("edit_file", &approval);
assert!(
matches!(decision, PermissionDecision::Ask(_)),
"edit on sensitive in-workspace path (.env) must prompt the user even with a \
session grant, got {decision:?}",
);
}
#[tokio::test]
#[serial]
async fn edit_file_writes_relative_path_against_tool_working_dir() {
let workspace = TempDir::new().unwrap();
let process_cwd = TempDir::new().unwrap();
std::fs::create_dir_all(workspace.path().join("src")).unwrap();
std::fs::create_dir_all(process_cwd.path().join("src")).unwrap();
std::fs::write(workspace.path().join("src/app.rs"), "fn main() {\n old();\n}\n")
.unwrap();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(process_cwd.path()).unwrap();
let result = EditFileTool
.execute(
r#"{"file_path":"src/app.rs","old_string":"old();","new_string":"new();"}"#,
&ToolContext::new(workspace.path().to_path_buf()),
)
.await;
std::env::set_current_dir(original_cwd).unwrap();
let result = result.unwrap();
assert!(result.success, "{}", result.output);
assert_eq!(
std::fs::read_to_string(workspace.path().join("src/app.rs")).unwrap(),
"fn main() {\n new();\n}\n"
);
assert!(
!process_cwd.path().join("src/app.rs").exists(),
"edit_file must not write relative paths against the process cwd"
);
}
}