code_mesh_core/tool/
edit.rs

1//! Edit tool implementation with multiple replacement strategies
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use tokio::fs;
8use similar::TextDiff;
9
10use super::{Tool, ToolContext, ToolResult, ToolError};
11
12/// Tool for editing files with smart replacement strategies
13pub struct EditTool;
14
15#[derive(Debug, Deserialize)]
16struct EditParams {
17    file_path: String,
18    old_string: String,
19    new_string: String,
20    #[serde(default)]
21    replace_all: bool,
22}
23
24#[async_trait]
25impl Tool for EditTool {
26    fn id(&self) -> &str {
27        "edit"
28    }
29    
30    fn description(&self) -> &str {
31        "Edit files using find-and-replace with smart matching strategies"
32    }
33    
34    fn parameters_schema(&self) -> Value {
35        json!({
36            "type": "object",
37            "properties": {
38                "file_path": {
39                    "type": "string",
40                    "description": "Path to the file to edit"
41                },
42                "old_string": {
43                    "type": "string",
44                    "description": "Text to find and replace"
45                },
46                "new_string": {
47                    "type": "string",
48                    "description": "Replacement text"
49                },
50                "replace_all": {
51                    "type": "boolean",
52                    "description": "Replace all occurrences (default: false)",
53                    "default": false
54                }
55            },
56            "required": ["file_path", "old_string", "new_string"]
57        })
58    }
59    
60    async fn execute(
61        &self,
62        args: Value,
63        ctx: ToolContext,
64    ) -> Result<ToolResult, ToolError> {
65        let params: EditParams = serde_json::from_value(args)
66            .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
67        
68        if params.old_string == params.new_string {
69            return Err(ToolError::InvalidParameters(
70                "old_string and new_string cannot be the same".to_string()
71            ));
72        }
73        
74        // Resolve path
75        let path = if PathBuf::from(&params.file_path).is_absolute() {
76            PathBuf::from(&params.file_path)
77        } else {
78            ctx.working_directory.join(&params.file_path)
79        };
80        
81        // Read file
82        let content = fs::read_to_string(&path).await?;
83        
84        // Try different replacement strategies
85        let strategies: [Box<dyn ReplacementStrategy>; 4] = [
86            Box::new(SimpleReplacer),
87            Box::new(LineTrimmedReplacer),
88            Box::new(WhitespaceNormalizedReplacer),
89            Box::new(IndentationFlexibleReplacer),
90        ];
91        
92        let mut replacements = 0;
93        let mut new_content = content.clone();
94        
95        for strategy in &strategies {
96            let result = strategy.replace(&content, &params.old_string, &params.new_string, params.replace_all);
97            if result.count > 0 {
98                new_content = result.content;
99                replacements = result.count;
100                break;
101            }
102        }
103        
104        if replacements == 0 {
105            return Err(ToolError::ExecutionFailed(format!(
106                "Could not find '{}' in {}. The file might have been modified since you last read it.",
107                params.old_string.chars().take(100).collect::<String>(),
108                params.file_path
109            )));
110        }
111        
112        // Write updated content
113        fs::write(&path, &new_content).await?;
114        
115        // Generate diff
116        let diff = TextDiff::from_lines(&content, &new_content);
117        let mut diff_output = String::new();
118        for change in diff.iter_all_changes() {
119            match change.tag() {
120                similar::ChangeTag::Delete => diff_output.push_str(&format!("- {}", change)),
121                similar::ChangeTag::Insert => diff_output.push_str(&format!("+ {}", change)),
122                similar::ChangeTag::Equal => {},
123            }
124        }
125        
126        let metadata = json!({
127            "path": path.to_string_lossy(),
128            "replacements": replacements,
129            "replace_all": params.replace_all,
130            "diff": diff_output,
131        });
132        
133        Ok(ToolResult {
134            title: format!("Made {} replacement{} in {}", 
135                replacements, 
136                if replacements == 1 { "" } else { "s" },
137                params.file_path
138            ),
139            metadata,
140            output: format!(
141                "Successfully replaced {} occurrence{} of '{}' with '{}' in {}",
142                replacements,
143                if replacements == 1 { "" } else { "s" },
144                params.old_string.chars().take(50).collect::<String>(),
145                params.new_string.chars().take(50).collect::<String>(),
146                params.file_path
147            ),
148        })
149    }
150}
151
152/// Replacement strategy trait
153pub trait ReplacementStrategy: Send + Sync {
154    fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult;
155}
156
157pub struct ReplaceResult {
158    pub content: String,
159    pub count: usize,
160}
161
162/// Simple exact string replacement
163pub struct SimpleReplacer;
164
165impl ReplacementStrategy for SimpleReplacer {
166    fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
167        if replace_all {
168            let count = content.matches(old).count();
169            ReplaceResult {
170                content: content.replace(old, new),
171                count,
172            }
173        } else {
174            if let Some(pos) = content.find(old) {
175                let mut result = content.to_string();
176                result.replace_range(pos..pos + old.len(), new);
177                ReplaceResult { content: result, count: 1 }
178            } else {
179                ReplaceResult { content: content.to_string(), count: 0 }
180            }
181        }
182    }
183}
184
185/// Replacement with trimmed line matching
186pub struct LineTrimmedReplacer;
187
188impl ReplacementStrategy for LineTrimmedReplacer {
189    fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
190        let old_lines: Vec<&str> = old.lines().collect();
191        let content_lines: Vec<&str> = content.lines().collect();
192        
193        if old_lines.is_empty() {
194            return ReplaceResult { content: content.to_string(), count: 0 };
195        }
196        
197        let mut result_lines: Vec<String> = Vec::new();
198        let mut i = 0;
199        let mut count = 0;
200        
201        while i < content_lines.len() {
202            let mut matched = true;
203            
204            // Check if we have enough lines to match
205            if i + old_lines.len() > content_lines.len() {
206                result_lines.push(content_lines[i].to_string());
207                i += 1;
208                continue;
209            }
210            
211            // Try to match trimmed lines
212            for (j, old_line) in old_lines.iter().enumerate() {
213                if content_lines[i + j].trim() != old_line.trim() {
214                    matched = false;
215                    break;
216                }
217            }
218            
219            if matched {
220                // Replace with new content
221                for new_line in new.lines() {
222                    result_lines.push(new_line.to_string());
223                }
224                i += old_lines.len();
225                count += 1;
226                
227                if !replace_all {
228                    // Copy remaining lines
229                    result_lines.extend(content_lines[i..].iter().map(|s| s.to_string()));
230                    break;
231                }
232            } else {
233                result_lines.push(content_lines[i].to_string());
234                i += 1;
235            }
236        }
237        
238        ReplaceResult {
239            content: result_lines.join("\n"),
240            count,
241        }
242    }
243}
244
245/// Replacement with normalized whitespace
246pub struct WhitespaceNormalizedReplacer;
247
248impl ReplacementStrategy for WhitespaceNormalizedReplacer {
249    fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
250        let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
251        let old_normalized = normalize(old);
252        
253        // Simple implementation - could be more sophisticated
254        let content_normalized = normalize(content);
255        if let Some(_) = content_normalized.find(&old_normalized) {
256            // For now, fall back to simple replacement
257            SimpleReplacer.replace(content, old, new, replace_all)
258        } else {
259            ReplaceResult { content: content.to_string(), count: 0 }
260        }
261    }
262}
263
264/// Replacement with flexible indentation
265pub struct IndentationFlexibleReplacer;
266
267impl ReplacementStrategy for IndentationFlexibleReplacer {
268    fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
269        // Detect common indentation in old string
270        let old_lines: Vec<&str> = old.lines().collect();
271        if old_lines.is_empty() {
272            return ReplaceResult { content: content.to_string(), count: 0 };
273        }
274        
275        // Find minimum indentation in old string
276        let min_indent = old_lines.iter()
277            .filter(|line| !line.trim().is_empty())
278            .map(|line| line.len() - line.trim_start().len())
279            .min()
280            .unwrap_or(0);
281        
282        // Strip common indentation from old string
283        let stripped_old: Vec<String> = old_lines.iter()
284            .map(|line| {
285                if line.trim().is_empty() {
286                    line.to_string()
287                } else {
288                    line.chars().skip(min_indent).collect()
289                }
290            })
291            .collect();
292        
293        // Try to find this pattern in content with any indentation
294        let content_lines: Vec<&str> = content.lines().collect();
295        let mut result_lines: Vec<String> = Vec::new();
296        let mut i = 0;
297        let mut count = 0;
298        
299        while i < content_lines.len() {
300            let mut matched = true;
301            let mut found_indent = 0;
302            
303            if i + stripped_old.len() > content_lines.len() {
304                result_lines.push(content_lines[i].to_string());
305                i += 1;
306                continue;
307            }
308            
309            // Try to match with flexible indentation
310            for (j, stripped_line) in stripped_old.iter().enumerate() {
311                let content_line = content_lines[i + j];
312                
313                if stripped_line.trim().is_empty() {
314                    if !content_line.trim().is_empty() {
315                        matched = false;
316                        break;
317                    }
318                } else {
319                    if j == 0 {
320                        // Determine indentation from first line
321                        found_indent = content_line.len() - content_line.trim_start().len();
322                    }
323                    
324                    let expected_content = if stripped_line.trim().is_empty() {
325                        ""
326                    } else {
327                        &format!("{}{}", " ".repeat(found_indent), stripped_line.trim_start())
328                    };
329                    
330                    if content_line != expected_content {
331                        matched = false;
332                        break;
333                    }
334                }
335            }
336            
337            if matched {
338                // Replace with new content, maintaining indentation
339                let indent_str = " ".repeat(found_indent);
340                for new_line in new.lines() {
341                    if new_line.trim().is_empty() {
342                        result_lines.push("".to_string());
343                    } else {
344                        result_lines.push(format!("{}{}", indent_str, new_line.trim_start()));
345                    }
346                }
347                i += stripped_old.len();
348                count += 1;
349                
350                if !replace_all {
351                    result_lines.extend(content_lines[i..].iter().map(|s| s.to_string()));
352                    break;
353                }
354            } else {
355                result_lines.push(content_lines[i].to_string());
356                i += 1;
357            }
358        }
359        
360        ReplaceResult {
361            content: result_lines.join("\n"),
362            count,
363        }
364    }
365}