//! `Edit` tool — partial string replacement with uniqueness check. use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use serde::Deserialize; use crate::error::ToolsError; use crate::scoped_fs::ScopedFs; use crate::tracker::Tracker; const DESCRIPTION: &str = "Replace a substring in an existing file. By default \ `old_string` must be unique in the file; set `replace_all: true` to replace \ every occurrence. The file must have been read first (via the Read tool) in \ this session. Paths must be absolute."; #[derive(Debug, Deserialize, schemars::JsonSchema)] pub(crate) struct EditParams { /// Absolute path to the file. pub file_path: PathBuf, /// String to replace. Must be unique in the file unless `replace_all` is true. pub old_string: String, /// Replacement string. Must differ from `old_string`. pub new_string: String, /// Replace all occurrences. Defaults to false. #[serde(default)] pub replace_all: bool, } pub(crate) struct EditTool { fs: ScopedFs, tracker: Tracker, } #[async_trait] impl Tool for EditTool { async fn execute(&self, input_json: &str) -> Result { let params: EditParams = serde_json::from_str(input_json) .map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?; tracing::debug!( path = %params.file_path.display(), replace_all = params.replace_all, "Edit" ); if params.old_string.is_empty() { return Err(ToolError::InvalidArgument( "old_string must not be empty".into(), )); } if params.old_string == params.new_string { return Err(ToolError::InvalidArgument( "old_string and new_string are identical".into(), )); } // Load current content and verify it matches the recorded hash. let current_bytes = self.fs.read_bytes(¶ms.file_path)?; self.tracker.verify(¶ms.file_path, ¤t_bytes)?; let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| { ToolsError::InvalidArgument(format!( "file is not valid UTF-8: {}", params.file_path.display() )) })?; let count = current_text.matches(¶ms.old_string).count(); if count == 0 { return Err(ToolsError::StringNotFound { path: params.file_path.clone(), } .into()); } if !params.replace_all && count > 1 { return Err(ToolsError::NotUnique { path: params.file_path.clone(), count, } .into()); } let new_text = if params.replace_all { current_text.replace(¶ms.old_string, ¶ms.new_string) } else { current_text.replacen(¶ms.old_string, ¶ms.new_string, 1) }; let occurrences = if params.replace_all { count } else { 1 }; self.fs.write(¶ms.file_path, new_text.as_bytes())?; self.tracker.record(¶ms.file_path, new_text.as_bytes()); let summary = format!( "Edited {} ({} replacement{})", params.file_path.display(), occurrences, if occurrences == 1 { "" } else { "s" } ); let preview = make_preview(&new_text, ¶ms.new_string); Ok(ToolOutput { summary, content: Some(preview), }) } } /// Build a small line-numbered snippet centered on the first occurrence of /// `needle` in `text`. Shows ±3 surrounding lines. fn make_preview(text: &str, needle: &str) -> String { let lines: Vec<&str> = text.lines().collect(); if lines.is_empty() { return String::new(); } let first_needle_line = needle.lines().next().unwrap_or(needle); let hit = lines .iter() .position(|l| l.contains(first_needle_line)) .unwrap_or(0); let start = hit.saturating_sub(3); let end = (hit + 4).min(lines.len()); use std::fmt::Write as _; let mut out = String::new(); for (i, line) in lines[start..end].iter().enumerate() { let lineno = start + i + 1; let _ = writeln!(&mut out, "{:>6}\t{}", lineno, line); } out } /// Factory for the `Edit` tool. pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(EditParams); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); let meta = ToolMeta::new("Edit") .description(DESCRIPTION) .input_schema(schema_value); let tool: Arc = Arc::new(EditTool { fs: fs.clone(), tracker: tracker.clone(), }); (meta, tool) }) } #[cfg(test)] mod tests { use super::*; use crate::read::read_tool; use manifest::Scope; use tempfile::TempDir; fn setup() -> (TempDir, ScopedFs, Tracker) { let dir = TempDir::new().unwrap(); let fs = ScopedFs::new( Scope::writable(dir.path()).unwrap(), dir.path().to_path_buf(), ); (dir, fs, Tracker::new()) } async fn read_first(fs: &ScopedFs, tracker: &Tracker, file: &std::path::Path) { let def = read_tool(fs.clone(), tracker.clone()); let (_, reader) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap() }); reader.execute(&inp.to_string()).await.unwrap(); } #[tokio::test] async fn edit_unique_replacement() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "line1\nfoo bar\nline3\n").unwrap(); read_first(&fs, &tracker, &file).await; let def = edit_tool(fs, tracker); let (meta, tool) = def(); assert_eq!(meta.name, "Edit"); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "foo bar", "new_string": "foo baz", }); let out = tool.execute(&inp.to_string()).await.unwrap(); assert!(out.summary.contains("1 replacement")); assert_eq!( std::fs::read_to_string(&file).unwrap(), "line1\nfoo baz\nline3\n" ); assert!(out.content.unwrap().contains("foo baz")); } #[tokio::test] async fn edit_replace_all() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "x x x\n").unwrap(); read_first(&fs, &tracker, &file).await; let def = edit_tool(fs, tracker); let (_, tool) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "x", "new_string": "y", "replace_all": true, }); let out = tool.execute(&inp.to_string()).await.unwrap(); assert!(out.summary.contains("3 replacements")); assert_eq!(std::fs::read_to_string(&file).unwrap(), "y y y\n"); } #[tokio::test] async fn edit_not_unique() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "a a\n").unwrap(); read_first(&fs, &tracker, &file).await; let def = edit_tool(fs, tracker); let (_, tool) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "a", "new_string": "b", }); let err = tool.execute(&inp.to_string()).await.unwrap_err(); assert!(matches!(err, ToolError::InvalidArgument(_))); } #[tokio::test] async fn edit_string_not_found() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "hello\n").unwrap(); read_first(&fs, &tracker, &file).await; let def = edit_tool(fs, tracker); let (_, tool) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "world", "new_string": "x", }); let err = tool.execute(&inp.to_string()).await.unwrap_err(); assert!(matches!(err, ToolError::InvalidArgument(_))); } #[tokio::test] async fn edit_requires_prior_read() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "foo\n").unwrap(); let def = edit_tool(fs, tracker); let (_, tool) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "foo", "new_string": "bar", }); let err = tool.execute(&inp.to_string()).await.unwrap_err(); assert!(matches!(err, ToolError::InvalidArgument(_))); } #[tokio::test] async fn edit_detects_external_modification() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "foo\n").unwrap(); read_first(&fs, &tracker, &file).await; // External tampering between read and edit std::fs::write(&file, "something else").unwrap(); let def = edit_tool(fs, tracker); let (_, tool) = def(); let inp = serde_json::json!({ "file_path": file.to_str().unwrap(), "old_string": "foo", "new_string": "bar", }); let err = tool.execute(&inp.to_string()).await.unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("modified externally"), "{msg}"); } }