use std::sync::Arc;
use std::sync::OnceLock;
use async_trait::async_trait;
use caliban_agent_core::{Tool, ToolContext, ToolError};
use caliban_provider::{ContentBlock, TextBlock};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::fs::match_old::{self, MatchOutcome};
use crate::workspace::WorkspaceRoot;
#[derive(Debug)]
pub struct EditTool {
root: Arc<WorkspaceRoot>,
schema: OnceLock<Value>,
}
impl EditTool {
#[must_use]
pub fn new(root: WorkspaceRoot) -> Self {
Self {
root: Arc::new(root),
schema: OnceLock::new(),
}
}
}
#[derive(Debug, Deserialize)]
struct EditInput {
path: String,
old_string: String,
new_string: String,
#[serde(default)]
replace_all: bool,
}
#[async_trait]
impl Tool for EditTool {
fn name(&self) -> &'static str {
"Edit"
}
fn mutates_files(&self) -> bool {
true
}
fn description(&self) -> &'static str {
"Replace occurrences of old_string with new_string in a file. By default expects exactly one match; set replace_all=true to replace all occurrences."
}
fn input_schema(&self) -> &Value {
self.schema.get_or_init(|| json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to edit (relative to workspace root or absolute)" },
"old_string": { "type": "string", "description": "Exact text to search for in the file" },
"new_string": { "type": "string", "description": "Text to replace old_string with" },
"replace_all": { "type": "boolean", "description": "Replace all occurrences instead of requiring exactly one (default false)" }
},
"required": ["path", "old_string", "new_string"]
}))
}
fn parallel_conflict_key(&self, input: &Value) -> Option<String> {
input
.get("path")
.and_then(Value::as_str)
.map(crate::parallel::canonical_key)
}
async fn invoke(&self, input: Value, cx: ToolContext) -> Result<Vec<ContentBlock>, ToolError> {
let parsed: EditInput = crate::parse_input(input)?;
let path = self.root.resolve(&parsed.path)?;
let text = tokio::fs::read_to_string(&path)
.await
.map_err(ToolError::execution)?;
let outcome = match_old::locate(
&text,
&parsed.old_string,
&parsed.new_string,
parsed.replace_all,
);
let (ranges, replacement) = match outcome {
MatchOutcome::Located {
ranges,
replacement,
tier,
} => {
if tier == match_old::MatchTier::Whitespace {
tracing::debug!(
path = %path.display(),
"Edit: matched via whitespace-tolerant tier"
);
}
(ranges, replacement)
}
MatchOutcome::Ambiguous { count, locations } => {
let locs: Vec<String> = locations
.iter()
.map(|(s, e)| format!("lines {s}-{e}"))
.collect();
return Err(ToolError::execution(std::io::Error::other(format!(
"old_string matched {count} times; expected exactly one (use replace_all=true to replace all). Locations: {}",
locs.join(", ")
))));
}
MatchOutcome::NotFound { near } => {
let msg = match near {
Some(nm) => nm.render(),
None => "old_string not found in file".to_string(),
};
return Err(ToolError::execution(std::io::Error::other(msg)));
}
};
let count = ranges.len();
let mut replaced = text.clone();
for range in ranges.iter().rev() {
replaced.replace_range(range.clone(), &replacement);
}
caliban_common::fs::write_atomic(&path, replaced.as_bytes())
.map_err(ToolError::execution)?;
cx.fire_file_changed(&path, caliban_agent_core::FileChangeKind::Modified, "Edit")
.await;
Ok(vec![ContentBlock::Text(TextBlock {
text: format!(
"→ Edited {} ({} replacement{})",
self.root.relativize(&path).display(),
count,
if count == 1 { "" } else { "s" },
),
cache_control: None,
})])
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
fn ctx() -> ToolContext {
ToolContext {
tool_use_id: "t1".into(),
cancel: CancellationToken::new(),
hooks: None,
turn_index: 0,
}
}
#[tokio::test]
async fn single_match_replaces_and_writes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "hello foo world").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let out = tool
.invoke(
json!({"path": "file.txt", "old_string": "foo", "new_string": "bar"}),
ctx(),
)
.await
.unwrap();
let ContentBlock::Text(t) = &out[0] else {
panic!("expected Text block")
};
assert!(t.text.contains("Edited"), "output: {}", t.text);
assert!(t.text.contains("1 replacement"), "output: {}", t.text);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, "hello bar world");
}
#[tokio::test]
async fn zero_match_errors() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "hello world").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let err = tool
.invoke(
json!({"path": "file.txt", "old_string": "foo", "new_string": "bar"}),
ctx(),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Execution(_)));
let msg = format!("{err}");
assert!(
msg.contains("closest match") || msg.contains("old_string not found in file"),
"unexpected error message format: {msg}"
);
}
#[tokio::test]
async fn not_found_near_none_when_old_longer_than_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "hello\n").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let err = tool
.invoke(
json!({
"path": "file.txt",
"old_string": "aaa\nbbb\nccc",
"new_string": "replaced"
}),
ctx(),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Execution(_)));
let msg = format!("{err}");
assert!(
msg.contains("old_string not found in file"),
"expected bare not-found message, got: {msg}"
);
assert!(
!msg.contains("closest match"),
"near-miss should be None for over-long old_string, got: {msg}"
);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(
contents, "hello\n",
"file should be unchanged after failed edit"
);
}
#[tokio::test]
async fn multiple_matches_without_replace_all_errors() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "foo and foo").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let err = tool
.invoke(
json!({"path": "file.txt", "old_string": "foo", "new_string": "bar"}),
ctx(),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Execution(_)));
let msg = format!("{err}");
assert!(msg.contains("2 times"), "error message: {msg}");
}
#[tokio::test]
async fn replace_all_replaces_multiple() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "foo and foo").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let out = tool
.invoke(
json!({"path": "file.txt", "old_string": "foo", "new_string": "bar", "replace_all": true}),
ctx(),
)
.await
.unwrap();
let ContentBlock::Text(t) = &out[0] else {
panic!("expected Text block")
};
assert!(t.text.contains("2 replacements"), "output: {}", t.text);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, "bar and bar");
}
#[tokio::test]
async fn trailing_whitespace_in_old_string_still_applies() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "let x = 1;\nlet y = 2;\n").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let out = tool
.invoke(
json!({
"path": "file.txt",
"old_string": "let x = 1; \nlet y = 2;",
"new_string": "let x = 9;\nlet y = 8;"
}),
ctx(),
)
.await
.unwrap();
let ContentBlock::Text(t) = &out[0] else {
panic!("expected Text block")
};
assert!(t.text.contains("1 replacement"), "output: {}", t.text);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, "let x = 9;\nlet y = 8;\n");
}
#[tokio::test]
async fn uniform_underindent_applies_with_correct_indentation() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, " if x {\n y();\n }\n").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let out = tool
.invoke(
json!({
"path": "file.txt",
"old_string": "if x {\n y();\n}",
"new_string": "if x {\n z();\n}"
}),
ctx(),
)
.await
.unwrap();
let ContentBlock::Text(t) = &out[0] else {
panic!("expected Text block")
};
assert!(t.text.contains("1 replacement"), "output: {}", t.text);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, " if x {\n z();\n }\n");
for line in written.lines().filter(|l| !l.trim().is_empty()) {
assert!(
line.starts_with(" "),
"line should have 4-space indent: {line:?}"
);
}
}
#[tokio::test]
async fn replace_all_whitespace_tier_uniform_windows_reindents_all_sites() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(
&path,
" if x {\n y();\n }\nMID\n if x {\n y();\n }\n",
)
.unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let out = tool
.invoke(
json!({
"path": "file.txt",
"old_string": "if x {\n y();\n}",
"new_string": "if x {\n z();\n}",
"replace_all": true
}),
ctx(),
)
.await
.unwrap();
let ContentBlock::Text(t) = &out[0] else {
panic!("expected Text block")
};
assert!(t.text.contains("2 replacements"), "output: {}", t.text);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(
written,
" if x {\n z();\n }\nMID\n if x {\n z();\n }\n"
);
for line in written
.lines()
.filter(|l| !l.trim().is_empty() && *l != "MID")
{
assert!(
line.starts_with(" "),
"line should keep 4-space indent: {line:?}"
);
}
}
#[tokio::test]
async fn true_miss_returns_near_miss_feedback_not_bare_message() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("file.txt");
std::fs::write(&path, "fn alpha() {\n do_thing();\n}\n").unwrap();
let tool = EditTool::new(WorkspaceRoot::new(tmp.path()));
let err = tool
.invoke(
json!({
"path": "file.txt",
"old_string": "fn alpha() {\n do_OTHER();\n}",
"new_string": "fn alpha() {}"
}),
ctx(),
)
.await
.unwrap_err();
assert!(matches!(err, ToolError::Execution(_)));
let msg = format!("{err}");
assert!(
!msg.contains("old_string not found in file"),
"should be near-miss feedback, not bare error: {msg}"
);
assert!(
msg.contains("- ") || msg.contains("+ "),
"no diff in: {msg}"
);
assert!(
msg.contains("do_OTHER") || msg.contains("do_thing"),
"expected diff content in: {msg}"
);
}
}