mod replacers; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use async_trait::async_trait; use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput}; use schemars::JsonSchema; use serde::Deserialize; use similar::{ChangeTag, TextDiff}; use tokio::sync::Mutex as AsyncMutex; use crate::paths; #[derive(Debug, Deserialize, JsonSchema)] struct EditParams { file_path: String, old_string: String, new_string: String, replace_all: Option, } /// Per-file `tokio::Mutex` lock map so concurrent edits to different files proceed in /// parallel while edits to the *same* file serialize (docs/05-tools.md). pub struct EditTool { locks: StdMutex>>>, } impl EditTool { pub fn new() -> Self { Self { locks: StdMutex::new(HashMap::new()), } } fn lock_for(&self, path: &Path) -> Arc> { self.locks .lock() .unwrap() .entry(path.to_path_buf()) .or_insert_with(|| Arc::new(AsyncMutex::new(()))) .clone() } } impl Default for EditTool { fn default() -> Self { Self::new() } } fn normalize_line_endings(text: &str) -> String { text.replace("\r\n", "\n") } fn detect_line_ending(text: &str) -> &'static str { if text.contains("\r\n") { "\r\n" } else { "\n" } } fn convert_line_ending(text: &str, ending: &str) -> String { if ending == "\n" { text.to_string() } else { text.replace('\n', "\r\n") } } const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF]; fn strip_bom(bytes: &[u8]) -> (bool, &[u8]) { if bytes.starts_with(&UTF8_BOM) { (true, &bytes[3..]) } else { (false, bytes) } } fn unified_diff(before: &str, after: &str, file_path: &str) -> String { TextDiff::from_lines(before, after) .unified_diff() .header(&format!("a/{file_path}"), &format!("b/{file_path}")) .to_string() } fn diff_stats(before: &str, after: &str) -> (usize, usize) { let diff = TextDiff::from_lines(before, after); let mut added = 0; let mut removed = 0; for change in diff.iter_all_changes() { match change.tag() { ChangeTag::Insert => added += 1, ChangeTag::Delete => removed += 1, ChangeTag::Equal => {} } } (added, removed) } #[async_trait] impl Tool for EditTool { fn name(&self) -> &str { "edit" } fn description(&self) -> &str { "Replaces an exact span of text in a file (fuzzy-matched against whitespace/indentation drift)." } fn parameters(&self) -> serde_json::Value { serde_json::to_value(schemars::schema_for!(EditParams)).unwrap() } async fn execute( &self, input: serde_json::Value, ctx: ToolCtx, ) -> Result { let params: EditParams = serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?; if params.old_string == params.new_string { return Err(ToolError::Invalid( "No changes to apply: oldString and newString are identical.".into(), )); } let path = paths::resolve(&ctx.cwd, ¶ms.file_path); let pattern = paths::relative_pattern(&ctx.cwd, &path); let lock = self.lock_for(&path); let _guard = lock.lock().await; let metadata = tokio::fs::metadata(&path).await.ok(); if metadata.as_ref().is_some_and(|m| m.is_dir()) { return Err(ToolError::Invalid(format!( "Path is a directory, not a file: {}", path.display() ))); } let (content_old, content_new, had_bom) = if params.old_string.is_empty() { if metadata.is_some() { return Err(ToolError::Invalid( "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.".into(), )); } (String::new(), params.new_string.clone(), false) } else { if metadata.is_none() { return Err(ToolError::Invalid(format!( "File {} not found", path.display() ))); } let bytes = tokio::fs::read(&path) .await .map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?; let (had_bom, stripped) = strip_bom(&bytes); let text = String::from_utf8_lossy(stripped).into_owned(); let ending = detect_line_ending(&text); let old = convert_line_ending(&normalize_line_endings(¶ms.old_string), ending); let new_ = convert_line_ending(&normalize_line_endings(¶ms.new_string), ending); let replaced = replacers::replace(&text, &old, &new_, params.replace_all.unwrap_or(false)) .map_err(|e| ToolError::Invalid(e.to_string()))?; (text, replaced, had_bom) }; let diff = unified_diff(&content_old, &content_new, ¶ms.file_path); ctx.ask .ask( "edit", pattern.clone(), "*", serde_json::json!({"file_path": params.file_path, "diff": diff}), ) .await?; if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| ToolError::Other(format!("{}: {e}", parent.display())))?; } let mut out_bytes = Vec::with_capacity(content_new.len() + 3); if had_bom { out_bytes.extend_from_slice(&UTF8_BOM); } out_bytes.extend_from_slice(content_new.as_bytes()); tokio::fs::write(&path, &out_bytes) .await .map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?; let (added, removed) = diff_stats(&content_old, &content_new); let mut output = ToolOutput::new(pattern, "Edit applied successfully.".to_string()); output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed}); Ok(output) } } #[cfg(test)] mod tests { use super::*; use harness_core::event::EventBus; use harness_core::permission::{spawn_auto_approve, PermissionService}; use harness_core::tool::{MetadataSink, PermissionHandle}; use harness_core::types::SessionId; use std::sync::Mutex; use tokio_util::sync::CancellationToken; fn ctx(cwd: PathBuf) -> ToolCtx { let bus = EventBus::new(); let service = Arc::new(PermissionService::new(bus.clone())); spawn_auto_approve(bus, service.clone()); let (metadata, _rx) = MetadataSink::channel(); ToolCtx { session_id: SessionId::new(), message_id: harness_core::types::MessageId::new(), call_id: "call_1".into(), data_dir: cwd.join("tool-output"), cwd, cancel: CancellationToken::new(), ask: PermissionHandle::new( service, SessionId::new(), Vec::new(), Arc::new(Mutex::new(Vec::new())), CancellationToken::new(), ), metadata, } } #[tokio::test] async fn creates_new_file_when_old_string_is_empty() { let dir = tempfile::tempdir().unwrap(); let result = EditTool::new() .execute( serde_json::json!({"file_path": "newfile.txt", "old_string": "", "new_string": "new content"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(result.metadata["diff"] .as_str() .unwrap() .contains("new content")); assert_eq!( std::fs::read_to_string(dir.path().join("newfile.txt")).unwrap(), "new content" ); } #[tokio::test] async fn rejects_empty_old_string_on_existing_file() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("existing.txt"), "original").unwrap(); let err = EditTool::new() .execute( serde_json::json!({"file_path": "existing.txt", "old_string": "", "new_string": "x"}), ctx(dir.path().to_path_buf()), ) .await .unwrap_err(); assert!( matches!(&err, ToolError::Invalid(msg) if msg.contains("oldString cannot be empty")) ); assert_eq!( std::fs::read_to_string(dir.path().join("existing.txt")).unwrap(), "original" ); } #[tokio::test] async fn creates_new_file_with_nested_directories() { let dir = tempfile::tempdir().unwrap(); EditTool::new() .execute( serde_json::json!({"file_path": "nested/dir/file.txt", "old_string": "", "new_string": "nested file"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert_eq!( std::fs::read_to_string(dir.path().join("nested/dir/file.txt")).unwrap(), "nested file" ); } #[tokio::test] async fn replaces_text_in_existing_file() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("existing.txt"), "old content here").unwrap(); let result = EditTool::new() .execute( serde_json::json!({"file_path": "existing.txt", "old_string": "old content", "new_string": "new content"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(result.output.contains("Edit applied successfully")); assert_eq!( std::fs::read_to_string(dir.path().join("existing.txt")).unwrap(), "new content here" ); } #[tokio::test] async fn preserves_bom_and_only_edits_visible_content() { let dir = tempfile::tempdir().unwrap(); let mut original = UTF8_BOM.to_vec(); original.extend_from_slice(b"using System;\nclass Test {}\n"); std::fs::write(dir.path().join("existing.cs"), &original).unwrap(); let result = EditTool::new() .execute( serde_json::json!({"file_path": "existing.cs", "old_string": "using System;", "new_string": "using Up;"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(result.metadata["diff"] .as_str() .unwrap() .contains("-using System;")); assert!(result.metadata["diff"] .as_str() .unwrap() .contains("+using Up;")); let content = std::fs::read(dir.path().join("existing.cs")).unwrap(); assert_eq!(&content[..3], &UTF8_BOM); assert_eq!(&content[3..], b"using Up;\nclass Test {}\n"); } #[tokio::test] async fn errors_when_file_does_not_exist() { let dir = tempfile::tempdir().unwrap(); let err = EditTool::new() .execute( serde_json::json!({"file_path": "nonexistent.txt", "old_string": "old", "new_string": "new"}), ctx(dir.path().to_path_buf()), ) .await .unwrap_err(); assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("not found"))); } #[tokio::test] async fn errors_when_old_equals_new() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "content").unwrap(); let err = EditTool::new() .execute( serde_json::json!({"file_path": "file.txt", "old_string": "same", "new_string": "same"}), ctx(dir.path().to_path_buf()), ) .await .unwrap_err(); assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("identical"))); } #[tokio::test] async fn errors_when_path_is_a_directory() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("adir")).unwrap(); let err = EditTool::new() .execute( serde_json::json!({"file_path": "adir", "old_string": "old", "new_string": "new"}), ctx(dir.path().to_path_buf()), ) .await .unwrap_err(); assert!(matches!(&err, ToolError::Invalid(msg) if msg.contains("directory"))); } #[tokio::test] async fn replaces_all_occurrences_with_replace_all_option() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "foo bar foo baz foo").unwrap(); EditTool::new() .execute( serde_json::json!({"file_path": "file.txt", "old_string": "foo", "new_string": "qux", "replace_all": true}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "qux bar qux baz qux" ); } #[tokio::test] async fn handles_crlf_line_endings() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "line1\r\nold\r\nline3").unwrap(); EditTool::new() .execute( serde_json::json!({"file_path": "file.txt", "old_string": "old", "new_string": "new"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "line1\r\nnew\r\nline3" ); } #[tokio::test] async fn tracks_file_diff_statistics() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("file.txt"), "line1\nline2\nline3").unwrap(); let result = EditTool::new() .execute( serde_json::json!({"file_path": "file.txt", "old_string": "line2", "new_string": "new line a\nnew line b"}), ctx(dir.path().to_path_buf()), ) .await .unwrap(); assert!(result.metadata["added"].as_u64().unwrap() > 0); } #[tokio::test] async fn concurrent_edits_to_the_same_file_serialize_without_losing_either_change() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("file.txt"), "top = 0\nmiddle = keep\nbottom = 0\n", ) .unwrap(); let tool = Arc::new(EditTool::new()); let t1 = tool.clone(); let cwd1 = dir.path().to_path_buf(); let h1 = tokio::spawn(async move { t1.execute( serde_json::json!({"file_path": "file.txt", "old_string": "top = 0", "new_string": "top = 1"}), ctx(cwd1), ) .await }); let t2 = tool.clone(); let cwd2 = dir.path().to_path_buf(); let h2 = tokio::spawn(async move { t2.execute( serde_json::json!({"file_path": "file.txt", "old_string": "bottom = 0", "new_string": "bottom = 2"}), ctx(cwd2), ) .await }); h1.await.unwrap().unwrap(); h2.await.unwrap().unwrap(); assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "top = 1\nmiddle = keep\nbottom = 2\n" ); } }