use crate::chat::agent::AgentEvent;
use crate::chat::edits::Resolution;
use crate::chat::tools::{
looks_binary, path_to_forward_slash, Tool, ToolContext, ToolError, MAX_FILE_BYTES,
};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
pub(crate) struct EditFileTool;
#[derive(Deserialize)]
struct EditFileInput {
path: String,
old_string: String,
new_string: String,
}
#[async_trait]
impl Tool for EditFileTool {
fn name(&self) -> &'static str {
"edit_file"
}
fn description(&self) -> &'static str {
"Propose an exact-string edit to a workspace file. The user must \
approve the change in the chat panel before it is written to disk. \
`old_string` must appear EXACTLY ONCE in the file (whitespace and \
newlines included); ambiguous matches or mismatches return an error \
and the model should retry with more surrounding context. UTF-8 \
files only; capped at 1 MiB. Returns a short status string once the \
user has accepted or rejected the proposal."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Workspace-relative path to the file to edit."
},
"old_string": {
"type": "string",
"description": "Exact substring to replace. Must occur exactly once."
},
"new_string": {
"type": "string",
"description": "Replacement string. Pass an empty string to delete `old_string`."
}
},
"required": ["path", "old_string", "new_string"]
})
}
async fn run(&self, ctx: &ToolContext, input: serde_json::Value) -> Result<String, ToolError> {
let args: EditFileInput = serde_json::from_value(input)
.map_err(|e| ToolError::InvalidArgument(format!("malformed input: {e}")))?;
if args.old_string.is_empty() {
return Err(ToolError::InvalidArgument(
"old_string must not be empty (use a separate write_file tool for new files)"
.into(),
));
}
if args.old_string == args.new_string {
return Err(ToolError::InvalidArgument(
"old_string and new_string are identical — no-op".into(),
));
}
let abs = ctx.resolve(&args.path)?;
let metadata = std::fs::metadata(&abs).map_err(|e| ToolError::Io(e.to_string()))?;
if metadata.len() > MAX_FILE_BYTES {
return Err(ToolError::TooLarge);
}
let bytes = std::fs::read(&abs).map_err(|e| ToolError::Io(e.to_string()))?;
if looks_binary(&bytes) {
return Err(ToolError::Binary);
}
let content = std::str::from_utf8(&bytes)
.map_err(|_| ToolError::Binary)?
.to_string();
let matches = content.matches(&args.old_string).count();
if matches == 0 {
return Err(ToolError::InvalidArgument(
"old_string not found in file (drift? check casing, whitespace, newlines)".into(),
));
}
if matches > 1 {
return Err(ToolError::InvalidArgument(format!(
"old_string occurs {matches} times — add surrounding context so it matches exactly once"
)));
}
let byte_offset = content
.find(&args.old_string)
.expect("just verified matches == 1");
let line = 1 + content[..byte_offset]
.bytes()
.filter(|b| *b == b'\n')
.count();
let store = ctx
.pending_edits
.as_ref()
.ok_or_else(|| ToolError::Internal("pending-edit store unavailable".into()))?;
let sink = ctx
.event_sink
.as_ref()
.ok_or_else(|| ToolError::Internal("agent event sink unavailable".into()))?;
let edit_id = uuid::Uuid::new_v4().to_string();
let rel = path_to_forward_slash(Path::new(&args.path));
let rx = store.insert(
edit_id.clone(),
rel.clone(),
args.old_string.clone(),
args.new_string.clone(),
line,
);
let _ = sink
.send(AgentEvent::EditPending {
id: edit_id.clone(),
tool_use_id: String::new(),
path: rel,
line,
old_string: args.old_string.clone(),
new_string: args.new_string.clone(),
})
.await;
let resolution = rx.await.unwrap_or(Resolution::Abandoned);
match resolution {
Resolution::Applied { line } => Ok(format!(
"applied: {} (1 occurrence replaced at line {line}); the file now reflects new_string",
args.path
)),
Resolution::Rejected => Err(ToolError::InvalidArgument(format!(
"user rejected the edit to {}; do not propose the same change again unless asked",
args.path
))),
Resolution::Drifted => Err(ToolError::InvalidArgument(format!(
"edit dropped: {} changed between proposal and apply, so old_string no longer matches exactly; \
re-read the file and propose a fresh edit if still wanted",
args.path
))),
Resolution::Abandoned => Err(ToolError::InvalidArgument(format!(
"edit abandoned: the user closed the chat panel before resolving the edit to {}",
args.path
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::edits::PendingEditStore;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::sync::mpsc;
fn ctx_with_state() -> (
TempDir,
ToolContext,
Arc<PendingEditStore>,
mpsc::Receiver<AgentEvent>,
) {
let td = TempDir::new().unwrap();
let store = Arc::new(PendingEditStore::new());
let (tx, rx) = mpsc::channel::<AgentEvent>(8);
let ctx = ToolContext::new(td.path())
.unwrap()
.with_chat_state(store.clone(), tx);
(td, ctx, store, rx)
}
#[tokio::test]
async fn rejects_empty_old_string() {
let (_td, ctx, _store, _rx) = ctx_with_state();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "x.md", "old_string": "", "new_string": "y" }),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn rejects_identical_strings() {
let (_td, ctx, _store, _rx) = ctx_with_state();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "x.md", "old_string": "same", "new_string": "same" }),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn rejects_traversal() {
let (_td, ctx, _store, _rx) = ctx_with_state();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "../escape.md", "old_string": "a", "new_string": "b" }),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::OutsideWorkspace));
}
#[tokio::test]
async fn rejects_missing_file() {
let (_td, ctx, _store, _rx) = ctx_with_state();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "no-such.md", "old_string": "a", "new_string": "b" }),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::NotFound(_)));
}
#[tokio::test]
async fn rejects_ambiguous_match() {
let (td, ctx, _store, _rx) = ctx_with_state();
std::fs::write(td.path().join("doc.md"), b"foo bar foo baz").unwrap();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "doc.md", "old_string": "foo", "new_string": "FOO" }),
)
.await
.unwrap_err();
match err {
ToolError::InvalidArgument(msg) => assert!(msg.contains("2 times"), "got {msg}"),
other => panic!("unexpected error {other:?}"),
}
}
#[tokio::test]
async fn rejects_missing_match() {
let (td, ctx, _store, _rx) = ctx_with_state();
std::fs::write(td.path().join("doc.md"), b"foo bar baz").unwrap();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "doc.md", "old_string": "QUUX", "new_string": "X" }),
)
.await
.unwrap_err();
match err {
ToolError::InvalidArgument(msg) => assert!(msg.contains("not found"), "got {msg}"),
other => panic!("unexpected error {other:?}"),
}
}
#[tokio::test]
async fn rejects_binary_file() {
let (td, ctx, _store, _rx) = ctx_with_state();
std::fs::write(td.path().join("bin"), b"\0\0\0").unwrap();
let err = EditFileTool
.run(
&ctx,
json!({ "path": "bin", "old_string": "abc", "new_string": "x" }),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Binary));
}
#[tokio::test]
async fn happy_path_applied_returns_success_string() {
let (td, ctx, store, mut rx) = ctx_with_state();
std::fs::write(td.path().join("doc.md"), b"hello world\nfoo bar\n").unwrap();
let ctx2 = ctx.clone();
let join = tokio::spawn(async move {
EditFileTool
.run(
&ctx2,
json!({ "path": "doc.md", "old_string": "foo bar", "new_string": "FOO" }),
)
.await
});
let evt = rx.recv().await.unwrap();
let id = match evt {
AgentEvent::EditPending { id, line, .. } => {
assert_eq!(line, 2, "old_string sits on line 2 of the test file");
id
}
other => panic!("expected EditPending, got {other:?}"),
};
store.resolve(&id, Resolution::Applied { line: 2 }).unwrap();
let out = join.await.unwrap().unwrap();
assert!(out.starts_with("applied: doc.md"), "got: {out}");
}
#[tokio::test]
async fn rejected_returns_invalid_argument_error() {
let (td, ctx, store, mut rx) = ctx_with_state();
std::fs::write(td.path().join("doc.md"), b"foo bar").unwrap();
let ctx2 = ctx.clone();
let join = tokio::spawn(async move {
EditFileTool
.run(
&ctx2,
json!({ "path": "doc.md", "old_string": "foo", "new_string": "FOO" }),
)
.await
});
let id = match rx.recv().await.unwrap() {
AgentEvent::EditPending { id, .. } => id,
other => panic!("expected EditPending, got {other:?}"),
};
store.resolve(&id, Resolution::Rejected).unwrap();
let err = join.await.unwrap().unwrap_err();
match err {
ToolError::InvalidArgument(msg) => assert!(msg.contains("user rejected"), "got {msg}"),
other => panic!("unexpected error {other:?}"),
}
}
}