//! 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 { 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(); } }