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 let expected_hash = params
93 .get("expected_hash")
94 .and_then(|v| v.as_str())
95 .map(|s| s.to_string());
96
97 EditInput {
98 path,
99 edits,
100 dry_run,
101 expected_hash,
102 }
103 }
104
105 async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
107 let guard = PathGuard::new(root_dir);
109 let validated_path = guard
110 .validate_traversal(Path::new(&input.path))
111 .map_err(|e| e.to_string())?;
112 let path = validated_path.as_path();
113
114 if input.edits.is_empty() {
116 return Err(
117 "No edits provided. Either use old_text/new_text or edits array.".to_string(),
118 );
119 }
120
121 if let Some(ref expected) = input.expected_hash {
126 let current_content = std::fs::read_to_string(path)
127 .map_err(|e| format!("Failed to read file for hash check: {}", e))?;
128 use std::hash::{Hash, Hasher};
129 let mut hasher = std::collections::hash_map::DefaultHasher::new();
130 current_content.hash(&mut hasher);
131 let current_hash = format!("{:016x}", hasher.finish());
132 if current_hash != *expected {
133 return Ok(EditOutput {
134 diff: String::new(),
135 first_changed_line: None,
136 applied: false,
137 message: "File has been modified since last read. Re-read the file and retry."
138 .to_string(),
139 });
140 }
141 }
142
143 let raw_content = fs::read_to_string(path)
145 .await
146 .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
147
148 let had_bom = has_bom(&raw_content);
150 let line_ending = detect_line_ending(&raw_content);
151 let content = normalize_to_lf(strip_bom(&raw_content));
152
153 let edits: Vec<Edit> = input
155 .edits
156 .iter()
157 .map(|e| Edit {
158 old_text: normalize_to_lf(&e.old_text),
159 new_text: normalize_to_lf(&e.new_text),
160 })
161 .collect();
162
163 let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
165 .map_err(|e: EditDiffError| e.message)?;
166
167 if input.dry_run {
168 return Ok(EditOutput {
169 diff: diff_result.diff,
170 first_changed_line: diff_result.first_changed_line,
171 applied: false,
172 message: "Dry run — no changes applied".to_string(),
173 });
174 }
175
176 let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
178 .map_err(|e: EditDiffError| e.message)?;
179
180 let mut final_content = restore_line_endings(&modified, line_ending);
182 if had_bom {
183 final_content = format!("\u{feff}{}", final_content);
184 }
185
186 let final_content_clone = final_content.clone();
188 global_mutation_queue()
189 .with_queue(path, || async {
190 fs::write(&validated_path, &final_content_clone)
191 .await
192 .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
193 })
194 .await
195 .map_err(|e: String| e)?;
196
197 Ok(EditOutput {
198 diff: diff_result.diff,
199 first_changed_line: diff_result.first_changed_line,
200 applied: true,
201 message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
202 })
203 }
204}
205
206impl Default for EditTool {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212struct EditInput {
214 path: String,
215 edits: Vec<EditEntry>,
216 dry_run: bool,
217 expected_hash: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224struct EditEntry {
225 #[serde(rename = "oldText", alias = "old_text")]
226 old_text: String,
227 #[serde(rename = "newText", alias = "new_text")]
228 new_text: String,
229}
230
231#[derive(Debug)]
233
234struct EditOutput {
235 diff: String,
236 first_changed_line: Option<usize>,
237 #[allow(dead_code)]
238 applied: bool,
239 message: String,
240}
241
242#[async_trait]
243impl AgentTool for EditTool {
244 fn name(&self) -> &str {
245 "edit"
246 }
247
248 fn label(&self) -> &str {
249 "Edit File"
250 }
251
252 fn essential(&self) -> bool {
253 true
254 }
255 fn description(&self) -> &str {
256 "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
257 Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
258 If two changes touch the same block or nearby lines, merge them into one edit instead. \
259 Use dry_run=true to preview without making changes."
260 }
261
262 fn parameters_schema(&self) -> Value {
263 json!({
264 "type": "object",
265 "properties": {
266 "path": {
267 "type": "string",
268 "description": "Path to the file to edit (relative or absolute)"
269 },
270 "edits": {
271 "type": "array",
272 "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
273 "items": {
274 "type": "object",
275 "properties": {
276 "oldText": {
277 "type": "string",
278 "description": "Exact text for one targeted replacement. Must be unique in the original file."
279 },
280 "newText": {
281 "type": "string",
282 "description": "Replacement text for this targeted edit."
283 }
284 },
285 "required": ["oldText", "newText"]
286 }
287 },
288 "old_text": {
289 "type": "string",
290 "description": "Legacy: exact text to replace (use edits[] instead for new code)"
291 },
292 "new_text": {
293 "type": "string",
294 "description": "Legacy: replacement text (use edits[] instead for new code)"
295 },
296 "dry_run": {
297 "type": "boolean",
298 "description": "If true, preview the change without applying it",
299 "default": false
300 },
301 "expected_hash": {
302 "type": "string",
303 "description": "Hash of the file content at last read. If provided, the edit will be rejected if the file was modified since the hash was computed."
304 }
305 },
306 "required": ["path"]
307 })
308 }
309
310 async fn execute(
311 &self,
312 _tool_call_id: &str,
313 params: Value,
314 _signal: Option<oneshot::Receiver<()>>,
315 ctx: &ToolContext,
316 ) -> Result<AgentToolResult, ToolError> {
317 let input = Self::prepare_arguments(¶ms);
318
319 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
321
322 match Self::apply_edits(root, &input).await {
323 Ok(output) => {
324 let mut result =
325 AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
326
327 if let Some(line) = output.first_changed_line {
329 result = result.with_metadata(json!({
330 "firstChangedLine": line,
331 }));
332 }
333
334 Ok(result)
335 }
336 Err(e) => Ok(AgentToolResult::error(e)),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_prepare_arguments_legacy() {
347 let params = json!({
348 "path": "/tmp/test.txt",
349 "old_text": "hello",
350 "new_text": "world"
351 });
352 let input = EditTool::prepare_arguments(¶ms);
353 assert_eq!(input.path, "/tmp/test.txt");
354 assert_eq!(input.edits.len(), 1);
355 assert_eq!(input.edits[0].old_text, "hello");
356 assert_eq!(input.edits[0].new_text, "world");
357 assert!(!input.dry_run);
358 }
359
360 #[test]
361 fn test_prepare_arguments_multi_edit() {
362 let params = json!({
363 "path": "/tmp/test.txt",
364 "edits": [
365 {"oldText": "foo", "newText": "bar"},
366 {"oldText": "baz", "newText": "qux"}
367 ]
368 });
369 let input = EditTool::prepare_arguments(¶ms);
370 assert_eq!(input.edits.len(), 2);
371 }
372
373 #[test]
374 fn test_prepare_arguments_edits_as_string() {
375 let params = json!({
376 "path": "/tmp/test.txt",
377 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
378 });
379 let input = EditTool::prepare_arguments(¶ms);
380 assert_eq!(input.edits.len(), 1);
381 assert_eq!(input.edits[0].old_text, "a");
382 }
383
384 #[test]
385 fn test_prepare_arguments_dry_run() {
386 let params = json!({
387 "path": "/tmp/test.txt",
388 "old_text": "hello",
389 "new_text": "world",
390 "dry_run": true
391 });
392 let input = EditTool::prepare_arguments(¶ms);
393 assert!(input.dry_run);
394 }
395
396 #[tokio::test]
397 async fn test_apply_edits_file_not_found() {
398 let input = EditInput {
399 path: "/tmp/nonexistent_file_12345.txt".to_string(),
400 edits: vec![EditEntry {
401 old_text: "foo".to_string(),
402 new_text: "bar".to_string(),
403 }],
404 dry_run: false,
405 expected_hash: None,
406 };
407 let result = EditTool::apply_edits(Path::new("."), &input).await;
408 assert!(result.is_err());
409 assert!(result.unwrap_err().contains("Cannot read file"));
410 }
411
412 #[tokio::test]
413 async fn test_apply_edits_dry_run() {
414 let dir = tempfile::tempdir().unwrap();
415 let file_path = dir.path().join("test.txt");
416 fs::write(&file_path, "hello world\n").await.unwrap();
417
418 let input = EditInput {
419 path: file_path.to_str().unwrap().to_string(),
420 edits: vec![EditEntry {
421 old_text: "hello".to_string(),
422 new_text: "goodbye".to_string(),
423 }],
424 dry_run: true,
425 expected_hash: None,
426 };
427 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
428 assert!(!output.applied);
429 assert!(output.diff.contains("-hello"));
430 assert!(output.diff.contains("+goodbye"));
431
432 let content = fs::read_to_string(&file_path).await.unwrap();
434 assert_eq!(content, "hello world\n");
435 }
436
437 #[tokio::test]
438 async fn test_apply_edits_single_edit() {
439 let dir = tempfile::tempdir().unwrap();
440 let file_path = dir.path().join("test.txt");
441 fs::write(&file_path, "hello world\nfoo bar\n")
442 .await
443 .unwrap();
444
445 let input = EditInput {
446 path: file_path.to_str().unwrap().to_string(),
447 edits: vec![EditEntry {
448 old_text: "hello".to_string(),
449 new_text: "goodbye".to_string(),
450 }],
451 dry_run: false,
452 expected_hash: None,
453 };
454 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
455 assert!(output.applied);
456 assert!(output.message.contains("1 edit(s)"));
457
458 let content = fs::read_to_string(&file_path).await.unwrap();
459 assert_eq!(content, "goodbye world\nfoo bar\n");
460 }
461
462 #[tokio::test]
463 async fn test_apply_edits_multiple_edits() {
464 let dir = tempfile::tempdir().unwrap();
465 let file_path = dir.path().join("test.txt");
466 fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
467
468 let input = EditInput {
469 path: file_path.to_str().unwrap().to_string(),
470 edits: vec![
471 EditEntry {
472 old_text: "aaa".to_string(),
473 new_text: "AAA".to_string(),
474 },
475 EditEntry {
476 old_text: "ccc".to_string(),
477 new_text: "CCC".to_string(),
478 },
479 ],
480 dry_run: false,
481 expected_hash: None,
482 };
483 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
484 assert!(output.applied);
485 assert!(output.message.contains("2 edit(s)"));
486
487 let content = fs::read_to_string(&file_path).await.unwrap();
488 assert_eq!(content, "AAA\nbbb\nCCC\n");
489 }
490
491 #[tokio::test]
492 async fn test_apply_edits_crlf_preserved() {
493 let dir = tempfile::tempdir().unwrap();
494 let file_path = dir.path().join("test.txt");
495 fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
496
497 let input = EditInput {
498 path: file_path.to_str().unwrap().to_string(),
499 edits: vec![EditEntry {
500 old_text: "hello".to_string(),
501 new_text: "goodbye".to_string(),
502 }],
503 dry_run: false,
504 expected_hash: None,
505 };
506 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
507
508 let content = fs::read_to_string(&file_path).await.unwrap();
509 assert_eq!(content, "goodbye\r\nworld\r\n");
510 }
511
512 #[tokio::test]
513 async fn test_apply_edits_bom_preserved() {
514 let dir = tempfile::tempdir().unwrap();
515 let file_path = dir.path().join("test.txt");
516 fs::write(&file_path, "\u{feff}hello world\n")
517 .await
518 .unwrap();
519
520 let input = EditInput {
521 path: file_path.to_str().unwrap().to_string(),
522 edits: vec![EditEntry {
523 old_text: "hello".to_string(),
524 new_text: "goodbye".to_string(),
525 }],
526 dry_run: false,
527 expected_hash: None,
528 };
529 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
530
531 let content = fs::read_to_string(&file_path).await.unwrap();
532 assert!(content.starts_with('\u{feff}'));
533 assert!(content.contains("goodbye"));
534 }
535
536 #[test]
537 fn test_prepare_arguments_expected_hash() {
538 let params = json!({
539 "path": "/tmp/test.txt",
540 "old_text": "hello",
541 "new_text": "world",
542 "expected_hash": "abcd1234"
543 });
544 let input = EditTool::prepare_arguments(¶ms);
545 assert_eq!(input.expected_hash.as_deref(), Some("abcd1234"));
546 }
547
548 #[test]
549 fn test_prepare_arguments_no_expected_hash() {
550 let params = json!({
551 "path": "/tmp/test.txt",
552 "old_text": "hello",
553 "new_text": "world"
554 });
555 let input = EditTool::prepare_arguments(¶ms);
556 assert!(input.expected_hash.is_none());
557 }
558
559 fn compute_hash(content: &str) -> String {
560 use std::hash::{Hash, Hasher};
561 let mut hasher = std::collections::hash_map::DefaultHasher::new();
562 content.hash(&mut hasher);
563 format!("{:016x}", hasher.finish())
564 }
565
566 #[tokio::test]
567 async fn test_conflict_detection_hash_mismatch() {
568 let dir = tempfile::tempdir().unwrap();
569 let file_path = dir.path().join("test.txt");
570 fs::write(&file_path, "hello world\n").await.unwrap();
571
572 let hash = compute_hash("hello world\n");
573
574 fs::write(&file_path, "hello modified world\n")
576 .await
577 .unwrap();
578
579 let input = EditInput {
580 path: file_path.to_str().unwrap().to_string(),
581 edits: vec![EditEntry {
582 old_text: "hello".to_string(),
583 new_text: "goodbye".to_string(),
584 }],
585 dry_run: false,
586 expected_hash: Some(hash),
587 };
588 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
589 assert!(!output.applied);
590 assert!(output.message.contains("modified since last read"));
591
592 let content = fs::read_to_string(&file_path).await.unwrap();
594 assert_eq!(content, "hello modified world\n");
595 }
596
597 #[tokio::test]
598 async fn test_conflict_detection_hash_match() {
599 let dir = tempfile::tempdir().unwrap();
600 let file_path = dir.path().join("test.txt");
601 fs::write(&file_path, "hello world\n").await.unwrap();
602
603 let hash = compute_hash("hello world\n");
604
605 let input = EditInput {
606 path: file_path.to_str().unwrap().to_string(),
607 edits: vec![EditEntry {
608 old_text: "hello".to_string(),
609 new_text: "goodbye".to_string(),
610 }],
611 dry_run: false,
612 expected_hash: Some(hash),
613 };
614 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
615 assert!(output.applied);
616
617 let content = fs::read_to_string(&file_path).await.unwrap();
618 assert_eq!(content, "goodbye world\n");
619 }
620}