2026-07-11 13:16:10 +07:00
|
|
|
use std::fs;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use anyhow::{Result, anyhow};
|
|
|
|
|
use super::super::Tool;
|
|
|
|
|
use super::super::ToolCtx;
|
|
|
|
|
use super::super::resolve_path;
|
2026-07-11 18:23:01 +07:00
|
|
|
use super::super::check_graduated_checks;
|
2026-07-11 13:16:10 +07:00
|
|
|
use super::helpers::arg_str;
|
|
|
|
|
|
|
|
|
|
pub struct Edit;
|
|
|
|
|
|
|
|
|
|
impl Tool for Edit {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"edit"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn description(&self) -> &'static str {
|
|
|
|
|
"Replace a string in a file with a new string. The old string must be unique unless replace_all is true."
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parameters(&self) -> Value {
|
|
|
|
|
json!({
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"path": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "Path to the file to edit (relative to workspace root)"
|
|
|
|
|
},
|
|
|
|
|
"old": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "The exact text to replace"
|
|
|
|
|
},
|
|
|
|
|
"new": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "The replacement text"
|
|
|
|
|
},
|
|
|
|
|
"replace_all": {
|
|
|
|
|
"type": "boolean",
|
|
|
|
|
"description": "Replace all occurrences instead of requiring uniqueness"
|
|
|
|
|
},
|
|
|
|
|
"reason": {
|
|
|
|
|
"type": "string",
|
|
|
|
|
"description": "Reason for the change (must be non-empty)"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"required": ["path", "old", "new", "reason"]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
|
|
|
|
let rel = arg_str(args, "path")?;
|
|
|
|
|
let old = arg_str(args, "old")?;
|
|
|
|
|
let new_str = arg_str(args, "new")?;
|
|
|
|
|
let reason = arg_str(args, "reason")?;
|
|
|
|
|
if reason.trim().is_empty() {
|
|
|
|
|
anyhow::bail!("reason must be a non-empty string");
|
|
|
|
|
}
|
2026-07-12 10:23:26 +07:00
|
|
|
if old.is_empty() {
|
|
|
|
|
anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents");
|
|
|
|
|
}
|
2026-07-11 18:23:01 +07:00
|
|
|
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
|
2026-07-11 13:16:10 +07:00
|
|
|
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
|
|
|
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
|
|
|
|
|
}
|
|
|
|
|
if path.is_dir() {
|
|
|
|
|
anyhow::bail!("'{}' is a directory, not a file", rel);
|
|
|
|
|
}
|
|
|
|
|
let content = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
|
|
|
|
|
if !content.contains(&old) {
|
|
|
|
|
anyhow::bail!("old string not found in '{}'", rel);
|
|
|
|
|
}
|
|
|
|
|
if !replace_all {
|
|
|
|
|
let count = content.matches(&old).count();
|
|
|
|
|
if count > 1 {
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.",
|
|
|
|
|
count, rel
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let new_content = if replace_all {
|
|
|
|
|
content.replace(&old, &new_str)
|
|
|
|
|
} else {
|
|
|
|
|
content.replacen(&old, &new_str, 1)
|
|
|
|
|
};
|
|
|
|
|
fs::write(&path, &new_content)
|
|
|
|
|
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
|
|
|
|
let bytes_diff = if new_content.len() > content.len() {
|
|
|
|
|
new_content.len() - content.len()
|
|
|
|
|
} else {
|
|
|
|
|
content.len() - new_content.len()
|
|
|
|
|
};
|
2026-07-11 18:23:01 +07:00
|
|
|
if check_matches.is_empty() {
|
|
|
|
|
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}", rel, bytes_diff as isize, check_matches.join(", ")))
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|