91 lines
3 KiB
Rust
91 lines
3 KiB
Rust
|
|
// tools/edit.rs — Search-and-replace file editing
|
||
|
|
//
|
||
|
|
// The edit tool performs exact string replacement in files. This is the
|
||
|
|
// same pattern used by Claude Code and aider — it's more reliable than
|
||
|
|
// line-number-based editing because the model specifies what it sees,
|
||
|
|
// not where it thinks it is.
|
||
|
|
//
|
||
|
|
// Supports replace_all for bulk renaming (e.g. variable renames).
|
||
|
|
|
||
|
|
use anyhow::{Context, Result};
|
||
|
|
use serde::Deserialize;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
use crate::agent::types::ToolDef;
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
struct Args {
|
||
|
|
file_path: String,
|
||
|
|
old_string: String,
|
||
|
|
new_string: String,
|
||
|
|
#[serde(default)]
|
||
|
|
replace_all: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn definition() -> ToolDef {
|
||
|
|
ToolDef::new(
|
||
|
|
"edit_file",
|
||
|
|
"Perform exact string replacement in a file. The old_string must appear \
|
||
|
|
exactly once in the file (unless replace_all is true). Use read_file first \
|
||
|
|
to see the current contents.",
|
||
|
|
json!({
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"file_path": {
|
||
|
|
"type": "string",
|
||
|
|
"description": "Absolute path to the file to edit"
|
||
|
|
},
|
||
|
|
"old_string": {
|
||
|
|
"type": "string",
|
||
|
|
"description": "The exact text to find and replace"
|
||
|
|
},
|
||
|
|
"new_string": {
|
||
|
|
"type": "string",
|
||
|
|
"description": "The replacement text"
|
||
|
|
},
|
||
|
|
"replace_all": {
|
||
|
|
"type": "boolean",
|
||
|
|
"description": "Replace all occurrences (default false)"
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"required": ["file_path", "old_string", "new_string"]
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn edit_file(args: &serde_json::Value) -> Result<String> {
|
||
|
|
let a: Args = serde_json::from_value(args.clone())
|
||
|
|
.context("invalid edit_file arguments")?;
|
||
|
|
|
||
|
|
if a.old_string == a.new_string {
|
||
|
|
anyhow::bail!("old_string and new_string are identical");
|
||
|
|
}
|
||
|
|
|
||
|
|
let content = std::fs::read_to_string(&a.file_path)
|
||
|
|
.with_context(|| format!("Failed to read {}", a.file_path))?;
|
||
|
|
|
||
|
|
let count = content.matches(&*a.old_string).count();
|
||
|
|
if count == 0 {
|
||
|
|
anyhow::bail!("old_string not found in {}", a.file_path);
|
||
|
|
}
|
||
|
|
|
||
|
|
if a.replace_all {
|
||
|
|
let new_content = content.replace(&*a.old_string, &a.new_string);
|
||
|
|
std::fs::write(&a.file_path, &new_content)
|
||
|
|
.with_context(|| format!("Failed to write {}", a.file_path))?;
|
||
|
|
Ok(format!("Replaced {} occurrences in {}", count, a.file_path))
|
||
|
|
} else {
|
||
|
|
if count > 1 {
|
||
|
|
anyhow::bail!(
|
||
|
|
"old_string appears {} times in {} — use replace_all or provide more context \
|
||
|
|
to make it unique",
|
||
|
|
count, a.file_path
|
||
|
|
);
|
||
|
|
}
|
||
|
|
let new_content = content.replacen(&*a.old_string, &a.new_string, 1);
|
||
|
|
std::fs::write(&a.file_path, &new_content)
|
||
|
|
.with_context(|| format!("Failed to write {}", a.file_path))?;
|
||
|
|
Ok(format!("Edited {}", a.file_path))
|
||
|
|
}
|
||
|
|
}
|