1use super::edit_diff::{
10 self, detect_line_ending, has_bom, normalize_to_lf, restore_line_endings, strip_bom, Edit,
11 EditDiffError,
12};
13use super::file_mutation_queue::global_mutation_queue;
14use super::path_security::PathGuard;
15use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use serde_json::{json, Value};
19use std::path::{Path, PathBuf};
20use tokio::fs;
21use tokio::sync::oneshot;
22
23pub struct EditTool {
25 root_dir: Option<PathBuf>,
26}
27
28impl EditTool {
29 pub fn new() -> Self {
31 Self { root_dir: None }
32 }
33
34 pub fn with_cwd(cwd: PathBuf) -> Self {
36 Self {
37 root_dir: Some(cwd),
38 }
39 }
40
41 fn prepare_arguments(params: &Value) -> EditInput {
44 let path = params
45 .get("path")
46 .or(params.get("file_path"))
47 .and_then(|v| v.as_str())
48 .unwrap_or("")
49 .to_string();
50
51 let mut edits: Vec<EditEntry> = Vec::new();
53
54 if let Some(edits_val) = params.get("edits") {
55 let edits_val = if let Some(s) = edits_val.as_str() {
57 serde_json::from_str::<Vec<EditEntry>>(s).unwrap_or_default()
58 } else if let Some(arr) = edits_val.as_array() {
59 arr.iter()
60 .filter_map(|v| serde_json::from_value::<EditEntry>(v.clone()).ok())
61 .collect()
62 } else {
63 Vec::new()
64 };
65 edits = edits_val;
66 }
67
68 if edits.is_empty() {
70 if let (Some(old), Some(new)) = (
71 params
72 .get("old_text")
73 .or(params.get("oldText"))
74 .and_then(|v| v.as_str()),
75 params
76 .get("new_text")
77 .or(params.get("newText"))
78 .and_then(|v| v.as_str()),
79 ) {
80 edits.push(EditEntry {
81 old_text: old.to_string(),
82 new_text: new.to_string(),
83 });
84 }
85 }
86
87 let dry_run = params
88 .get("dry_run")
89 .and_then(|v| v.as_bool())
90 .unwrap_or(false);
91
92 EditInput {
93 path,
94 edits,
95 dry_run,
96 }
97 }
98
99 async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
101 let guard = PathGuard::new(root_dir);
103 let validated_path = guard
104 .validate_traversal(Path::new(&input.path))
105 .map_err(|e| e.to_string())?;
106 let path = validated_path.as_path();
107
108 if input.edits.is_empty() {
110 return Err(
111 "No edits provided. Either use old_text/new_text or edits array.".to_string(),
112 );
113 }
114
115 let raw_content = fs::read_to_string(path)
117 .await
118 .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
119
120 let had_bom = has_bom(&raw_content);
122 let line_ending = detect_line_ending(&raw_content);
123 let content = normalize_to_lf(strip_bom(&raw_content));
124
125 let edits: Vec<Edit> = input
127 .edits
128 .iter()
129 .map(|e| Edit {
130 old_text: normalize_to_lf(&e.old_text),
131 new_text: normalize_to_lf(&e.new_text),
132 })
133 .collect();
134
135 let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
137 .map_err(|e: EditDiffError| e.message)?;
138
139 if input.dry_run {
140 return Ok(EditOutput {
141 diff: diff_result.diff,
142 first_changed_line: diff_result.first_changed_line,
143 applied: false,
144 message: "Dry run — no changes applied".to_string(),
145 });
146 }
147
148 let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
150 .map_err(|e: EditDiffError| e.message)?;
151
152 let mut final_content = restore_line_endings(&modified, line_ending);
154 if had_bom {
155 final_content = format!("\u{feff}{}", final_content);
156 }
157
158 let final_content_clone = final_content.clone();
160 global_mutation_queue()
161 .with_queue(path, || async {
162 fs::write(&validated_path, &final_content_clone)
163 .await
164 .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
165 })
166 .await
167 .map_err(|e: String| e)?;
168
169 Ok(EditOutput {
170 diff: diff_result.diff,
171 first_changed_line: diff_result.first_changed_line,
172 applied: true,
173 message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
174 })
175 }
176}
177
178impl Default for EditTool {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184struct EditInput {
186 path: String,
187 edits: Vec<EditEntry>,
188 dry_run: bool,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193struct EditEntry {
194 #[serde(rename = "oldText", alias = "old_text")]
195 old_text: String,
196 #[serde(rename = "newText", alias = "new_text")]
197 new_text: String,
198}
199
200#[derive(Debug)]
202
203struct EditOutput {
204 diff: String,
205 first_changed_line: Option<usize>,
206 #[allow(dead_code)]
207 applied: bool,
208 message: String,
209}
210
211#[async_trait]
212impl AgentTool for EditTool {
213 fn name(&self) -> &str {
214 "edit"
215 }
216
217 fn label(&self) -> &str {
218 "Edit File"
219 }
220
221 fn essential(&self) -> bool {
222 true
223 }
224 fn description(&self) -> &str {
225 "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
226 Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
227 If two changes touch the same block or nearby lines, merge them into one edit instead. \
228 Use dry_run=true to preview without making changes."
229 }
230
231 fn parameters_schema(&self) -> Value {
232 json!({
233 "type": "object",
234 "properties": {
235 "path": {
236 "type": "string",
237 "description": "Path to the file to edit (relative or absolute)"
238 },
239 "edits": {
240 "type": "array",
241 "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
242 "items": {
243 "type": "object",
244 "properties": {
245 "oldText": {
246 "type": "string",
247 "description": "Exact text for one targeted replacement. Must be unique in the original file."
248 },
249 "newText": {
250 "type": "string",
251 "description": "Replacement text for this targeted edit."
252 }
253 },
254 "required": ["oldText", "newText"]
255 }
256 },
257 "old_text": {
258 "type": "string",
259 "description": "Legacy: exact text to replace (use edits[] instead for new code)"
260 },
261 "new_text": {
262 "type": "string",
263 "description": "Legacy: replacement text (use edits[] instead for new code)"
264 },
265 "dry_run": {
266 "type": "boolean",
267 "description": "If true, preview the change without applying it",
268 "default": false
269 }
270 },
271 "required": ["path"]
272 })
273 }
274
275 async fn execute(
276 &self,
277 _tool_call_id: &str,
278 params: Value,
279 _signal: Option<oneshot::Receiver<()>>,
280 ctx: &ToolContext,
281 ) -> Result<AgentToolResult, ToolError> {
282 let input = Self::prepare_arguments(¶ms);
283
284 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
286
287 match Self::apply_edits(root, &input).await {
288 Ok(output) => {
289 let mut result =
290 AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
291
292 if let Some(line) = output.first_changed_line {
294 result = result.with_metadata(json!({
295 "firstChangedLine": line,
296 }));
297 }
298
299 Ok(result)
300 }
301 Err(e) => Ok(AgentToolResult::error(e)),
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_prepare_arguments_legacy() {
312 let params = json!({
313 "path": "/tmp/test.txt",
314 "old_text": "hello",
315 "new_text": "world"
316 });
317 let input = EditTool::prepare_arguments(¶ms);
318 assert_eq!(input.path, "/tmp/test.txt");
319 assert_eq!(input.edits.len(), 1);
320 assert_eq!(input.edits[0].old_text, "hello");
321 assert_eq!(input.edits[0].new_text, "world");
322 assert!(!input.dry_run);
323 }
324
325 #[test]
326 fn test_prepare_arguments_multi_edit() {
327 let params = json!({
328 "path": "/tmp/test.txt",
329 "edits": [
330 {"oldText": "foo", "newText": "bar"},
331 {"oldText": "baz", "newText": "qux"}
332 ]
333 });
334 let input = EditTool::prepare_arguments(¶ms);
335 assert_eq!(input.edits.len(), 2);
336 }
337
338 #[test]
339 fn test_prepare_arguments_edits_as_string() {
340 let params = json!({
341 "path": "/tmp/test.txt",
342 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
343 });
344 let input = EditTool::prepare_arguments(¶ms);
345 assert_eq!(input.edits.len(), 1);
346 assert_eq!(input.edits[0].old_text, "a");
347 }
348
349 #[test]
350 fn test_prepare_arguments_dry_run() {
351 let params = json!({
352 "path": "/tmp/test.txt",
353 "old_text": "hello",
354 "new_text": "world",
355 "dry_run": true
356 });
357 let input = EditTool::prepare_arguments(¶ms);
358 assert!(input.dry_run);
359 }
360
361 #[tokio::test]
362 async fn test_apply_edits_file_not_found() {
363 let input = EditInput {
364 path: "/tmp/nonexistent_file_12345.txt".to_string(),
365 edits: vec![EditEntry {
366 old_text: "foo".to_string(),
367 new_text: "bar".to_string(),
368 }],
369 dry_run: false,
370 };
371 let result = EditTool::apply_edits(Path::new("."), &input).await;
372 assert!(result.is_err());
373 assert!(result.unwrap_err().contains("Cannot read file"));
374 }
375
376 #[tokio::test]
377 async fn test_apply_edits_dry_run() {
378 let dir = tempfile::tempdir().unwrap();
379 let file_path = dir.path().join("test.txt");
380 fs::write(&file_path, "hello world\n").await.unwrap();
381
382 let input = EditInput {
383 path: file_path.to_str().unwrap().to_string(),
384 edits: vec![EditEntry {
385 old_text: "hello".to_string(),
386 new_text: "goodbye".to_string(),
387 }],
388 dry_run: true,
389 };
390 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
391 assert!(!output.applied);
392 assert!(output.diff.contains("-hello"));
393 assert!(output.diff.contains("+goodbye"));
394
395 let content = fs::read_to_string(&file_path).await.unwrap();
397 assert_eq!(content, "hello world\n");
398 }
399
400 #[tokio::test]
401 async fn test_apply_edits_single_edit() {
402 let dir = tempfile::tempdir().unwrap();
403 let file_path = dir.path().join("test.txt");
404 fs::write(&file_path, "hello world\nfoo bar\n")
405 .await
406 .unwrap();
407
408 let input = EditInput {
409 path: file_path.to_str().unwrap().to_string(),
410 edits: vec![EditEntry {
411 old_text: "hello".to_string(),
412 new_text: "goodbye".to_string(),
413 }],
414 dry_run: false,
415 };
416 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
417 assert!(output.applied);
418 assert!(output.message.contains("1 edit(s)"));
419
420 let content = fs::read_to_string(&file_path).await.unwrap();
421 assert_eq!(content, "goodbye world\nfoo bar\n");
422 }
423
424 #[tokio::test]
425 async fn test_apply_edits_multiple_edits() {
426 let dir = tempfile::tempdir().unwrap();
427 let file_path = dir.path().join("test.txt");
428 fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
429
430 let input = EditInput {
431 path: file_path.to_str().unwrap().to_string(),
432 edits: vec![
433 EditEntry {
434 old_text: "aaa".to_string(),
435 new_text: "AAA".to_string(),
436 },
437 EditEntry {
438 old_text: "ccc".to_string(),
439 new_text: "CCC".to_string(),
440 },
441 ],
442 dry_run: false,
443 };
444 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
445 assert!(output.applied);
446 assert!(output.message.contains("2 edit(s)"));
447
448 let content = fs::read_to_string(&file_path).await.unwrap();
449 assert_eq!(content, "AAA\nbbb\nCCC\n");
450 }
451
452 #[tokio::test]
453 async fn test_apply_edits_crlf_preserved() {
454 let dir = tempfile::tempdir().unwrap();
455 let file_path = dir.path().join("test.txt");
456 fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
457
458 let input = EditInput {
459 path: file_path.to_str().unwrap().to_string(),
460 edits: vec![EditEntry {
461 old_text: "hello".to_string(),
462 new_text: "goodbye".to_string(),
463 }],
464 dry_run: false,
465 };
466 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
467
468 let content = fs::read_to_string(&file_path).await.unwrap();
469 assert_eq!(content, "goodbye\r\nworld\r\n");
470 }
471
472 #[tokio::test]
473 async fn test_apply_edits_bom_preserved() {
474 let dir = tempfile::tempdir().unwrap();
475 let file_path = dir.path().join("test.txt");
476 fs::write(&file_path, "\u{feff}hello world\n")
477 .await
478 .unwrap();
479
480 let input = EditInput {
481 path: file_path.to_str().unwrap().to_string(),
482 edits: vec![EditEntry {
483 old_text: "hello".to_string(),
484 new_text: "goodbye".to_string(),
485 }],
486 dry_run: false,
487 };
488 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
489
490 let content = fs::read_to_string(&file_path).await.unwrap();
491 assert!(content.starts_with('\u{feff}'));
492 assert!(content.contains("goodbye"));
493 }
494}