code_mesh_core/tool/
multiedit.rs

1//! Multi-edit tool implementation for batch file editing with atomic transactions
2
3use 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
14/// Tool for performing multiple file edits in a single atomic operation
15pub 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        // Validate parameters
102        if params.edits.is_empty() {
103            return Err(ToolError::InvalidParameters(
104                "At least one edit operation is required".to_string()
105            ));
106        }
107        
108        // Check for invalid edits
109        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        // Resolve file path
118        let path = if PathBuf::from(&params.file_path).is_absolute() {
119            PathBuf::from(&params.file_path)
120        } else {
121            ctx.working_directory.join(&params.file_path)
122        };
123        
124        // Create backup before any modifications
125        let backup = self.create_backup(&path).await?;
126        
127        // Attempt to apply all edits
128        match self.apply_edits_atomic(&path, &params.edits, &ctx).await {
129            Ok(results) => {
130                // Success - clean up backup and return results
131                self.cleanup_backup(&backup).await.ok(); // Don't fail if cleanup fails
132                self.format_success_result(&params.file_path, &backup.original_content, &path, results).await
133            }
134            Err(error) => {
135                // Failure - restore from backup
136                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    /// Create a backup of the file before modifications
151    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        // Write backup file
157        fs::write(&backup_path, &original_content).await?;
158        
159        Ok(FileBackup {
160            backup_id,
161            original_content,
162            backup_path,
163        })
164    }
165    
166    /// Apply all edits atomically - if any fail, return error
167    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        // Replacement strategies to try
177        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        // Apply each edit sequentially
185        for (i, edit) in edits.iter().enumerate() {
186            // Check for cancellation
187            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            // Try each replacement strategy
196            for (strategy_name, strategy) in &strategies {
197                let result = strategy.replace(&current_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        // All edits successful - write final content
225        fs::write(path, &current_content).await?;
226        
227        Ok(results)
228    }
229    
230    /// Restore file from backup
231    async fn restore_backup(&self, backup: &FileBackup, path: &PathBuf) -> Result<(), ToolError> {
232        fs::write(path, &backup.original_content).await?;
233        Ok(())
234    }
235    
236    /// Clean up backup file
237    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    /// Format successful result with comprehensive information
245    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        // Calculate total replacements
255        let total_replacements: usize = results.iter().map(|r| r.replacements).sum();
256        
257        // Generate comprehensive diff
258        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        // Create detailed metadata
277        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// Note: Replacement strategies are imported at the top of the file
332
333#[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        // Verify rollback - content should be unchanged
413        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}