Add the cast-allow block to zesdex-entities/src/lib.rs and zesdex-utils/src/lib.rs (which lacked it), then remove from 65 sub-files across all 8 crates. Build and all 223 tests continue to pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
7.0 KiB
Rust
199 lines
7.0 KiB
Rust
//! Tool: `edit` — replace a substring in a file with a new string.
|
|
use super::super::check_graduated_checks;
|
|
use super::super::resolve_path;
|
|
use super::super::Tool;
|
|
use super::super::ToolCtx;
|
|
use super::helpers;
|
|
use crate::tool::arg_str;
|
|
use anyhow::{anyhow, Result};
|
|
use serde_json::{json, Value};
|
|
use similar::TextDiff;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
/// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true.
|
|
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"]
|
|
})
|
|
}
|
|
|
|
/// Perform the in-file string replacement.
|
|
///
|
|
/// Flow: validate args → resolve path → read file → count occurrences →
|
|
/// replace one or all → write back → report byte delta (+ optional graduated checks).
|
|
///
|
|
/// Why: requires a non-empty `reason` and a non-empty `old` string to prevent
|
|
/// accidental identity edits. Enforces uniqueness unless `replace_all` is set.
|
|
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");
|
|
}
|
|
if old.is_empty() {
|
|
anyhow::bail!(
|
|
"'old' must be a non-empty string; use 'write' to replace entire file contents"
|
|
);
|
|
}
|
|
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
|
|
let replace_all = args
|
|
.get("replace_all")
|
|
.and_then(serde_json::Value::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!("'{rel}' is a directory, not a file");
|
|
}
|
|
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 {count} times in '{rel}'. Set replace_all=true to replace all occurrences, or provide a more specific match."
|
|
);
|
|
}
|
|
}
|
|
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 text_diff = TextDiff::from_lines(content.as_str(), new_content.as_str());
|
|
let diff_text = format!(
|
|
"{}",
|
|
text_diff
|
|
.unified_diff()
|
|
.context_radius(3)
|
|
.header(&rel, &rel)
|
|
);
|
|
let diff_block = format!("```diff\n{}\n```", helpers::truncate_diff(&diff_text));
|
|
// Notify the LSP server of the on-disk change so diagnostics stay fresh.
|
|
// Never fail the edit because of this — LSP errors are surfaced as a
|
|
// trailing annotation on the success message instead.
|
|
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
|
|
lsp.did_change_file(&path);
|
|
String::new()
|
|
} else {
|
|
String::new()
|
|
};
|
|
if check_matches.is_empty() {
|
|
Ok(format!("edited {rel}\n{diff_block}{lsp_note}"))
|
|
} else {
|
|
Ok(format!(
|
|
"edited {rel}. Graduated checks matched: {}\n{diff_block}{lsp_note}",
|
|
check_matches.join(", ")
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_ctx(workspace: std::path::PathBuf) -> crate::tool::ToolCtx {
|
|
crate::tool::ToolCtx::builder()
|
|
.workspaces(vec![workspace])
|
|
.build()
|
|
}
|
|
|
|
fn temp_workspace() -> std::path::PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("zesdex-edit-test-{}", uuid::Uuid::new_v4()));
|
|
fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn edit_returns_a_diff_block_for_a_single_replace() {
|
|
let workspace = temp_workspace();
|
|
fs::write(workspace.join("a.txt"), "line1\nline2\nline3\n").unwrap();
|
|
let ctx = test_ctx(workspace.clone());
|
|
let args = json!({
|
|
"path": "a.txt",
|
|
"old": "line2",
|
|
"new": "changed",
|
|
"reason": "test edit"
|
|
});
|
|
let result = Edit.run(&ctx, &args).unwrap();
|
|
assert!(result.contains("```diff"));
|
|
assert!(result.contains("-line2"));
|
|
assert!(result.contains("+changed"));
|
|
fs::remove_dir_all(&workspace).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn edit_truncates_a_very_large_diff() {
|
|
let workspace = temp_workspace();
|
|
let old_content: String = (0..300).fold(String::new(), |mut acc, i| {
|
|
use std::fmt::Write;
|
|
let _ = writeln!(acc, "line{i}");
|
|
acc
|
|
});
|
|
let new_content: String = (0..300).fold(String::new(), |mut acc, i| {
|
|
use std::fmt::Write;
|
|
let _ = writeln!(acc, "changed{i}");
|
|
acc
|
|
});
|
|
fs::write(workspace.join("big.txt"), &old_content).unwrap();
|
|
let ctx = test_ctx(workspace.clone());
|
|
let args = json!({
|
|
"path": "big.txt",
|
|
"old": &old_content,
|
|
"new": &new_content,
|
|
"reason": "test large replace"
|
|
});
|
|
let result = Edit.run(&ctx, &args).unwrap();
|
|
assert!(result.contains("more lines truncated"));
|
|
fs::remove_dir_all(&workspace).ok();
|
|
}
|
|
}
|