1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use tokio::fs;
8use similar::TextDiff;
9use uuid::Uuid;
10
11use super::{Tool, ToolContext, ToolResult, ToolError};
12use super::edit::{SimpleReplacer, LineTrimmedReplacer, WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, ReplacementStrategy};
13
14pub struct MultiEditTool;
16
17#[derive(Debug, Deserialize, Serialize, Clone)]
18pub struct EditOperation {
19 pub old_string: String,
20 pub new_string: String,
21 #[serde(default)]
22 pub replace_all: bool,
23}
24
25#[derive(Debug, Deserialize)]
26struct MultiEditParams {
27 file_path: String,
28 edits: Vec<EditOperation>,
29}
30
31#[derive(Debug)]
32struct FileBackup {
33 backup_id: String,
34 original_content: String,
35 backup_path: PathBuf,
36}
37
38#[derive(Debug)]
39struct EditResult {
40 operation_index: usize,
41 replacements: usize,
42 strategy_used: String,
43 content_after: String,
44}
45
46#[async_trait]
47impl Tool for MultiEditTool {
48 fn id(&self) -> &str {
49 "multiedit"
50 }
51
52 fn description(&self) -> &str {
53 "Perform multiple file edits in a single atomic operation with rollback support"
54 }
55
56 fn parameters_schema(&self) -> Value {
57 json!({
58 "type": "object",
59 "properties": {
60 "file_path": {
61 "type": "string",
62 "description": "Path to the file to edit"
63 },
64 "edits": {
65 "type": "array",
66 "description": "Array of edit operations to perform sequentially",
67 "items": {
68 "type": "object",
69 "properties": {
70 "old_string": {
71 "type": "string",
72 "description": "Text to find and replace"
73 },
74 "new_string": {
75 "type": "string",
76 "description": "Replacement text"
77 },
78 "replace_all": {
79 "type": "boolean",
80 "description": "Replace all occurrences (default: false)",
81 "default": false
82 }
83 },
84 "required": ["old_string", "new_string"]
85 },
86 "minItems": 1
87 }
88 },
89 "required": ["file_path", "edits"]
90 })
91 }
92
93 async fn execute(
94 &self,
95 args: Value,
96 ctx: ToolContext,
97 ) -> Result<ToolResult, ToolError> {
98 let params: MultiEditParams = serde_json::from_value(args)
99 .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
100
101 if params.edits.is_empty() {
103 return Err(ToolError::InvalidParameters(
104 "At least one edit operation is required".to_string()
105 ));
106 }
107
108 for (i, edit) in params.edits.iter().enumerate() {
110 if edit.old_string == edit.new_string {
111 return Err(ToolError::InvalidParameters(format!(
112 "Edit operation {} has identical old_string and new_string", i
113 )));
114 }
115 }
116
117 let path = if PathBuf::from(¶ms.file_path).is_absolute() {
119 PathBuf::from(¶ms.file_path)
120 } else {
121 ctx.working_directory.join(¶ms.file_path)
122 };
123
124 let backup = self.create_backup(&path).await?;
126
127 match self.apply_edits_atomic(&path, ¶ms.edits, &ctx).await {
129 Ok(results) => {
130 self.cleanup_backup(&backup).await.ok(); self.format_success_result(¶ms.file_path, &backup.original_content, &path, results).await
133 }
134 Err(error) => {
135 if let Err(restore_error) = self.restore_backup(&backup, &path).await {
137 return Err(ToolError::ExecutionFailed(format!(
138 "Edit failed: {}. Backup restoration also failed: {}",
139 error, restore_error
140 )));
141 }
142 self.cleanup_backup(&backup).await.ok();
143 Err(error)
144 }
145 }
146 }
147}
148
149impl MultiEditTool {
150 async fn create_backup(&self, path: &PathBuf) -> Result<FileBackup, ToolError> {
152 let original_content = fs::read_to_string(path).await?;
153 let backup_id = Uuid::new_v4().to_string();
154 let backup_path = path.with_extension(format!("backup.{}", backup_id));
155
156 fs::write(&backup_path, &original_content).await?;
158
159 Ok(FileBackup {
160 backup_id,
161 original_content,
162 backup_path,
163 })
164 }
165
166 async fn apply_edits_atomic(
168 &self,
169 path: &PathBuf,
170 edits: &[EditOperation],
171 ctx: &ToolContext,
172 ) -> Result<Vec<EditResult>, ToolError> {
173 let mut current_content = fs::read_to_string(path).await?;
174 let mut results = Vec::new();
175
176 let strategies: Vec<(&str, Box<dyn ReplacementStrategy + Send + Sync>)> = vec![
178 ("simple", Box::new(SimpleReplacer)),
179 ("line_trimmed", Box::new(LineTrimmedReplacer)),
180 ("whitespace_normalized", Box::new(WhitespaceNormalizedReplacer)),
181 ("indentation_flexible", Box::new(IndentationFlexibleReplacer)),
182 ];
183
184 for (i, edit) in edits.iter().enumerate() {
186 if *ctx.abort_signal.borrow() {
188 return Err(ToolError::Aborted);
189 }
190
191 let mut found_replacement = false;
192 let mut replacements = 0;
193 let mut strategy_used = String::new();
194
195 for (strategy_name, strategy) in &strategies {
197 let result = strategy.replace(¤t_content, &edit.old_string, &edit.new_string, edit.replace_all);
198 if result.count > 0 {
199 current_content = result.content;
200 replacements = result.count;
201 strategy_used = strategy_name.to_string();
202 found_replacement = true;
203 break;
204 }
205 }
206
207 if !found_replacement {
208 return Err(ToolError::ExecutionFailed(format!(
209 "Edit operation {} failed: Could not find '{}' in file after {} previous edit(s)",
210 i,
211 edit.old_string.chars().take(100).collect::<String>(),
212 i
213 )));
214 }
215
216 results.push(EditResult {
217 operation_index: i,
218 replacements,
219 strategy_used,
220 content_after: current_content.clone(),
221 });
222 }
223
224 fs::write(path, ¤t_content).await?;
226
227 Ok(results)
228 }
229
230 async fn restore_backup(&self, backup: &FileBackup, path: &PathBuf) -> Result<(), ToolError> {
232 fs::write(path, &backup.original_content).await?;
233 Ok(())
234 }
235
236 async fn cleanup_backup(&self, backup: &FileBackup) -> Result<(), ToolError> {
238 if backup.backup_path.exists() {
239 fs::remove_file(&backup.backup_path).await?;
240 }
241 Ok(())
242 }
243
244 async fn format_success_result(
246 &self,
247 file_path: &str,
248 original_content: &str,
249 final_path: &PathBuf,
250 results: Vec<EditResult>,
251 ) -> Result<ToolResult, ToolError> {
252 let final_content = fs::read_to_string(final_path).await?;
253
254 let total_replacements: usize = results.iter().map(|r| r.replacements).sum();
256
257 let diff = TextDiff::from_lines(original_content, &final_content);
259 let mut diff_output = String::new();
260 let mut changes_count = 0;
261
262 for change in diff.iter_all_changes() {
263 match change.tag() {
264 similar::ChangeTag::Delete => {
265 diff_output.push_str(&format!("- {}", change));
266 changes_count += 1;
267 }
268 similar::ChangeTag::Insert => {
269 diff_output.push_str(&format!("+ {}", change));
270 changes_count += 1;
271 }
272 similar::ChangeTag::Equal => {},
273 }
274 }
275
276 let edit_details: Vec<Value> = results.iter().map(|result| {
278 json!({
279 "operation_index": result.operation_index,
280 "replacements": result.replacements,
281 "strategy_used": result.strategy_used
282 })
283 }).collect();
284
285 let metadata = json!({
286 "path": final_path.to_string_lossy(),
287 "total_operations": results.len(),
288 "total_replacements": total_replacements,
289 "operations_details": edit_details,
290 "diff": diff_output,
291 "diff_changes": changes_count,
292 "atomic_transaction": true
293 });
294
295 let operations_summary = results.iter()
296 .map(|r| format!("Op {}: {} replacement{} ({})",
297 r.operation_index,
298 r.replacements,
299 if r.replacements == 1 { "" } else { "s" },
300 r.strategy_used
301 ))
302 .collect::<Vec<_>>()
303 .join(", ");
304
305 Ok(ToolResult {
306 title: format!(
307 "Successfully completed {} edit operation{} with {} total replacement{} in {}",
308 results.len(),
309 if results.len() == 1 { "" } else { "s" },
310 total_replacements,
311 if total_replacements == 1 { "" } else { "s" },
312 file_path
313 ),
314 metadata,
315 output: format!(
316 "Multi-edit completed successfully:\n\
317 - File: {}\n\
318 - Total operations: {}\n\
319 - Total replacements: {}\n\
320 - Operations: {}\n\
321 - Atomic transaction: All edits applied successfully or rolled back on failure",
322 file_path,
323 results.len(),
324 total_replacements,
325 operations_summary
326 ),
327 })
328 }
329}
330
331#[cfg(test)]
334mod tests {
335 use super::*;
336 use tempfile::NamedTempFile;
337 use std::io::Write;
338
339 #[tokio::test]
340 async fn test_multiedit_atomic_success() {
341 let mut temp_file = NamedTempFile::new().unwrap();
342 writeln!(temp_file, "Hello world\nThis is a test\nEnd of file").unwrap();
343 let temp_path = temp_file.path().to_path_buf();
344
345 let tool = MultiEditTool;
346 let params = json!({
347 "file_path": temp_path.to_string_lossy(),
348 "edits": [
349 {
350 "old_string": "Hello",
351 "new_string": "Hi",
352 "replace_all": false
353 },
354 {
355 "old_string": "test",
356 "new_string": "example",
357 "replace_all": false
358 }
359 ]
360 });
361
362 let ctx = ToolContext {
363 session_id: "test".to_string(),
364 message_id: "test".to_string(),
365 abort_signal: tokio::sync::watch::channel(false).1,
366 working_directory: std::env::current_dir().unwrap(),
367 };
368
369 let result = tool.execute(params, ctx).await.unwrap();
370 assert!(result.title.contains("2 edit operation"));
371 assert!(result.title.contains("2 total replacement"));
372
373 let content = fs::read_to_string(&temp_path).await.unwrap();
374 assert!(content.contains("Hi world"));
375 assert!(content.contains("This is a example"));
376 }
377
378 #[tokio::test]
379 async fn test_multiedit_atomic_failure_rollback() {
380 let mut temp_file = NamedTempFile::new().unwrap();
381 writeln!(temp_file, "Hello world\nThis is a test\nEnd of file").unwrap();
382 let temp_path = temp_file.path().to_path_buf();
383 let original_content = fs::read_to_string(&temp_path).await.unwrap();
384
385 let tool = MultiEditTool;
386 let params = json!({
387 "file_path": temp_path.to_string_lossy(),
388 "edits": [
389 {
390 "old_string": "Hello",
391 "new_string": "Hi",
392 "replace_all": false
393 },
394 {
395 "old_string": "nonexistent",
396 "new_string": "replacement",
397 "replace_all": false
398 }
399 ]
400 });
401
402 let ctx = ToolContext {
403 session_id: "test".to_string(),
404 message_id: "test".to_string(),
405 abort_signal: tokio::sync::watch::channel(false).1,
406 working_directory: std::env::current_dir().unwrap(),
407 };
408
409 let result = tool.execute(params, ctx).await;
410 assert!(result.is_err());
411
412 let final_content = fs::read_to_string(&temp_path).await.unwrap();
414 assert_eq!(original_content, final_content);
415 }
416
417 #[tokio::test]
418 async fn test_multiedit_replace_all() {
419 let mut temp_file = NamedTempFile::new().unwrap();
420 writeln!(temp_file, "test test test\nAnother test line").unwrap();
421 let temp_path = temp_file.path().to_path_buf();
422
423 let tool = MultiEditTool;
424 let params = json!({
425 "file_path": temp_path.to_string_lossy(),
426 "edits": [
427 {
428 "old_string": "test",
429 "new_string": "example",
430 "replace_all": true
431 }
432 ]
433 });
434
435 let ctx = ToolContext {
436 session_id: "test".to_string(),
437 message_id: "test".to_string(),
438 abort_signal: tokio::sync::watch::channel(false).1,
439 working_directory: std::env::current_dir().unwrap(),
440 };
441
442 let result = tool.execute(params, ctx).await.unwrap();
443 assert!(result.title.contains("4 total replacement"));
444
445 let content = fs::read_to_string(&temp_path).await.unwrap();
446 assert_eq!(content, "example example example\nAnother example line\n");
447 }
448}