Skip to main content

opendev_tools_impl/
multi_edit.rs

1//! Multi-edit tool — apply multiple sequential edits to a single file atomically.
2//!
3//! Instead of calling `edit_file` N times (each reading/writing the file), this
4//! tool reads the file once, applies all edits in-memory in order, writes the
5//! result atomically, and returns a single combined diff.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, LazyLock, Mutex};
10
11use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
12
13use crate::diagnostics_helper;
14use crate::edit_replacers;
15use crate::formatter;
16use crate::path_utils::{is_sensitive_file, resolve_file_path, validate_path_access};
17
18// ---------------------------------------------------------------------------
19// Per-file locking: serialize concurrent edits to the same file.
20// ---------------------------------------------------------------------------
21
22static FILE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
23    LazyLock::new(|| Mutex::new(HashMap::new()));
24
25fn get_file_lock(path: &Path) -> Arc<Mutex<()>> {
26    let mut map = FILE_LOCKS.lock().unwrap();
27    map.entry(path.to_path_buf())
28        .or_insert_with(|| Arc::new(Mutex::new(())))
29        .clone()
30}
31
32// ---------------------------------------------------------------------------
33// MultiEditTool
34// ---------------------------------------------------------------------------
35
36/// Tool for applying multiple sequential edits to a single file atomically.
37#[derive(Debug)]
38pub struct MultiEditTool;
39
40/// A single edit operation within a multi-edit batch.
41struct EditOp {
42    old_string: String,
43    new_string: String,
44    replace_all: bool,
45}
46
47#[async_trait::async_trait]
48impl BaseTool for MultiEditTool {
49    fn name(&self) -> &str {
50        "multi_edit"
51    }
52
53    fn description(&self) -> &str {
54        "Apply multiple sequential edits to a single file atomically. \
55         The file is read once, all edits are applied in order in memory, \
56         then written back in a single atomic operation. Each edit uses the \
57         same 9-pass fuzzy matching as edit_file."
58    }
59
60    fn parameter_schema(&self) -> serde_json::Value {
61        serde_json::json!({
62            "type": "object",
63            "properties": {
64                "file_path": {
65                    "type": "string",
66                    "description": "Absolute path to the file to edit"
67                },
68                "edits": {
69                    "type": "array",
70                    "description": "Array of edit operations to apply sequentially",
71                    "items": {
72                        "type": "object",
73                        "properties": {
74                            "old_string": {
75                                "type": "string",
76                                "description": "The string to find and replace. Must be different from new_string"
77                            },
78                            "new_string": {
79                                "type": "string",
80                                "description": "The replacement string. Must be different from old_string"
81                            },
82                            "replace_all": {
83                                "type": "boolean",
84                                "description": "Replace all occurrences (default: false)"
85                            }
86                        },
87                        "required": ["old_string", "new_string"]
88                    }
89                }
90            },
91            "required": ["file_path", "edits"]
92        })
93    }
94
95    async fn execute(
96        &self,
97        args: HashMap<String, serde_json::Value>,
98        ctx: &ToolContext,
99    ) -> ToolResult {
100        // --- Parse arguments ---
101        let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
102            Some(p) => p,
103            None => return ToolResult::fail("file_path is required"),
104        };
105
106        let edits_val = match args.get("edits").and_then(|v| v.as_array()) {
107            Some(arr) => arr,
108            None => return ToolResult::fail("edits is required and must be an array"),
109        };
110
111        if edits_val.is_empty() {
112            return ToolResult::fail("edits array must not be empty");
113        }
114
115        // Parse each edit operation
116        let mut edits = Vec::with_capacity(edits_val.len());
117        for (i, edit_val) in edits_val.iter().enumerate() {
118            let old_string = match edit_val.get("old_string").and_then(|v| v.as_str()) {
119                Some(s) => s.to_string(),
120                None => {
121                    return ToolResult::fail(format!("edit[{i}]: old_string is required"));
122                }
123            };
124            let new_string = match edit_val.get("new_string").and_then(|v| v.as_str()) {
125                Some(s) => s.to_string(),
126                None => {
127                    return ToolResult::fail(format!("edit[{i}]: new_string is required"));
128                }
129            };
130            let replace_all = edit_val
131                .get("replace_all")
132                .and_then(|v| v.as_bool())
133                .unwrap_or(false);
134
135            if old_string == new_string {
136                continue;
137            }
138
139            edits.push(EditOp {
140                old_string,
141                new_string,
142                replace_all,
143            });
144        }
145
146        // --- Resolve path and check existence ---
147        let path = resolve_file_path(file_path, &ctx.working_dir);
148
149        if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
150            return ToolResult::fail(msg);
151        }
152
153        if !path.exists() {
154            return ToolResult::fail(format!("File not found: {file_path}"));
155        }
156
157        // Block editing sensitive files.
158        if let Some(reason) = is_sensitive_file(&path) {
159            return ToolResult::fail(format!(
160                "Refusing to edit {}: {} — this file likely contains secrets. \
161                 If you need to modify it, ask the user to do so manually.",
162                file_path, reason
163            ));
164        }
165
166        // Acquire per-file lock — scoped so the guard drops before async diagnostics
167        let (output_text, metadata) = {
168            let lock = get_file_lock(&path);
169            let _guard = lock.lock().unwrap();
170
171            // --- Read file once ---
172            let original_content = match std::fs::read_to_string(&path) {
173                Ok(c) => c,
174                Err(e) => return ToolResult::fail(format!("Failed to read file: {e}")),
175            };
176
177            // --- Apply edits sequentially in memory ---
178            let mut content = original_content.clone();
179            let mut total_additions: usize = 0;
180            let mut total_removals: usize = 0;
181            let mut total_replacements: usize = 0;
182            let mut edit_summaries: Vec<String> = Vec::new();
183
184            for (i, edit) in edits.iter().enumerate() {
185                // Fuzzy match against current in-memory content
186                let (actual_old, _pass_name) =
187                    match edit_replacers::find_match(&content, &edit.old_string) {
188                        Some(m) => (m.actual, m.pass_name),
189                        None => {
190                            return ToolResult::fail(format!(
191                                "edit[{i}]: old_string not found in {file_path}. \
192                                 Make sure the string matches the file content \
193                                 (tried 9 fuzzy matching passes). \
194                                 Note: earlier edits in this batch may have changed the content."
195                            ));
196                        }
197                    };
198
199                // Uniqueness check
200                let count = content.matches(&actual_old as &str).count();
201                if count > 1 && !edit.replace_all {
202                    let positions =
203                        edit_replacers::find_occurrence_positions(&content, &actual_old);
204                    let locations: String = positions
205                        .iter()
206                        .map(|n| format!("line {n}"))
207                        .collect::<Vec<_>>()
208                        .join(", ");
209                    return ToolResult::fail(format!(
210                        "edit[{i}]: old_string found {count} times at {locations} in {file_path}. \
211                         Provide more surrounding context to make the match unique, \
212                         or use replace_all=true."
213                    ));
214                }
215
216                // Perform replacement
217                let new_content = if edit.replace_all {
218                    content.replace(&actual_old, &edit.new_string)
219                } else {
220                    content.replacen(&actual_old, &edit.new_string, 1)
221                };
222
223                // Track stats
224                let old_line_parts: Vec<&str> = actual_old.split('\n').collect();
225                let new_line_parts: Vec<&str> = edit.new_string.split('\n').collect();
226                let removals = old_line_parts.len();
227                let additions = new_line_parts.len();
228                let replacements = if edit.replace_all { count } else { 1 };
229
230                total_additions += additions;
231                total_removals += removals;
232                total_replacements += replacements;
233
234                edit_summaries.push(format!(
235                    "edit[{i}]: {replacements} replacement(s), +{additions}/-{removals} lines"
236                ));
237
238                content = new_content;
239            }
240
241            // --- Generate combined diff ---
242            let diff_text = edit_replacers::unified_diff(file_path, &original_content, &content, 3);
243
244            // --- Atomic write ---
245            let dir = path.parent().unwrap_or(Path::new("."));
246            let tmp_path = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
247
248            if let Err(e) = std::fs::write(&tmp_path, &content) {
249                return ToolResult::fail(format!("Failed to write temp file: {e}"));
250            }
251            if let Err(e) = std::fs::rename(&tmp_path, &path) {
252                let _ = std::fs::remove_file(&tmp_path);
253                return ToolResult::fail(format!("Failed to rename temp file: {e}"));
254            }
255
256            // --- Auto-format ---
257            let formatted =
258                formatter::format_file(path.to_str().unwrap_or(file_path), &ctx.working_dir);
259
260            // --- Build result ---
261            let mut metadata = HashMap::new();
262            metadata.insert(
263                "total_replacements".into(),
264                serde_json::json!(total_replacements),
265            );
266            metadata.insert("total_additions".into(), serde_json::json!(total_additions));
267            metadata.insert("total_removals".into(), serde_json::json!(total_removals));
268            metadata.insert("edits_applied".into(), serde_json::json!(edits.len()));
269            metadata.insert("diff".into(), serde_json::json!(diff_text));
270            if formatted {
271                metadata.insert("formatted".into(), serde_json::json!(true));
272            }
273
274            let fmt_note = if formatted { " (formatted)" } else { "" };
275            let summary = format!(
276                "Applied {} edit(s) to {file_path}: {total_replacements} total replacement(s), \
277                 {total_additions} addition(s) and {total_removals} removal(s){fmt_note}",
278                edits.len()
279            );
280
281            let details = edit_summaries.join("\n");
282            let output_text = if diff_text.is_empty() {
283                format!("{summary}\n{details}")
284            } else {
285                format!("{summary}\n{details}\n{diff_text}")
286            };
287
288            (output_text, metadata)
289        }; // lock guard dropped here
290
291        // Collect LSP diagnostics after multi-edit (requires no lock held)
292        let mut output_text = output_text;
293        if let Some(diag_output) =
294            diagnostics_helper::collect_post_edit_diagnostics(ctx, &path).await
295        {
296            output_text.push_str(&diag_output);
297        }
298
299        ToolResult::ok_with_metadata(output_text, metadata)
300    }
301}
302
303// ===========================================================================
304// Tests
305// ===========================================================================
306
307#[cfg(test)]
308#[path = "multi_edit_tests.rs"]
309mod tests;