#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use similar::TextDiff;
use crate::tools::write::is_hard_blocked;
use crate::tools::ToolCtx;
pub struct EditTool {
ctx: Arc<ToolCtx>,
}
impl EditTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for EditTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "edit".into(),
description: "Exact-string replacement in a file. Use `replace_all=true` to replace every occurrence.".into(),
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"old_string": { "type": "string" },
"new_string": { "type": "string" },
"replace_all": { "type": "boolean", "default": false }
},
"required": ["path", "old_string", "new_string"]
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let path = match args.get("path").and_then(|v| v.as_str()) {
Some(path) => PathBuf::from(path),
None => return ToolResult::error("missing 'path'"),
};
let old = match args.get("old_string").and_then(|value| value.as_str()) {
Some(old) => old,
None => return ToolResult::error("missing 'old_string'"),
};
let new = match args.get("new_string").and_then(|value| value.as_str()) {
Some(new) => new,
None => return ToolResult::error("missing 'new_string'"),
};
let replace_all = args
.get("replace_all")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if old == new {
return ToolResult::error("old_string and new_string are identical");
}
let abs = if path.is_absolute() {
path
} else {
ctx.cwd.join(&path)
};
if is_hard_blocked(&abs) {
return ToolResult::error(format!("edit blocked: {} is protected", abs.display()));
}
let canonical = tokio::fs::canonicalize(&abs)
.await
.unwrap_or_else(|_| abs.clone());
if !ctx.has_been_read(&canonical).await && !ctx.has_been_read(&abs).await {
return ToolResult::error(format!(
"refusing to edit {} without reading it first",
abs.display()
));
}
let original = match tokio::fs::read_to_string(&abs).await {
Ok(text) => text,
Err(err) => return ToolResult::error(format!("read failed: {err}")),
};
if !original.contains(old) {
return ToolResult::error("old_string not found in file");
}
let count = original.matches(old).count();
if !replace_all && count > 1 {
return ToolResult::error(format!(
"old_string matches {count} occurrences; pass replace_all=true or add more context"
));
}
let replaced = if replace_all {
original.replace(old, new)
} else {
original.replacen(old, new, 1)
};
if let Err(err) = tokio::fs::write(&abs, &replaced).await {
return ToolResult::error(format!("write failed: {err}"));
}
ctx.mark_read(&canonical).await;
let diff = TextDiff::from_lines(&original, &replaced);
let udiff = diff
.unified_diff()
.context_radius(3)
.header(&abs.display().to_string(), &abs.display().to_string())
.to_string();
ToolResult::text(format!(
"{{\"path\":\"{}\",\"replacements\":{},\"diff\":{}}}",
abs.display(),
if replace_all { count } else { 1 },
serde_json::to_string(&udiff).unwrap_or_else(|_| "\"<diff render error>\"".into())
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use std::path::Path;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn replaces_single_occurrence_when_unique() {
let dir = tempdir().unwrap();
let file = dir.path().join("code.rs");
tokio::fs::write(&file, "fn main() { println!(\"old\"); }")
.await
.unwrap();
let ctx = test_ctx(dir.path());
let canonical = tokio::fs::canonicalize(&file).await.unwrap();
ctx.read_files.lock().await.insert(canonical);
let tool = EditTool::new(ctx);
let result = tool
.call(
json!({ "path": "code.rs", "old_string": "old", "new_string": "new" }),
&ToolContext::default(),
)
.await;
assert!(!result.is_error, "{result:?}");
let debug = format!("{result:?}");
assert!(debug.contains("@@"), "{debug}");
let body = tokio::fs::read_to_string(&file).await.unwrap();
assert!(body.contains("new"), "{body}");
}
#[tokio::test]
async fn rejects_ambiguous_old_string_without_replace_all() {
let dir = tempdir().unwrap();
let file = dir.path().join("code.rs");
tokio::fs::write(&file, "x\nx\n").await.unwrap();
let ctx = test_ctx(dir.path());
ctx.read_files
.lock()
.await
.insert(tokio::fs::canonicalize(&file).await.unwrap());
let tool = EditTool::new(ctx);
let result = tool
.call(
json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
&ToolContext::default(),
)
.await;
assert!(
format!("{result:?}").contains("2 occurrences"),
"{result:?}"
);
}
#[tokio::test]
async fn replace_all_true_replaces_every_match() {
let dir = tempdir().unwrap();
let file = dir.path().join("code.rs");
tokio::fs::write(&file, "x\nx\n").await.unwrap();
let ctx = test_ctx(dir.path());
ctx.read_files
.lock()
.await
.insert(tokio::fs::canonicalize(&file).await.unwrap());
let tool = EditTool::new(ctx);
let result = tool
.call(
json!({ "path": "code.rs", "old_string": "x", "new_string": "y", "replace_all": true }),
&ToolContext::default(),
)
.await;
assert!(!result.is_error, "{result:?}");
let body = tokio::fs::read_to_string(&file).await.unwrap();
assert_eq!(body, "y\ny\n");
}
#[tokio::test]
async fn rejects_identical_strings() {
let dir = tempdir().unwrap();
let file = dir.path().join("code.rs");
tokio::fs::write(&file, "x").await.unwrap();
let ctx = test_ctx(dir.path());
ctx.read_files
.lock()
.await
.insert(tokio::fs::canonicalize(&file).await.unwrap());
let tool = EditTool::new(ctx);
let result = tool
.call(
json!({ "path": "code.rs", "old_string": "x", "new_string": "x" }),
&ToolContext::default(),
)
.await;
assert!(format!("{result:?}").contains("identical"), "{result:?}");
}
#[tokio::test]
async fn rejects_missing_old_string() {
let dir = tempdir().unwrap();
let tool = EditTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "path": "code.rs", "new_string": "y" }),
&ToolContext::default(),
)
.await;
assert!(
format!("{result:?}").contains("missing 'old_string'"),
"{result:?}"
);
}
#[tokio::test]
async fn rejects_edit_without_prior_read() {
let dir = tempdir().unwrap();
let file = dir.path().join("code.rs");
tokio::fs::write(&file, "x").await.unwrap();
let tool = EditTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(debug.contains("without reading"), "{debug}");
}
}