use crate::agent::extension::{Extension, ToolDefinition};
use crate::agent::extension::{ToolRenderContext, ToolRenderer};
use crate::tui::Theme;
use crate::tui::ThemeKey;
use async_trait::async_trait;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use unicode_normalization::UnicodeNormalization;
#[async_trait]
pub trait EditOperations: Send + Sync {
async fn read_file(&self, absolute_path: &Path) -> anyhow::Result<String>;
async fn write_file(&self, absolute_path: &Path, content: &str) -> anyhow::Result<()>;
async fn access(&self, absolute_path: &Path) -> anyhow::Result<()>;
}
struct DefaultEditOperations;
#[async_trait]
impl EditOperations for DefaultEditOperations {
async fn read_file(&self, absolute_path: &Path) -> anyhow::Result<String> {
Ok(std::fs::read_to_string(absolute_path)?)
}
async fn write_file(&self, absolute_path: &Path, content: &str) -> anyhow::Result<()> {
Ok(std::fs::write(absolute_path, content)?)
}
async fn access(&self, absolute_path: &Path) -> anyhow::Result<()> {
if !absolute_path.exists() {
anyhow::bail!("File not found: {}", absolute_path.display());
}
if !absolute_path.is_file() {
anyhow::bail!("Not a file: {}", absolute_path.display());
}
Ok(())
}
}
pub struct EditExtension {
cwd: PathBuf,
operations: Arc<dyn EditOperations>,
}
impl EditExtension {
pub fn new(cwd: PathBuf) -> Self {
Self {
cwd,
operations: Arc::new(DefaultEditOperations),
}
}
pub fn with_operations(mut self, operations: Arc<dyn EditOperations>) -> Self {
self.operations = operations;
self
}
}
impl Extension for EditExtension {
fn name(&self) -> Cow<'static, str> {
"edit".into()
}
fn tools(&self) -> Vec<ToolDefinition> {
vec![ToolDefinition {
tool: Box::new(EditTool {
cwd: self.cwd.clone(),
operations: self.operations.clone(),
}),
snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
guidelines: &[
"Use edit for precise changes (edits[].oldText must match exactly)",
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
"Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
],
prepare_arguments: Some(prepare_edit_args),
before_tool_call: None,
after_tool_call: None,
renderer: Some(std::sync::Arc::new(EditRenderer::new())),
}]
}
}
struct EditTool {
cwd: PathBuf,
operations: Arc<dyn EditOperations>,
}
#[derive(serde::Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct Edit {
old_text: String,
new_text: String,
}
fn strip_bom(content: &str) -> (&str, &str) {
if content.starts_with('\u{FEFF}') {
("\u{FEFF}", &content['\u{FEFF}'.len_utf8()..])
} else {
("", content)
}
}
fn detect_line_ending(content: &str) -> &'static str {
if content.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn normalize_to_lf(content: &str) -> String {
content.replace("\r\n", "\n")
}
fn restore_line_endings(content: &str, ending: &str) -> String {
if ending == "\r\n" {
content.replace('\n', "\r\n")
} else {
content.to_string()
}
}
fn normalize_for_fuzzy_match(text: &str) -> String {
let nfkc = text.nfkc().collect::<String>();
let mut intermediate = String::with_capacity(nfkc.len());
for line in nfkc.lines() {
if !intermediate.is_empty() {
intermediate.push('\n');
}
intermediate.push_str(line.trim_end());
}
if nfkc.ends_with('\n') {
intermediate.push('\n');
}
let mut result = String::with_capacity(intermediate.len());
for ch in intermediate.chars() {
match ch {
'\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => result.push('\''),
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => result.push('"'),
'\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
| '\u{2212}' => {
result.push('-');
}
'\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
| '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
| '\u{3000}' => {
result.push(' ');
}
other => result.push(other),
}
}
result
}
fn prepare_edit_arguments(args: &serde_json::Value) -> Result<(String, Vec<Edit>), String> {
let path = args["path"]
.as_str()
.ok_or_else(|| "Missing 'path' argument".to_string())?;
let edits = if let Some(edits_val) = args.get("edits") {
if let Some(s) = edits_val.as_str() {
serde_json::from_str::<Vec<Edit>>(s)
.map_err(|e| format!("Invalid edits JSON string: {}", e))?
} else {
serde_json::from_value::<Vec<Edit>>(edits_val.clone())
.map_err(|e| format!("Invalid edits array: {}", e))?
}
} else if let (Some(old), Some(new)) = (args.get("oldText"), args.get("newText")) {
let old_text = old
.as_str()
.ok_or_else(|| "Invalid 'oldText' argument: expected string".to_string())?;
let new_text = new
.as_str()
.ok_or_else(|| "Invalid 'newText' argument: expected string".to_string())?;
vec![Edit {
old_text: old_text.to_string(),
new_text: new_text.to_string(),
}]
} else if let (Some(old), Some(new)) = (args.get("old_text"), args.get("new_text")) {
let old_text = old
.as_str()
.ok_or_else(|| "Invalid 'old_text' argument: expected string".to_string())?;
let new_text = new
.as_str()
.ok_or_else(|| "Invalid 'new_text' argument: expected string".to_string())?;
vec![Edit {
old_text: old_text.to_string(),
new_text: new_text.to_string(),
}]
} else {
return Err("Missing 'edits' array (or 'oldText'/'newText' or 'old_text'/'new_text' for legacy format)".to_string());
};
if edits.is_empty() {
return Err("At least one edit is required".to_string());
}
Ok((path.to_string(), edits))
}
pub fn prepare_edit_args(mut args: serde_json::Value) -> Result<serde_json::Value, String> {
let (path_str, edits) = prepare_edit_arguments(&args)?;
let edits_array: Vec<serde_json::Value> = edits
.iter()
.map(|e| {
serde_json::json!({
"oldText": e.old_text,
"newText": e.new_text
})
})
.collect();
if let Some(obj) = args.as_object_mut() {
obj.remove("oldText");
obj.remove("newText");
obj.remove("old_text");
obj.remove("new_text");
obj.insert("path".to_string(), serde_json::Value::String(path_str));
obj.insert("edits".to_string(), serde_json::Value::Array(edits_array));
}
Ok(args)
}
#[allow(dead_code)]
fn prepare_edit_tool_args(mut args: serde_json::Value) -> serde_json::Value {
let (path_str, edits) = match prepare_edit_arguments(&args) {
Ok(result) => result,
Err(_) => return args,
};
let edits_array: Vec<serde_json::Value> = edits
.iter()
.map(|e| {
serde_json::json!({
"oldText": e.old_text,
"newText": e.new_text
})
})
.collect();
if let Some(obj) = args.as_object_mut() {
obj.remove("oldText");
obj.remove("newText");
obj.remove("old_text");
obj.remove("new_text");
obj.insert("path".to_string(), serde_json::Value::String(path_str));
obj.insert("edits".to_string(), serde_json::Value::Array(edits_array));
}
args
}
#[derive(Debug, Clone, Copy)]
struct LineSpan {
start: usize,
end: usize,
}
fn split_lines_with_endings(content: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut remaining = content;
while let Some(pos) = remaining.find('\n') {
result.push(&remaining[..=pos]);
remaining = &remaining[pos + 1..];
}
if !remaining.is_empty() {
result.push(remaining);
}
result
}
fn get_line_spans(content: &str) -> Vec<LineSpan> {
let mut offset = 0;
split_lines_with_endings(content)
.iter()
.map(|line| {
let span = LineSpan {
start: offset,
end: offset + line.len(),
};
offset = span.end;
span
})
.collect()
}
fn get_replacement_line_range(
lines: &[LineSpan],
match_index: usize,
match_length: usize,
) -> (usize, usize) {
let replacement_end = match_index + match_length;
let mut start_line = 0;
for (i, line) in lines.iter().enumerate() {
if match_index >= line.start && match_index < line.end {
start_line = i;
break;
}
}
let mut end_line = start_line;
while end_line < lines.len() && lines[end_line].end < replacement_end {
end_line += 1;
}
if end_line >= lines.len() {
end_line = lines.len() - 1;
}
(start_line, end_line + 1)
}
fn apply_replacements(
content: &str,
replacements: &[(usize, usize, &str)],
offset: usize,
) -> String {
let mut result = content.to_string();
for (start, length, new_text) in replacements.iter().rev() {
let adj_start = start - offset;
let adj_end = adj_start + length;
result.replace_range(adj_start..adj_end, new_text);
}
result
}
fn apply_replacements_preserving_unchanged_lines(
original_content: &str,
base_content: &str,
replacements: &[(usize, usize, &str)], ) -> String {
let original_lines = split_lines_with_endings(original_content);
let base_lines = get_line_spans(base_content);
if original_lines.len() != base_lines.len() {
let mut result = base_content.to_string();
for (start, end, new_text) in replacements.iter().rev() {
result.replace_range(*start..*end, new_text);
}
return result;
}
struct Group {
start_line: usize,
end_line: usize,
replacements: Vec<(usize, usize, String)>, }
let mut groups: Vec<Group> = Vec::new();
for &(start, end, new_text) in replacements {
let (sl, el) = get_replacement_line_range(&base_lines, start, end);
if let Some(last) = groups.last_mut()
&& sl < last.end_line
{
last.end_line = last.end_line.max(el);
last.replacements.push((start, end, new_text.to_string()));
continue;
}
groups.push(Group {
start_line: sl,
end_line: el,
replacements: vec![(start, end, new_text.to_string())],
});
}
let mut original_line_index = 0;
let mut result = String::new();
for group in &groups {
result.push_str(&original_lines[original_line_index..group.start_line].concat());
let group_start_offset = base_lines[group.start_line].start;
let group_end_offset = base_lines[group.end_line - 1].end;
let group_slice = &base_content[group_start_offset..group_end_offset];
let adjusted_replacements: Vec<(usize, usize, &str)> = group
.replacements
.iter()
.map(|(s, e, t)| (*s - group_start_offset, *e, t.as_str()))
.collect();
result.push_str(&apply_replacements(group_slice, &adjusted_replacements, 0));
original_line_index = group.end_line;
}
result.push_str(&original_lines[original_line_index..].concat());
result
}
fn replace_tabs(text: &str) -> String {
text.replace('\t', " ")
}
fn compute_diff(original: &str, modified: &str, _path: &str) -> String {
let orig_lines: Vec<&str> = original.lines().collect();
let mod_lines: Vec<&str> = modified.lines().collect();
let max_line_num = orig_lines.len().max(mod_lines.len());
let line_num_width = max_line_num.to_string().len();
let mut output: Vec<String> = Vec::new();
let n = orig_lines.len();
let m = mod_lines.len();
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in 1..=n {
for j in 1..=m {
if orig_lines[i - 1] == mod_lines[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
let mut changes: Vec<(char, &str)> = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 || j > 0 {
if i > 0 && j > 0 && orig_lines[i - 1] == mod_lines[j - 1] {
changes.push((' ', orig_lines[i - 1]));
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
changes.push(('+', mod_lines[j - 1]));
j -= 1;
} else {
changes.push(('-', orig_lines[i - 1]));
i -= 1;
}
}
changes.reverse();
const CONTEXT_LINES: usize = 4;
let mut old_line_num: usize = 1;
let mut new_line_num: usize = 1;
let pad = |num: usize| -> String { format!("{:width$}", num, width = line_num_width) };
let mut k = 0;
while k < changes.len() {
let (tag, _text) = changes[k];
if tag == ' ' {
let mut ctx_buffer: Vec<&str> = Vec::new();
let ctx_start = k;
while k < changes.len() && changes[k].0 == ' ' {
ctx_buffer.push(changes[k].1);
k += 1;
}
let ctx_end = k;
let has_leading_change = ctx_start > 0 && changes[ctx_start - 1].0 != ' ';
let has_trailing_change = ctx_end < changes.len() - 1;
if has_leading_change || has_trailing_change {
let total_ctx = ctx_buffer.len();
if has_leading_change && has_trailing_change {
if total_ctx <= CONTEXT_LINES * 2 {
for &line in &ctx_buffer {
output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
new_line_num += 1;
}
} else {
let leading = &ctx_buffer[..CONTEXT_LINES];
let trailing = &ctx_buffer[total_ctx - CONTEXT_LINES..];
let skipped = total_ctx - leading.len() - trailing.len();
for &line in leading {
output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
new_line_num += 1;
}
output.push(format!(" {} ...", " ".repeat(line_num_width)));
old_line_num += skipped;
new_line_num += skipped;
for &line in trailing {
output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
new_line_num += 1;
}
}
} else if has_leading_change {
let shown = ctx_buffer.len().min(CONTEXT_LINES);
let skipped = ctx_buffer.len() - shown;
for &line in &ctx_buffer[..shown] {
output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
new_line_num += 1;
}
if skipped > 0 {
output.push(format!(" {} ...", " ".repeat(line_num_width)));
old_line_num += skipped;
new_line_num += skipped;
}
} else if has_trailing_change {
let shown = ctx_buffer.len().min(CONTEXT_LINES);
let skipped = ctx_buffer.len() - shown;
if skipped > 0 {
output.push(format!(" {} ...", " ".repeat(line_num_width)));
old_line_num += skipped;
new_line_num += skipped;
}
for &line in &ctx_buffer[ctx_buffer.len() - shown..] {
output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
new_line_num += 1;
}
}
} else {
old_line_num += ctx_buffer.len();
new_line_num += ctx_buffer.len();
}
} else {
let mut removed: Vec<&str> = Vec::new();
while k < changes.len() && changes[k].0 == '-' {
removed.push(changes[k].1);
k += 1;
}
let mut added: Vec<&str> = Vec::new();
while k < changes.len() && changes[k].0 == '+' {
added.push(changes[k].1);
k += 1;
}
for &line in &removed {
output.push(format!("-{} {}", pad(old_line_num), replace_tabs(line)));
old_line_num += 1;
}
for &line in &added {
output.push(format!("+{} {}", pad(new_line_num), replace_tabs(line)));
new_line_num += 1;
}
}
}
output.join("\n")
}
fn parse_path_edits(args: &serde_json::Value) -> Option<(String, Vec<Edit>)> {
let path = args.get("path").and_then(|v| v.as_str())?;
let edits: Vec<Edit> = if let Some(edits_val) = args.get("edits") {
if let Some(s) = edits_val.as_str() {
serde_json::from_str(s).ok()?
} else {
serde_json::from_value(edits_val.clone()).ok()?
}
} else if let (Some(old), Some(new)) = (args.get("oldText"), args.get("newText")) {
let old_text = old.as_str()?;
let new_text = new.as_str()?;
vec![Edit {
old_text: old_text.to_string(),
new_text: new_text.to_string(),
}]
} else {
return None;
};
if edits.is_empty() {
return None;
}
Some((path.to_string(), edits))
}
fn apply_edits_and_compute_diff(
normalized: &str,
edits: &[Edit],
path_str: &str,
) -> Result<(String, String, String), String> {
let mut needs_fuzzy = false;
for edit in edits {
let old_lf = normalize_to_lf(&edit.old_text);
if !normalized.contains(&old_lf) {
needs_fuzzy = true;
break;
}
}
let fuzzy_owned;
let (work_content, is_fuzzy_space) = if needs_fuzzy {
fuzzy_owned = normalize_for_fuzzy_match(normalized);
(fuzzy_owned.as_str(), true)
} else {
(normalized, false)
};
let mut matched_indices: Vec<(usize, usize)> = Vec::new();
for (i, edit) in edits.iter().enumerate() {
if edit.old_text.is_empty() {
return if edits.len() == 1 {
Err(format!("oldText must not be empty in {}.", path_str))
} else {
Err(format!(
"edits[{}].oldText must not be empty in {}.",
i, path_str
))
};
}
let search_text = if is_fuzzy_space {
normalize_for_fuzzy_match(&normalize_to_lf(&edit.old_text))
} else {
normalize_to_lf(&edit.old_text)
};
let count = work_content.matches(&search_text).count();
if count == 0 {
return if edits.len() == 1 {
Err(format!(
"Could not find the exact text in {}. \
The old text must match exactly including all whitespace and newlines.",
path_str
))
} else {
Err(format!(
"Could not find edits[{}] in {}. \
The oldText must match exactly including all whitespace and newlines.",
i, path_str
))
};
}
if count > 1 {
return if edits.len() == 1 {
Err(format!(
"Found {} occurrences of the text in {}. \
The text must be unique. Please provide more context to make it unique.",
count, path_str
))
} else {
Err(format!(
"Found {} occurrences of edits[{}] in {}. \
Each oldText must be unique. Please provide more context to make it unique.",
count, i, path_str
))
};
}
let pos = work_content.find(&search_text).unwrap();
matched_indices.push((pos, pos + search_text.len()));
}
for (idx_i, &(pos_i, end_i)) in matched_indices.iter().enumerate() {
for (idx_j, &(pos_j, end_j)) in matched_indices.iter().enumerate().skip(idx_i + 1) {
if pos_i < end_j && pos_j < end_i {
return Err(format!(
"edits[{}] and edits[{}] overlap in {}. Merge them into one edit or target disjoint regions.",
idx_i, idx_j, path_str
));
}
}
}
let mut sorted: Vec<(usize, usize, &Edit)> = matched_indices
.into_iter()
.zip(edits.iter())
.map(|((start, end), edit)| (start, end, edit))
.collect();
sorted.sort_by_key(|(pos, _, _)| *pos);
let (base_content, new_content) = if is_fuzzy_space {
let mapped_refs: Vec<(usize, usize, &str)> = sorted
.iter()
.map(|(start, end, edit)| (*start, *end - *start, &edit.new_text[..]))
.collect();
let new_content =
apply_replacements_preserving_unchanged_lines(normalized, work_content, &mapped_refs);
(normalized.to_string(), new_content)
} else {
let mut modified = String::new();
let mut cursor = 0usize;
for (start, end, edit) in &sorted {
modified.push_str(&normalized[cursor..*start]);
modified.push_str(&normalize_to_lf(&edit.new_text));
cursor = *end;
}
modified.push_str(&normalized[cursor..]);
(normalized.to_string(), modified)
};
if base_content == new_content {
return if edits.len() == 1 {
Err(format!(
"No changes made to {}. The replacement produced identical content. \
This might indicate an issue with special characters or the text not \
existing as expected.",
path_str
))
} else {
Err(format!(
"No changes made to {}. The replacements produced identical content.",
path_str
))
};
}
let diff = compute_diff(&base_content, &new_content, path_str);
Ok((base_content, new_content, diff))
}
fn compute_edits_diff(
path_str: &str,
edits: &[Edit],
cwd: &std::path::Path,
) -> Result<String, String> {
let abs_path = {
let p = std::path::Path::new(path_str);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
};
let raw_content =
std::fs::read_to_string(&abs_path).map_err(|e| format!("Could not read file: {}", e))?;
let (_bom, content) = strip_bom(&raw_content);
let normalized = normalize_to_lf(content);
let (_, _, diff) = apply_edits_and_compute_diff(&normalized, edits, path_str)?;
Ok(diff)
}
#[async_trait::async_trait]
impl yoagent::types::AgentTool for EditTool {
fn name(&self) -> &str {
"edit"
}
fn label(&self) -> &str {
"edit"
}
fn description(&self) -> &str {
"Edit a single file using exact text replacement. Every edits[].oldText must match a \
unique, non-overlapping region of the original file. If two changes affect the same \
block or nearby lines, merge them into one edit instead of emitting overlapping edits. \
Do not include large unchanged regions just to connect distant changes."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"required": ["path", "edits"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit"
},
"edits": {
"type": "array",
"items": {
"type": "object",
"required": ["oldText", "newText"],
"additionalProperties": false,
"properties": {
"oldText": {
"type": "string",
"description": "Text to search for"
},
"newText": {
"type": "string",
"description": "Text to replace with"
}
}
}
}
}
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: yoagent::types::ToolContext,
) -> std::result::Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
let path_str = params["path"]
.as_str()
.ok_or_else(|| {
yoagent::types::ToolError::InvalidArgs("Missing 'path' argument".into())
})?
.to_string();
let edits: Vec<Edit> = serde_json::from_value(params["edits"].clone())
.map_err(|e| yoagent::types::ToolError::InvalidArgs(format!("Invalid edits: {}", e)))?;
if ctx.cancel.is_cancelled() {
return Err(yoagent::types::ToolError::Cancelled);
}
let cwd = self.cwd.clone();
let cancel = ctx.cancel.clone();
let ops = self.operations.clone();
let path_for_queue = path_str.clone();
let cwd_for_closure = cwd.clone();
let edits_for_closure = edits.clone();
let output = crate::builtin::file_mutation_queue::with_file_mutation_queue(
&path_for_queue,
&cwd,
|| async move {
let abs_path = {
let p = std::path::Path::new(&path_str);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd_for_closure.join(p)
}
};
if cancel.is_cancelled() {
anyhow::bail!("Operation cancelled");
}
ops.access(&abs_path).await?;
if cancel.is_cancelled() {
anyhow::bail!("Operation cancelled");
}
let raw_content = ops.read_file(&abs_path).await?;
if cancel.is_cancelled() {
anyhow::bail!("Operation cancelled");
}
let (bom, content) = strip_bom(&raw_content);
let original_ending = detect_line_ending(content);
let normalized = normalize_to_lf(content);
let (_base_content, new_content, diff) =
apply_edits_and_compute_diff(&normalized, &edits_for_closure, &path_str)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if cancel.is_cancelled() {
anyhow::bail!("Operation cancelled");
}
let final_content =
bom.to_string() + &restore_line_endings(&new_content, original_ending);
ops.write_file(&abs_path, &final_content).await?;
if cancel.is_cancelled() {
anyhow::bail!("Operation cancelled");
}
let first_changed_line = extract_first_changed_line(&diff);
let patch = generate_unified_patch(&path_str, &_base_content, &new_content);
let noun = if edits.len() == 1 { "block" } else { "blocks" };
let msg = format!(
"Successfully replaced {} {} in {}.",
edits.len(),
noun,
path_str
);
let details = serde_json::json!({
"diff": diff.trim_end(),
"path": path_str,
"patch": patch,
"firstChangedLine": first_changed_line,
});
Ok::<_, anyhow::Error>((msg, details))
},
)
.await
.map_err(|e| yoagent::types::ToolError::Failed(e.to_string()))?;
let (msg, details) = output;
Ok(yoagent::types::ToolResult {
content: vec![yoagent::types::Content::Text { text: msg }],
details,
})
}
}
#[derive(Debug, Clone)]
struct EditPreview {
diff: String,
error: Option<String>,
}
#[derive(Clone)]
struct EditRenderer {
preview: std::sync::Arc<Mutex<Option<EditPreview>>>,
}
impl EditRenderer {
fn new() -> Self {
Self {
preview: std::sync::Arc::new(Mutex::new(None)),
}
}
}
impl ToolRenderer for EditRenderer {
fn render_self(&self) -> bool {
true
}
fn render_bg_key(&self) -> Option<&'static str> {
if let Ok(p) = self.preview.lock()
&& let Some(ref preview) = *p
{
if preview.error.is_some() {
return Some("toolErrorBg");
}
return Some("toolSuccessBg");
}
None }
fn render_call(
&self,
args: &serde_json::Value,
_width: usize,
theme: &dyn Theme,
ctx: &ToolRenderContext,
) -> Vec<String> {
let path = args
.get("file_path")
.or_else(|| args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("");
let short = if let Ok(home) = std::env::var("HOME") {
path.replacen(&home, "~", 1)
} else {
path.to_string()
};
let path_disp = if short.is_empty() {
String::new()
} else {
theme.fg_key(ThemeKey::Accent, &short)
};
let header = format!(
"{} {}",
theme.fg_key(ThemeKey::ToolTitle, &theme.bold("edit")),
path_disp
);
let mut lines = vec![header];
let actual_diff = ctx
.details
.as_ref()
.and_then(|d| d.get("diff"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let diff_to_show = if let Some(ref d) = actual_diff {
Some(d.clone())
} else if ctx.args_complete && !ctx.is_partial {
self.preview.lock().ok().and_then(|p| {
p.as_ref().map(|preview| {
if let Some(ref err) = preview.error {
format!("error: {}", err)
} else {
preview.diff.clone()
}
})
})
} else if ctx.args_complete && actual_diff.is_none() {
let cached = self.preview.lock().ok().and_then(|p| p.clone());
if let Some(preview) = cached {
if let Some(ref err) = preview.error {
Some(format!("error: {}", err))
} else {
Some(preview.diff.clone())
}
} else if let Some((path_str, edits)) = parse_path_edits(args) {
let mut preview_lock = self.preview.lock().unwrap();
if preview_lock.is_some() {
drop(preview_lock);
let cached = self.preview.lock().ok().and_then(|p| p.clone());
cached.map(|preview| {
if let Some(ref err) = preview.error {
format!("error: {}", err)
} else {
preview.diff.clone()
}
})
} else {
*preview_lock = Some(EditPreview {
diff: String::new(),
error: Some("pending".to_string()),
});
drop(preview_lock);
let preview_arc = self.preview.clone();
let path_owned = path_str.clone();
let edits_owned = edits.clone();
let cwd_owned = ctx.cwd.clone();
let invalidate_tx = ctx.invalidate.clone();
tokio::spawn(async move {
let result = compute_edits_diff(
&path_owned,
&edits_owned,
std::path::Path::new(&cwd_owned),
);
let (diff, error) = match result {
Ok(d) => (d, None),
Err(e) => (String::new(), Some(e)),
};
if let Ok(mut p) = preview_arc.lock() {
*p = Some(EditPreview { diff, error });
}
if let Some(ref tx) = invalidate_tx {
let _ = tx.send(());
}
});
None
}
} else {
None
}
} else {
None
};
if let Some(ref diff) = diff_to_show {
if diff.starts_with("error: ") {
lines.push(String::new());
lines.push(theme.fg_key(ThemeKey::Muted, diff));
} else if !diff.is_empty() {
lines.push(String::new());
let rendered_lines = crate::tui::components::diff::render_diff(diff, theme);
lines.extend(rendered_lines);
}
}
lines
}
fn render_result(
&self,
_content: &str,
_width: usize,
theme: &dyn Theme,
ctx: &ToolRenderContext,
) -> Vec<String> {
if ctx.is_error {
if !_content.is_empty() {
let msg = _content;
let preview_err = self
.preview
.lock()
.ok()
.and_then(|p| p.as_ref().and_then(|preview| preview.error.clone()));
if preview_err.as_deref() != Some(msg) {
return vec![String::new(), theme.fg_key(ThemeKey::Error, msg)];
}
}
}
Vec::new()
}
}
fn extract_first_changed_line(diff: &str) -> Option<usize> {
for line in diff.lines() {
let bytes = line.as_bytes();
if bytes.is_empty() {
continue;
}
let prefix = bytes[0] as char;
if prefix != '+' && prefix != '-' {
continue;
}
let rest = &line[1..];
let num_str: String = rest
.chars()
.take_while(|c| c.is_whitespace() || c.is_ascii_digit())
.collect();
if let Ok(num) = num_str.trim().parse::<usize>() {
return Some(num);
}
}
None
}
fn generate_unified_patch(path: &str, original: &str, modified: &str) -> String {
let orig_lines: Vec<&str> = original.lines().collect();
let mod_lines: Vec<&str> = modified.lines().collect();
let n = orig_lines.len();
let m = mod_lines.len();
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in 1..=n {
for j in 1..=m {
if orig_lines[i - 1] == mod_lines[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
let mut changes: Vec<(char, &str)> = Vec::new();
let mut i = n;
let mut j = m;
while i > 0 || j > 0 {
if i > 0 && j > 0 && orig_lines[i - 1] == mod_lines[j - 1] {
changes.push((' ', orig_lines[i - 1]));
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
changes.push(('+', mod_lines[j - 1]));
j -= 1;
} else {
changes.push(('-', orig_lines[i - 1]));
i -= 1;
}
}
changes.reverse();
const CTX: usize = 3;
let mut hunks: Vec<String> = Vec::new();
let mut pos = 0;
while pos < changes.len() {
while pos < changes.len() && changes[pos].0 == ' ' {
pos += 1;
}
if pos >= changes.len() {
break;
}
let hunk_start = pos.saturating_sub(CTX);
let hunk_end = (pos + 3 * CTX).min(changes.len());
let mut old_line = 1usize;
let mut new_line = 1usize;
for (tag, _) in changes.iter().take(pos.saturating_sub(CTX)) {
match tag {
' ' => {
old_line += 1;
new_line += 1;
}
'-' => old_line += 1,
'+' => new_line += 1,
_ => {}
}
}
let old_start = old_line;
let new_start = new_line;
let mut old_count = 0usize;
let mut new_count = 0usize;
for (tag, _) in changes[hunk_start..hunk_end].iter() {
match tag {
' ' => {
old_count += 1;
new_count += 1;
}
'-' => old_count += 1,
'+' => new_count += 1,
_ => {}
}
}
let mut hunk = format!(
"@@ -{},{} +{},{} @@\n",
old_start, old_count, new_start, new_count
);
for (tag, text) in changes[hunk_start..hunk_end].iter() {
match tag {
' ' => hunk.push_str(&format!(" {}", text)),
'-' => hunk.push_str(&format!("-{}", text)),
'+' => hunk.push_str(&format!("+{}", text)),
_ => {}
}
hunk.push('\n');
}
hunks.push(hunk);
pos = hunk_end;
}
if hunks.is_empty() {
return String::new();
}
let mut patch = format!("--- a/{}\n+++ b/{}\n", path, path);
for hunk in &hunks {
patch.push_str(hunk);
}
patch
}
#[cfg(test)]
mod tests {
use super::*;
use yoagent::AgentTool;
fn tmp_dir() -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("rab-edit-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn make_tool() -> (EditTool, std::path::PathBuf) {
let tmp = tmp_dir();
let tool = EditTool {
cwd: tmp.clone(),
operations: Arc::new(DefaultEditOperations),
};
(tool, tmp)
}
fn tool_ctx() -> yoagent::types::ToolContext {
yoagent::types::ToolContext {
tool_call_id: "id".into(),
tool_name: "edit".into(),
cancel: tokio_util::sync::CancellationToken::new(),
on_update: None,
on_progress: None,
}
}
fn yo_msg_text(content: &[yoagent::types::Content]) -> String {
content
.iter()
.filter_map(|c| {
if let yoagent::types::Content::Text { text } = c {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("")
}
async fn exec_ok(tool: &EditTool, args: serde_json::Value) -> String {
let args = prepare_edit_tool_args(args);
let result = tool.execute(args, tool_ctx()).await.unwrap();
yo_msg_text(&result.content)
}
async fn exec_ok_details(
tool: &EditTool,
args: serde_json::Value,
) -> (String, Option<serde_json::Value>) {
let args = prepare_edit_tool_args(args);
let result = tool.execute(args, tool_ctx()).await.unwrap();
let text = yo_msg_text(&result.content);
(text, Some(result.details))
}
async fn exec_err(tool: &EditTool, args: serde_json::Value) -> String {
let args = prepare_edit_tool_args(args);
tool.execute(args, tool_ctx())
.await
.unwrap_err()
.to_string()
}
async fn is_err(tool: &EditTool, args: serde_json::Value) -> bool {
let args = prepare_edit_tool_args(args);
tool.execute(args, tool_ctx()).await.is_err()
}
#[tokio::test]
async fn single_edit_replaces_text() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "hello world\nfoo bar\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "foo bar", "newText": "baz qux"}]
}),
)
.await;
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"hello world\nbaz qux\n"
);
}
#[tokio::test]
async fn multiple_edits_replaces_all() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [
{"oldText": "aaa", "newText": "111"},
{"oldText": "ccc", "newText": "333"}
]
}),
)
.await;
assert_eq!(std::fs::read_to_string(&path).unwrap(), "111\nbbb\n333\n");
}
#[tokio::test]
async fn non_unique_oldtext_errors() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "dup\ndup\n").unwrap();
assert!(
is_err(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "dup", "newText": "x"}]
}),
)
.await
);
}
#[tokio::test]
async fn missing_oldtext_errors() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "content\n").unwrap();
let err = exec_err(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "not found", "newText": "x"}]
}),
)
.await;
assert!(err.contains("Could not find"));
}
#[tokio::test]
async fn overlapping_edits_error() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "abcdef\n").unwrap();
assert!(
is_err(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [
{"oldText": "abc", "newText": "1"},
{"oldText": "bcd", "newText": "2"}
]
}),
)
.await
);
}
#[tokio::test]
async fn empty_edits_errors() {
let (tool, tmp) = make_tool();
let path = tmp.join("file.txt");
std::fs::write(&path, "content\n").unwrap();
assert!(
is_err(
&tool,
serde_json::json!({"path": path.to_str().unwrap(), "edits": []}),
)
.await
);
}
#[tokio::test]
async fn handles_bom() {
let (tool, tmp) = make_tool();
let path = tmp.join("bom.txt");
std::fs::write(&path, "\u{FEFF}hello world\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "hello world", "newText": "goodbye"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with('\u{FEFF}'));
assert!(content.contains("goodbye"));
}
#[tokio::test]
async fn preserves_bom_when_no_edit_at_start() {
let (tool, tmp) = make_tool();
let path = tmp.join("bom2.txt");
std::fs::write(&path, "\u{FEFF}line1\nline2\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "line2", "newText": "modified"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with('\u{FEFF}'));
assert!(content.contains("modified"));
}
#[tokio::test]
async fn preserves_crlf() {
let (tool, tmp) = make_tool();
let path = tmp.join("crlf.txt");
std::fs::write(&path, "hello\r\nworld\r\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "world", "newText": "universe"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello\r\nuniverse\r\n");
}
#[tokio::test]
async fn handles_mixed_line_endings() {
let (tool, tmp) = make_tool();
let path = tmp.join("mixed.txt");
std::fs::write(&path, "line1\r\nline2\nline3\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "line2", "newText": "modified"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "line1\r\nmodified\r\nline3\r\n");
}
#[tokio::test]
async fn lf_only_stays_lf() {
let (tool, tmp) = make_tool();
let path = tmp.join("lf.txt");
std::fs::write(&path, "hello\nworld\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "world", "newText": "universe"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hello\nuniverse\n");
}
#[tokio::test]
async fn fuzzy_match_trailing_whitespace() {
let (tool, tmp) = make_tool();
let path = tmp.join("trailing.txt");
std::fs::write(&path, "hello world \nnext line\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "hello world", "newText": "hi there"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "hi there \nnext line\n");
}
#[tokio::test]
async fn fuzzy_match_smart_quotes() {
let (tool, tmp) = make_tool();
let path = tmp.join("quotes.txt");
std::fs::write(&path, "he said \u{201C}hello\u{201D}\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "he said \"hello\"", "newText": "she said \"hi\""}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "she said \"hi\"\n");
}
#[tokio::test]
async fn fuzzy_match_dashes() {
let (tool, tmp) = make_tool();
let path = tmp.join("dashes.txt");
std::fs::write(&path, "foo \u{2014} bar\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "foo - bar", "newText": "baz"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "baz\n");
}
#[tokio::test]
async fn no_change_identical_edit_errors() {
let (tool, tmp) = make_tool();
let path = tmp.join("nochange.txt");
std::fs::write(&path, "hello\nworld\n").unwrap();
let err = exec_err(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "hello", "newText": "hello"}]
}),
)
.await;
assert!(
err.contains("No changes made"),
"expected no-change error but got: {}",
err
);
}
#[tokio::test]
async fn legacy_oldtext_newtext() {
let (tool, tmp) = make_tool();
let path = tmp.join("legacy.txt");
std::fs::write(&path, "hello world\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"oldText": "hello world",
"newText": "goodbye"
}),
)
.await;
assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye\n");
}
#[tokio::test]
async fn edits_as_json_string() {
let (tool, tmp) = make_tool();
let path = tmp.join("jsonstr.txt");
std::fs::write(&path, "aaa\nbbb\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": r#"[{"oldText": "bbb", "newText": "xxx"}]"#
}),
)
.await;
assert_eq!(std::fs::read_to_string(&path).unwrap(), "aaa\nxxx\n");
}
#[tokio::test]
async fn result_content_has_no_diff_block() {
let (tool, tmp) = make_tool();
let path = tmp.join("diff_test.txt");
std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
let (content, details) = exec_ok_details(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "bbb", "newText": "xxx"}]
}),
)
.await;
assert!(
!content.contains("```diff"),
"content should not contain diff block, got: {}",
content
);
assert!(content.contains("Successfully replaced 1 block"));
let details_obj = details.expect("details should be present");
let diff = details_obj
.get("diff")
.and_then(|v| v.as_str())
.unwrap_or("");
assert!(
diff.contains("-2 bbb"),
"diff should contain '-2 bbb' but got: {}",
diff
);
assert!(
diff.contains("+2 xxx"),
"diff should contain '+2 xxx' but got: {}",
diff
);
}
#[tokio::test]
async fn fuzzy_preserves_unchanged_line_trailing_whitespace() {
let (tool, tmp) = make_tool();
let path = tmp.join("fuzzy_preserve.txt");
std::fs::write(&path, "keep this line \nchange \u{201C}this\u{201D}\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "change \"this\"", "newText": "changed"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.starts_with("keep this line "),
"expected preserved trailing spaces but got: {:?}",
content
);
assert!(content.contains("changed\n"), "got: {:?}", content);
}
#[tokio::test]
async fn empty_oldtext_errors() {
let (tool, tmp) = make_tool();
let path = tmp.join("empty.txt");
std::fs::write(&path, "content\n").unwrap();
let err = exec_err(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "", "newText": "x"}]
}),
)
.await;
assert!(err.contains("empty"));
}
#[tokio::test]
async fn relative_path_resolves_to_cwd() {
let (tool, tmp) = make_tool();
let path = tmp.join("relative.txt");
std::fs::write(&path, "hello\n").unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": "relative.txt",
"edits": [{"oldText": "hello", "newText": "hi"}]
}),
)
.await;
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hi\n");
}
#[tokio::test]
async fn fuzzy_match_nfkc_composed_vs_decomposed() {
let (tool, tmp) = make_tool();
let path = tmp.join("nfkc.txt");
let nfd: String = "cafe\u{0301}".chars().collect();
std::fs::write(&path, format!("{} rest\n", nfd)).unwrap();
exec_ok(
&tool,
serde_json::json!({
"path": path.to_str().unwrap(),
"edits": [{"oldText": "café", "newText": "changed"}]
}),
)
.await;
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.starts_with("changed"),
"expected 'changed' but got: {:?}",
content
);
}
}
#[cfg(test)]
mod fuzzy_tests {
use super::*;
#[test]
fn test_strip_trailing_whitespace() {
assert_eq!(
normalize_for_fuzzy_match("hello \nworld "),
"hello\nworld"
);
}
#[test]
fn test_smart_quotes() {
assert_eq!(
normalize_for_fuzzy_match("\u{2018}hello\u{2019} \u{201C}world\u{201D}"),
"'hello' \"world\""
);
}
#[test]
fn test_dashes() {
assert_eq!(normalize_for_fuzzy_match("a\u{2014}b"), "a-b");
assert_eq!(normalize_for_fuzzy_match("a\u{2013}b"), "a-b");
}
#[test]
fn test_nbsp() {
assert_eq!(normalize_for_fuzzy_match("a\u{00A0}b"), "a b");
}
#[test]
fn test_preserves_trailing_newline() {
assert_eq!(normalize_for_fuzzy_match("hello\n"), "hello\n");
assert_eq!(
normalize_for_fuzzy_match("hello\nworld\n"),
"hello\nworld\n"
);
}
#[test]
fn test_nfkc_normalization() {
let composed = "café";
let decomposed: String = "cafe\u{0301}".chars().collect();
assert_eq!(
normalize_for_fuzzy_match(composed),
normalize_for_fuzzy_match(&decomposed),
"NFKC should make composed and decomposed café match"
);
}
}
#[cfg(test)]
mod diff_tests {
use super::*;
#[test]
fn test_simple_diff() {
let orig = "aaa\nbbb\nccc\n";
let modified = "aaa\nxxx\nccc\n";
let diff = compute_diff(orig, modified, "test.txt");
assert!(
diff.contains("-2 bbb"),
"diff should contain -2 bbb but got: {}",
diff
);
assert!(
diff.contains("+2 xxx"),
"diff should contain +2 xxx but got: {}",
diff
);
}
#[test]
fn test_no_changes() {
let text = "hello\nworld\n";
let diff = compute_diff(text, text, "f.txt");
assert!(diff.is_empty(), "no changes should produce empty diff");
}
#[test]
fn test_multiple_hunks() {
let orig = "a\nb\nc\nd\ne\nf\ng\nh\n";
let modified = "a\nX\nc\nd\ne\nY\ng\nh\n";
let diff = compute_diff(orig, modified, "f.txt");
assert!(
diff.contains("-2 b"),
"should contain -2 b but got: {}",
diff
);
assert!(
diff.contains("+2 X"),
"should contain +2 X but got: {}",
diff
);
assert!(
diff.contains("-6 f"),
"should contain -6 f but got: {}",
diff
);
assert!(
diff.contains("+6 Y"),
"should contain +6 Y but got: {}",
diff
);
}
#[test]
fn test_apply_replacements_preserving_unchanged_lines() {
let original = "keep this \nchange this\nkeep that \n";
let base = "keep this\nchange this\nkeep that\n";
let replacements = vec![(10usize, 11usize, "modified")];
let result = apply_replacements_preserving_unchanged_lines(original, base, &replacements);
assert_eq!(result, "keep this \nmodified\nkeep that \n");
}
}