use std::path::PathBuf;
use agent_base::{AgentResult, Tool, ToolContext, ToolControlFlow, ToolOutput};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::resolve_path;
#[derive(Debug, Deserialize, Serialize)]
struct Edit {
old_text: String,
new_text: String,
}
pub struct EditFileTool {
workspace_root: PathBuf,
}
impl EditFileTool {
pub fn new(workspace_root: PathBuf) -> Self {
Self { workspace_root }
}
}
#[async_trait]
impl Tool for EditFileTool {
fn name(&self) -> &'static str {
"edit_file"
}
fn definition(&self) -> Value {
json!({
"type": "function",
"function": {
"name": "edit_file",
"description": "Make precise text replacements in an existing file.\n\
Provide one or more edits, each with old_text (text to find) and new_text (replacement).\n\
Each old_text must appear exactly once in the file.\n\
Multiple edits are applied to the original file (not incrementally).\n\
Use this instead of write_file when you only need to change specific parts of a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to edit, relative to the workspace root."
},
"edits": {
"type": "array",
"description": "List of edit operations. Each edit has old_text and new_text.",
"items": {
"type": "object",
"properties": {
"old_text": {
"type": "string",
"description": "The exact text to find and replace. Must appear exactly once in the file."
},
"new_text": {
"type": "string",
"description": "The replacement text."
}
},
"required": ["old_text", "new_text"]
}
}
},
"required": ["path", "edits"]
}
}
})
}
fn metadata(&self) -> agent_base::ToolMetadata {
agent_base::ToolMetadata {
name: self.name().to_string(),
description:
"Precision text replacement in workspace files with uniqueness checks and atomic writes."
.to_string(),
origin: "phi-kernel-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
requirements: vec![],
}
}
async fn call(&self, args: &Value, _ctx: &ToolContext) -> AgentResult<ToolOutput> {
let path_str = args
.get("path")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if path_str.is_empty() {
return Ok(error_output("No file path provided.", &path_str));
}
let edits: Vec<Edit> = match args.get("edits") {
Some(raw) => match serde_json::from_value(raw.clone()) {
Ok(edits) => edits,
Err(e) => {
return Ok(ToolOutput {
summary: format!(
"[Error]: Failed to parse edits: {}. Expected array of {{old_text, new_text}} objects.",
e
),
raw: Some(json!({"error": e.to_string(), "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
},
None => {
return Ok(ToolOutput {
summary: "[Error]: No edits provided. Expected array of {old_text, new_text} objects."
.to_string(),
raw: Some(json!({"error": "no edits provided", "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
};
if edits.is_empty() {
return Ok(ToolOutput {
summary: "[Error]: Edits array is empty. Provide at least one edit operation."
.to_string(),
raw: Some(json!({"error": "empty edits", "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
let file_path = match resolve_path(&self.workspace_root, &path_str) {
Ok(p) => p,
Err(e) => {
return Ok(error_output(&format!("Path error: {}", e), &path_str));
}
};
if !file_path.exists() {
return Ok(ToolOutput {
summary: format!("[Error]: File not found: {}", path_str),
raw: Some(json!({"error": "file not found", "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
if !file_path.is_file() {
return Ok(ToolOutput {
summary: format!("[Error]: Path is not a file: {}", path_str),
raw: Some(json!({"error": "not a file", "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
let original = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
return Ok(ToolOutput {
summary: format!("[Error]: Failed to read file: {}", e),
raw: Some(json!({"error": e.to_string(), "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
};
let line_ending = detect_line_ending(&original);
if let Err(overlap_err) = check_overlaps(&original, &edits) {
return Ok(ToolOutput {
summary: format!("[Error]: {}", overlap_err),
raw: Some(json!({"error": overlap_err, "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
let mut modified = original.clone();
let mut applied = 0;
for (i, edit) in edits.iter().enumerate() {
match find_and_replace(&modified, &original, edit, i) {
Ok(new_content) => {
modified = new_content;
applied += 1;
}
Err(err) => {
return Ok(ToolOutput {
summary: format!("[Error]: Edit {} failed: {}", i, err),
raw: Some(json!({
"error": err,
"path": path_str,
"edit_index": i,
"old_text": edit.old_text,
})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
}
}
let modified = normalize_line_endings(&modified, line_ending);
let temp_path = temp_path_for(&file_path)?;
if let Err(e) = std::fs::write(&temp_path, &modified) {
let _ = std::fs::remove_file(&temp_path);
return Ok(ToolOutput {
summary: format!("[Error]: Failed to write file: {}", e),
raw: Some(json!({"error": e.to_string(), "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
if let Err(e) = std::fs::rename(&temp_path, &file_path) {
let _ = std::fs::remove_file(&temp_path);
return Ok(ToolOutput {
summary: format!("[Error]: Failed to save file (rename): {}", e),
raw: Some(json!({"error": e.to_string(), "path": path_str})),
control_flow: ToolControlFlow::Break,
truncation: None,
});
}
tracing::info!(
path = %path_str,
edits = applied,
"edit_file"
);
Ok(ToolOutput {
summary: format!("Successfully applied {} edit(s) to {}.", applied, path_str),
raw: Some(json!({
"path": path_str,
"edits_applied": applied,
})),
control_flow: ToolControlFlow::Break,
truncation: None,
})
}
}
fn error_output(message: &str, path: &str) -> ToolOutput {
ToolOutput {
summary: format!("[Error]: {}", message),
raw: Some(json!({"error": message, "path": path})),
control_flow: ToolControlFlow::Break,
truncation: None,
}
}
fn detect_line_ending(content: &str) -> &'static str {
if content.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn normalize_line_endings(content: &str, line_ending: &str) -> String {
let normalized = content.replace("\r\n", "\n");
if line_ending == "\r\n" {
normalized.replace('\n', "\r\n")
} else {
normalized
}
}
fn temp_path_for(file_path: &std::path::Path) -> AgentResult<PathBuf> {
let parent = file_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let file_name = file_path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("tmp"));
let mut temp_name = file_name.to_os_string();
temp_name.push(".phi-tmp");
Ok(parent.join(temp_name))
}
fn check_overlaps(original: &str, edits: &[Edit]) -> Result<(), String> {
let mut ranges: Vec<(usize, usize, usize)> = Vec::new();
for (i, edit) in edits.iter().enumerate() {
let positions = find_all_positions(original, &edit.old_text);
if positions.len() != 1 {
continue;
}
let start = positions[0];
let end = start + edit.old_text.len();
ranges.push((start, end, i));
}
ranges.sort_by_key(|(s, _, _)| *s);
for w in ranges.windows(2) {
let (_, end_a, idx_a) = w[0];
let (start_b, _, idx_b) = w[1];
if end_a > start_b {
return Err(format!(
"Edits {} and {} overlap. Edit {} ends at byte {} but edit {} starts at byte {}. \
Merge these edits into a single edit.",
idx_a, idx_b, idx_a, end_a, idx_b, start_b
));
}
}
Ok(())
}
fn find_all_positions(haystack: &str, needle: &str) -> Vec<usize> {
if needle.is_empty() {
return vec![];
}
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = haystack[start..].find(needle) {
let abs_pos = start + pos;
positions.push(abs_pos);
start = abs_pos + 1; }
positions
}
fn find_and_replace(
current: &str,
original: &str,
edit: &Edit,
_idx: usize,
) -> Result<String, String> {
let matches = find_all_positions(current, &edit.old_text);
if matches.len() == 1 {
let pos = matches[0];
return Ok(apply_replace(current, pos, &edit.old_text, &edit.new_text));
}
if matches.is_empty() {
} else {
let orig_matches = find_all_positions(original, &edit.old_text);
if orig_matches.len() == 1 {
return Err(format!(
"old_text is not unique in the file after previous edits. \
Found {} occurrences. Merge edits that affect the same text.",
matches.len()
));
}
return Err(format!(
"old_text is not unique — found {} occurrences. \
Provide more surrounding context to make it unique.",
matches.len()
));
}
let old_rstrip = edit.old_text.trim_end();
if old_rstrip != edit.old_text {
let current_rstrip = find_rstrip_matches(current, old_rstrip);
if current_rstrip.len() == 1 {
let (pos, actual) = current_rstrip[0];
return Ok(apply_replace(current, pos, actual, &edit.new_text));
}
if current_rstrip.len() > 1 {
return Err(format!(
"old_text (with trailing whitespace stripped) is not unique — found {} matches. \
Provide more context.",
current_rstrip.len()
));
}
}
let old_trim = edit.old_text.trim();
if old_trim != edit.old_text && old_trim != old_rstrip {
let current_trim = find_trim_matches(current, old_trim);
if current_trim.len() == 1 {
let (pos, actual) = current_trim[0];
return Ok(apply_replace(current, pos, actual, &edit.new_text));
}
if current_trim.len() > 1 {
return Err(format!(
"old_text (with whitespace trimmed) is not unique — found {} matches. \
Provide more context.",
current_trim.len()
));
}
}
let old_nfc = unicode_normalize(&edit.old_text);
if old_nfc != edit.old_text && old_nfc != old_rstrip && old_nfc != old_trim {
let current_nfc = find_nfc_matches(current, &old_nfc);
if current_nfc.len() == 1 {
let (pos, actual) = current_nfc[0];
return Ok(apply_replace(current, pos, actual, &edit.new_text));
}
if current_nfc.len() > 1 {
return Err(format!(
"old_text (with Unicode NFC normalization) is not unique — found {} matches. \
Provide more context.",
current_nfc.len()
));
}
}
Err(
"old_text not found in the file. Check that the text matches exactly, \
including whitespace and indentation."
.to_string(),
)
}
fn find_rstrip_matches<'a>(haystack: &'a str, needle: &str) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
let mut start = 0;
let needle_len = needle.len();
while start + needle_len <= haystack.len() {
let candidate = &haystack[start..];
if candidate.starts_with(needle) {
let actual_len = candidate[needle_len..]
.chars()
.take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
.map(|c| c.len_utf8())
.sum::<usize>()
+ needle_len;
let actual = &haystack[start..start + actual_len];
results.push((start, actual));
start += actual_len;
} else {
if let Some(c) = candidate.chars().next() {
start += c.len_utf8();
} else {
break;
}
}
}
results
}
fn find_trim_matches<'a>(haystack: &'a str, needle: &str) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
let mut start = 0;
while start < haystack.len() {
let rest = &haystack[start..];
let ws_skip: usize = rest
.chars()
.take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
.map(|c| c.len_utf8())
.sum();
let after_ws = start + ws_skip;
if let Some(after_ws_str) = haystack.get(after_ws..)
&& let Some(stripped) = after_ws_str.strip_prefix(needle)
{
let actual_len = ws_skip
+ needle.len()
+ stripped
.chars()
.take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r')
.map(|c| c.len_utf8())
.sum::<usize>();
let actual = &haystack[start..start + actual_len];
results.push((start, actual));
start += actual_len;
continue;
}
if let Some(c) = rest.chars().next() {
start += c.len_utf8();
} else {
break;
}
}
results
}
fn find_nfc_matches<'a>(haystack: &'a str, needle_nfc: &str) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
let chars: Vec<char> = haystack.chars().collect();
let needle_chars: Vec<char> = needle_nfc.chars().collect();
let needle_len = needle_chars.len();
for i in 0..=chars.len().saturating_sub(needle_len) {
let candidate: String = chars[i..i + needle_len].iter().collect();
let candidate_nfc = unicode_normalize(&candidate);
if candidate_nfc == needle_nfc {
let start = chars[..i].iter().map(|c| c.len_utf8()).sum();
let end = start
+ chars[i..i + needle_len]
.iter()
.map(|c| c.len_utf8())
.sum::<usize>();
results.push((start, &haystack[start..end]));
}
}
results
}
fn unicode_normalize(s: &str) -> String {
s.to_string()
}
fn apply_replace(content: &str, pos: usize, old: &str, new: &str) -> String {
let mut result = String::with_capacity(content.len() - old.len() + new.len());
result.push_str(&content[..pos]);
result.push_str(new);
result.push_str(&content[pos + old.len()..]);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_ctx() -> ToolContext {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
ToolContext {
session_id: agent_base::SessionId::new(0),
user_event_tx: tx,
llm_client: None,
session_store: None,
language: agent_base::Language::En,
cancel_token: tokio_util::sync::CancellationToken::new(),
}
}
fn setup_temp_workspace() -> (tempfile::TempDir, EditFileTool) {
let dir = tempfile::tempdir().unwrap();
let tool = EditFileTool::new(dir.path().to_path_buf());
(dir, tool)
}
#[test]
fn test_find_all_positions_single() {
let pos = find_all_positions("hello world", "world");
assert_eq!(pos, vec![6]);
}
#[test]
fn test_find_all_positions_multiple() {
let pos = find_all_positions("foo bar foo baz foo", "foo");
assert_eq!(pos, vec![0, 8, 16]);
}
#[test]
fn test_find_all_positions_none() {
let pos = find_all_positions("hello", "xyz");
assert!(pos.is_empty());
}
#[test]
fn test_find_all_positions_empty_needle() {
let pos = find_all_positions("hello", "");
assert!(pos.is_empty());
}
#[test]
fn test_detect_line_ending_lf() {
assert_eq!(detect_line_ending("hello\nworld\n"), "\n");
}
#[test]
fn test_detect_line_ending_crlf() {
assert_eq!(detect_line_ending("hello\r\nworld\r\n"), "\r\n");
}
#[test]
fn test_detect_line_ending_mixed_prefers_crlf() {
assert_eq!(detect_line_ending("hello\nworld\r\n"), "\r\n");
}
#[test]
fn test_normalize_to_lf() {
assert_eq!(
normalize_line_endings("hello\r\nworld\r\n", "\n"),
"hello\nworld\n"
);
}
#[test]
fn test_normalize_to_crlf() {
assert_eq!(
normalize_line_endings("hello\nworld\n", "\r\n"),
"hello\r\nworld\r\n"
);
}
#[test]
fn test_normalize_no_change() {
assert_eq!(
normalize_line_endings("hello\nworld\n", "\n"),
"hello\nworld\n"
);
}
#[test]
fn test_apply_replace_basic() {
let result = apply_replace("hello world", 6, "world", "earth");
assert_eq!(result, "hello earth");
}
#[test]
fn test_apply_replace_beginning() {
let result = apply_replace("fn old() {}", 3, "old", "new");
assert_eq!(result, "fn new() {}");
}
#[test]
fn test_check_overlaps_no_overlap() {
let content = "line1\nline2\nline3\n";
let edits = vec![
Edit {
old_text: "line1".to_string(),
new_text: "LINE1".to_string(),
},
Edit {
old_text: "line3".to_string(),
new_text: "LINE3".to_string(),
},
];
assert!(check_overlaps(content, &edits).is_ok());
}
#[test]
fn test_check_overlaps_detected() {
let content = "hello world";
let edits = vec![
Edit {
old_text: "hello world".to_string(),
new_text: "hi earth".to_string(),
},
Edit {
old_text: "world".to_string(),
new_text: "earth".to_string(),
},
];
let err = check_overlaps(content, &edits).unwrap_err();
assert!(err.contains("overlap"));
}
#[test]
fn test_find_and_replace_exact() {
let result = find_and_replace(
"hello world",
"hello world",
&Edit {
old_text: "world".to_string(),
new_text: "earth".to_string(),
},
0,
)
.unwrap();
assert_eq!(result, "hello earth");
}
#[test]
fn test_find_and_replace_not_unique() {
let err = find_and_replace(
"foo bar foo",
"foo bar foo",
&Edit {
old_text: "foo".to_string(),
new_text: "baz".to_string(),
},
0,
)
.unwrap_err();
assert!(err.contains("not unique"));
}
#[test]
fn test_find_and_replace_not_found() {
let err = find_and_replace(
"hello world",
"hello world",
&Edit {
old_text: "xyz".to_string(),
new_text: "abc".to_string(),
},
0,
)
.unwrap_err();
assert!(err.contains("not found"));
}
#[tokio::test]
async fn test_edit_file_single_edit() {
let (dir, tool) = setup_temp_workspace();
std::fs::write(dir.path().join("test.txt"), "hello world\n").unwrap();
let result = tool
.call(
&json!({
"path": "test.txt",
"edits": [{"old_text": "hello", "new_text": "hi"}]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("Successfully applied 1 edit"));
let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap();
assert_eq!(content, "hi world\n");
}
#[tokio::test]
async fn test_edit_file_multiple_edits() {
let (dir, tool) = setup_temp_workspace();
std::fs::write(dir.path().join("test.rs"), "fn old() {}\nfn other() {}\n").unwrap();
let result = tool
.call(
&json!({
"path": "test.rs",
"edits": [
{"old_text": "fn old() {}", "new_text": "fn new() {}"},
{"old_text": "fn other() {}", "new_text": "fn another() {}"}
]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("Successfully applied 2 edit"));
let content = std::fs::read_to_string(dir.path().join("test.rs")).unwrap();
assert_eq!(content, "fn new() {}\nfn another() {}\n");
}
#[tokio::test]
async fn test_edit_file_not_found() {
let (_dir, tool) = setup_temp_workspace();
let result = tool
.call(
&json!({
"path": "nonexistent.txt",
"edits": [{"old_text": "x", "new_text": "y"}]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("not found"));
}
#[tokio::test]
async fn test_edit_file_duplicate_old_text() {
let (dir, tool) = setup_temp_workspace();
std::fs::write(dir.path().join("dup.txt"), "foo bar foo\n").unwrap();
let result = tool
.call(
&json!({
"path": "dup.txt",
"edits": [{"old_text": "foo", "new_text": "baz"}]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("not unique"));
}
#[tokio::test]
async fn test_edit_file_no_path() {
let (_dir, tool) = setup_temp_workspace();
let result = tool
.call(
&json!({"edits": [{"old_text": "x", "new_text": "y"}]}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("No file path"));
}
#[tokio::test]
async fn test_edit_file_no_edits() {
let (_dir, tool) = setup_temp_workspace();
let result = tool
.call(&json!({"path": "test.txt"}), &dummy_ctx())
.await
.unwrap();
assert!(result.summary.contains("No edits provided"));
}
#[tokio::test]
async fn test_edit_file_empty_edits() {
let (_dir, tool) = setup_temp_workspace();
let result = tool
.call(&json!({"path": "test.txt", "edits": []}), &dummy_ctx())
.await
.unwrap();
assert!(result.summary.contains("empty"));
}
#[tokio::test]
async fn test_edit_file_preserves_line_endings_crlf() {
let (dir, tool) = setup_temp_workspace();
std::fs::write(dir.path().join("crlf.txt"), "line1\r\nline2\r\nline3\r\n").unwrap();
let result = tool
.call(
&json!({
"path": "crlf.txt",
"edits": [{"old_text": "line2", "new_text": "LINE2"}]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("Successfully applied 1 edit"));
let content = std::fs::read_to_string(dir.path().join("crlf.txt")).unwrap();
assert_eq!(content, "line1\r\nLINE2\r\nline3\r\n");
}
#[tokio::test]
async fn test_edit_file_overlapping_edits_rejected() {
let (dir, tool) = setup_temp_workspace();
std::fs::write(dir.path().join("overlap.txt"), "hello world\n").unwrap();
let result = tool
.call(
&json!({
"path": "overlap.txt",
"edits": [
{"old_text": "hello world", "new_text": "hi"},
{"old_text": "world", "new_text": "earth"}
]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("overlap"));
}
#[tokio::test]
async fn test_edit_file_path_traversal_rejected() {
let (_dir, tool) = setup_temp_workspace();
let result = tool
.call(
&json!({
"path": "../outside.txt",
"edits": [{"old_text": "x", "new_text": "y"}]
}),
&dummy_ctx(),
)
.await
.unwrap();
assert!(result.summary.contains("Error"));
}
#[tokio::test]
async fn test_name_and_definition() {
let tool = EditFileTool::new(PathBuf::from("/tmp"));
assert_eq!(tool.name(), "edit_file");
let def = tool.definition();
assert_eq!(def["function"]["name"], "edit_file");
}
#[tokio::test]
async fn test_metadata() {
let tool = EditFileTool::new(PathBuf::from("/tmp"));
let meta = tool.metadata();
assert_eq!(meta.name, "edit_file");
assert_eq!(meta.origin, "phi-kernel-tools");
}
#[test]
fn test_find_rstrip_matches() {
let content = "hello \nworld\n";
let matches = find_rstrip_matches(content, "hello");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].1, "hello ");
}
#[test]
fn test_find_trim_matches() {
let content = " hello \nworld\n";
let matches = find_trim_matches(content, "hello");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].1, " hello ");
}
#[test]
fn test_level2_rstrip_fallback() {
let content = "hello \nworld\n";
let result = find_and_replace(
content,
content,
&Edit {
old_text: "hello ".to_string(),
new_text: "hi".to_string(),
},
0,
)
.unwrap();
assert_eq!(result, "hi\nworld\n");
}
}