opendev_tools_impl/
multi_edit.rs1use 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
18static 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#[derive(Debug)]
38pub struct MultiEditTool;
39
40struct 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 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 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 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 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 let (output_text, metadata) = {
168 let lock = get_file_lock(&path);
169 let _guard = lock.lock().unwrap();
170
171 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 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 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 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 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 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 let diff_text = edit_replacers::unified_diff(file_path, &original_content, &content, 3);
243
244 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 let formatted =
258 formatter::format_file(path.to_str().unwrap_or(file_path), &ctx.working_dir);
259
260 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 }; 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#[cfg(test)]
308#[path = "multi_edit_tests.rs"]
309mod tests;