1use super::edit_diff::{
10 self, Edit, EditDiffError, detect_line_ending, has_bom, normalize_to_lf, restore_line_endings,
11 strip_bom,
12};
13use super::file_mutation_queue::global_mutation_queue;
14use super::hashline_fs::TokioHashlineFs;
15use super::path_security::PathGuard;
16use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
17use async_trait::async_trait;
18use oxi_hashline::parser::split_patch_input;
19use oxi_hashline::patcher::Patcher;
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use tokio::fs;
25use tokio::sync::oneshot;
26
27pub struct EditTool {
29 root_dir: Option<PathBuf>,
30}
31
32impl EditTool {
33 pub fn new() -> Self {
35 Self { root_dir: None }
36 }
37
38 pub fn with_cwd(cwd: PathBuf) -> Self {
40 Self {
41 root_dir: Some(cwd),
42 }
43 }
44
45 fn prepare_arguments(params: &Value) -> EditInput {
48 let path = params
49 .get("path")
50 .or(params.get("file_path"))
51 .and_then(|v| v.as_str())
52 .unwrap_or("")
53 .to_string();
54
55 let mut edits: Vec<EditEntry> = Vec::new();
57
58 if let Some(edits_val) = params.get("edits") {
59 let edits_val = if let Some(s) = edits_val.as_str() {
61 serde_json::from_str::<Vec<EditEntry>>(s).unwrap_or_default()
62 } else if let Some(arr) = edits_val.as_array() {
63 arr.iter()
64 .filter_map(|v| serde_json::from_value::<EditEntry>(v.clone()).ok())
65 .collect()
66 } else {
67 Vec::new()
68 };
69 edits = edits_val;
70 }
71
72 if edits.is_empty()
74 && let (Some(old), Some(new)) = (
75 params
76 .get("old_text")
77 .or(params.get("oldText"))
78 .and_then(|v| v.as_str()),
79 params
80 .get("new_text")
81 .or(params.get("newText"))
82 .and_then(|v| v.as_str()),
83 )
84 {
85 edits.push(EditEntry {
86 old_text: old.to_string(),
87 new_text: new.to_string(),
88 });
89 }
90
91 let dry_run = params
92 .get("dry_run")
93 .and_then(|v| v.as_bool())
94 .unwrap_or(false);
95
96 let expected_hash = params
97 .get("expected_hash")
98 .and_then(|v| v.as_str())
99 .map(|s| s.to_string());
100
101 let patch = params
103 .get("patch")
104 .and_then(|v| v.as_str())
105 .map(|s| s.to_string());
106
107 EditInput {
108 path,
109 edits,
110 dry_run,
111 expected_hash,
112 patch,
113 }
114 }
115
116 async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
118 let guard = PathGuard::new(root_dir);
120 let validated_path = guard
121 .validate_traversal(Path::new(&input.path))
122 .map_err(|e| e.to_string())?;
123 let path = validated_path.as_path();
124
125 if input.edits.is_empty() {
127 return Err(
128 "No edits provided. Either use old_text/new_text or edits array.".to_string(),
129 );
130 }
131
132 if let Some(ref expected) = input.expected_hash {
137 let current_content = std::fs::read_to_string(path)
138 .map_err(|e| format!("Failed to read file for hash check: {}", e))?;
139 use std::hash::{Hash, Hasher};
140 let mut hasher = std::collections::hash_map::DefaultHasher::new();
141 current_content.hash(&mut hasher);
142 let current_hash = format!("{:016x}", hasher.finish());
143 if current_hash != *expected {
144 return Ok(EditOutput {
145 diff: String::new(),
146 first_changed_line: None,
147 applied: false,
148 message: "File has been modified since last read. Re-read the file and retry."
149 .to_string(),
150 });
151 }
152 }
153
154 let raw_content = fs::read_to_string(path)
156 .await
157 .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
158
159 let had_bom = has_bom(&raw_content);
161 let line_ending = detect_line_ending(&raw_content);
162 let content = normalize_to_lf(strip_bom(&raw_content));
163
164 let edits: Vec<Edit> = input
166 .edits
167 .iter()
168 .map(|e| Edit {
169 old_text: normalize_to_lf(&e.old_text),
170 new_text: normalize_to_lf(&e.new_text),
171 })
172 .collect();
173
174 let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
176 .map_err(|e: EditDiffError| e.message)?;
177
178 if input.dry_run {
179 return Ok(EditOutput {
180 diff: diff_result.diff,
181 first_changed_line: diff_result.first_changed_line,
182 applied: false,
183 message: "Dry run — no changes applied".to_string(),
184 });
185 }
186
187 let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
189 .map_err(|e: EditDiffError| e.message)?;
190
191 let mut final_content = restore_line_endings(&modified, line_ending);
193 if had_bom {
194 final_content = format!("\u{feff}{}", final_content);
195 }
196
197 let final_content_clone = final_content.clone();
199 global_mutation_queue()
200 .with_queue(path, || async {
201 fs::write(&validated_path, &final_content_clone)
202 .await
203 .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
204 })
205 .await
206 .map_err(|e: String| e)?;
207
208 Ok(EditOutput {
209 diff: diff_result.diff,
210 first_changed_line: diff_result.first_changed_line,
211 applied: true,
212 message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
213 })
214 }
215
216 async fn apply_hashline(
218 root_dir: &Path,
219 patch_text: &str,
220 dry_run: bool,
221 ctx: &ToolContext,
222 ) -> Result<EditOutput, ToolError> {
223 let snapshots = ctx.snapshot_store.clone().ok_or_else(|| {
224 "Hashline edit mode requires a snapshot store (not configured in this session)."
225 .to_string()
226 })?;
227
228 let patch = split_patch_input(patch_text, None).map_err(|e| e.to_string())?;
229
230 if dry_run {
231 let fs = Arc::new(TokioHashlineFs::new(root_dir.to_path_buf()));
233 let patcher = Patcher::new(fs, snapshots);
234 patcher.preflight(&patch).await.map_err(|e| e.to_string())?;
235 return Ok(EditOutput {
236 diff: String::new(),
237 first_changed_line: None,
238 applied: false,
239 message: "Dry run — no changes applied".to_string(),
240 });
241 }
242
243 let fs = Arc::new(TokioHashlineFs::new(root_dir.to_path_buf()));
244 let patcher = Patcher::new(fs, snapshots);
245 let result = patcher.apply(&patch).await.map_err(|e| e.to_string())?;
246
247 let mut diff_parts = Vec::new();
248 let mut messages = Vec::new();
249 let mut first_changed: Option<usize> = None;
250 for section in &result.sections {
251 if !section.diff.is_empty() {
252 diff_parts.push(format!(
253 "[{}#{}]\n{}",
254 section.path, section.new_hash, section.diff
255 ));
256 }
257 for w in §ion.warnings {
258 diff_parts.push(format!("⚠ {w}"));
259 }
260 if first_changed.is_none()
261 && let Some(line) = section.first_changed_line
262 {
263 first_changed = Some(line as usize);
264 }
265 messages.push(format!("{}#{}", section.path, section.new_hash));
266 }
267
268 let section_count = result.sections.len();
269 Ok(EditOutput {
270 diff: diff_parts.join("\n"),
271 first_changed_line: first_changed,
272 applied: true,
273 message: format!(
274 "Applied hashline patch to {} section(s). New tags: {}",
275 section_count,
276 messages.join(", ")
277 ),
278 })
279 }
280}
281
282impl Default for EditTool {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288#[derive(Default)]
290struct EditInput {
291 path: String,
292 edits: Vec<EditEntry>,
293 dry_run: bool,
294 expected_hash: Option<String>,
297 patch: Option<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304struct EditEntry {
305 #[serde(rename = "oldText", alias = "old_text")]
306 old_text: String,
307 #[serde(rename = "newText", alias = "new_text")]
308 new_text: String,
309}
310
311#[derive(Debug)]
313
314struct EditOutput {
315 diff: String,
316 first_changed_line: Option<usize>,
317 #[allow(dead_code)]
318 applied: bool,
319 message: String,
320}
321
322#[async_trait]
323impl AgentTool for EditTool {
324 fn name(&self) -> &str {
325 "edit"
326 }
327
328 fn label(&self) -> &str {
329 "Edit File"
330 }
331
332 fn essential(&self) -> bool {
333 true
334 }
335 fn description(&self) -> &str {
336 "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
337 Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
338 If two changes touch the same block or nearby lines, merge them into one edit instead. \
339 Use dry_run=true to preview without making changes."
340 }
341
342 fn parameters_schema(&self) -> Value {
343 json!({
344 "type": "object",
345 "properties": {
346 "path": {
347 "type": "string",
348 "description": "Path to the file to edit (relative or absolute)"
349 },
350 "edits": {
351 "type": "array",
352 "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
353 "items": {
354 "type": "object",
355 "properties": {
356 "oldText": {
357 "type": "string",
358 "description": "Exact text for one targeted replacement. Must be unique in the original file."
359 },
360 "newText": {
361 "type": "string",
362 "description": "Replacement text for this targeted edit."
363 }
364 },
365 "required": ["oldText", "newText"]
366 }
367 },
368 "old_text": {
369 "type": "string",
370 "description": "Legacy: exact text to replace (use edits[] instead for new code)"
371 },
372 "new_text": {
373 "type": "string",
374 "description": "Legacy: replacement text (use edits[] instead for new code)"
375 },
376 "dry_run": {
377 "type": "boolean",
378 "description": "If true, preview the change without applying it",
379 "default": false
380 },
381 "expected_hash": {
382 "type": "string",
383 "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."
384 },
385 "patch": {
386 "type": "string",
387 "description": "Hashline patch text (*** Begin Patch … *** End Patch). When present, hashline line-anchored editing is used instead of str_replace. Mutually exclusive with edits/old_text/new_text."
388 }
389 },
390 "required": ["path"]
391 })
392 }
393
394 async fn execute(
395 &self,
396 _tool_call_id: &str,
397 params: Value,
398 _signal: Option<oneshot::Receiver<()>>,
399 ctx: &ToolContext,
400 ) -> Result<AgentToolResult, ToolError> {
401 let input = Self::prepare_arguments(¶ms);
402
403 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
405
406 let output = if let Some(ref patch_text) = input.patch {
408 Self::apply_hashline(root, patch_text, input.dry_run, ctx).await
409 } else {
410 Self::apply_edits(root, &input).await
411 };
412 match output {
413 Ok(output) => {
414 let mut result =
415 AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
416
417 if let Some(line) = output.first_changed_line {
419 result = result.with_metadata(json!({
420 "firstChangedLine": line,
421 }));
422 }
423
424 if let Some(provider) = ctx.lsp.as_ref() {
428 let notify_path = std::path::Path::new(&input.path).to_path_buf();
429 let notify_abs = if notify_path.is_absolute() {
430 notify_path
431 } else {
432 std::path::Path::new(root).join(¬ify_path)
433 };
434 let content_owned = std::fs::read_to_string(¬ify_abs).unwrap_or_default();
438 let provider_clone = provider.clone();
439 tokio::spawn(async move {
440 provider_clone
441 .notify_file_changed(¬ify_abs, &content_owned)
442 .await;
443 });
444 }
445
446 Ok(result)
447 }
448 Err(e) => Ok(AgentToolResult::error(e)),
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn test_prepare_arguments_legacy() {
459 let params = json!({
460 "path": "/tmp/test.txt",
461 "old_text": "hello",
462 "new_text": "world"
463 });
464 let input = EditTool::prepare_arguments(¶ms);
465 assert_eq!(input.path, "/tmp/test.txt");
466 assert_eq!(input.edits.len(), 1);
467 assert_eq!(input.edits[0].old_text, "hello");
468 assert_eq!(input.edits[0].new_text, "world");
469 assert!(!input.dry_run);
470 }
471
472 #[test]
473 fn test_prepare_arguments_multi_edit() {
474 let params = json!({
475 "path": "/tmp/test.txt",
476 "edits": [
477 {"oldText": "foo", "newText": "bar"},
478 {"oldText": "baz", "newText": "qux"}
479 ]
480 });
481 let input = EditTool::prepare_arguments(¶ms);
482 assert_eq!(input.edits.len(), 2);
483 }
484
485 #[test]
486 fn test_prepare_arguments_edits_as_string() {
487 let params = json!({
488 "path": "/tmp/test.txt",
489 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
490 });
491 let input = EditTool::prepare_arguments(¶ms);
492 assert_eq!(input.edits.len(), 1);
493 assert_eq!(input.edits[0].old_text, "a");
494 }
495
496 #[test]
497 fn test_prepare_arguments_dry_run() {
498 let params = json!({
499 "path": "/tmp/test.txt",
500 "old_text": "hello",
501 "new_text": "world",
502 "dry_run": true
503 });
504 let input = EditTool::prepare_arguments(¶ms);
505 assert!(input.dry_run);
506 }
507
508 #[tokio::test]
509 async fn test_apply_edits_file_not_found() {
510 let input = EditInput {
511 path: "/tmp/nonexistent_file_12345.txt".to_string(),
512 edits: vec![EditEntry {
513 old_text: "foo".to_string(),
514 new_text: "bar".to_string(),
515 }],
516 dry_run: false,
517 expected_hash: None,
518 ..Default::default()
519 };
520 let result = EditTool::apply_edits(Path::new("."), &input).await;
521 assert!(result.is_err());
522 assert!(result.unwrap_err().contains("Cannot read file"));
523 }
524
525 #[tokio::test]
526 async fn test_apply_edits_dry_run() {
527 let dir = tempfile::tempdir().unwrap();
528 let file_path = dir.path().join("test.txt");
529 fs::write(&file_path, "hello world\n").await.unwrap();
530
531 let input = EditInput {
532 path: file_path.to_str().unwrap().to_string(),
533 edits: vec![EditEntry {
534 old_text: "hello".to_string(),
535 new_text: "goodbye".to_string(),
536 }],
537 dry_run: true,
538 expected_hash: None,
539 ..Default::default()
540 };
541 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
542 assert!(!output.applied);
543 assert!(output.diff.contains("-hello"));
544 assert!(output.diff.contains("+goodbye"));
545
546 let content = fs::read_to_string(&file_path).await.unwrap();
548 assert_eq!(content, "hello world\n");
549 }
550
551 #[tokio::test]
552 async fn test_apply_edits_single_edit() {
553 let dir = tempfile::tempdir().unwrap();
554 let file_path = dir.path().join("test.txt");
555 fs::write(&file_path, "hello world\nfoo bar\n")
556 .await
557 .unwrap();
558
559 let input = EditInput {
560 path: file_path.to_str().unwrap().to_string(),
561 edits: vec![EditEntry {
562 old_text: "hello".to_string(),
563 new_text: "goodbye".to_string(),
564 }],
565 dry_run: false,
566 expected_hash: None,
567 ..Default::default()
568 };
569 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
570 assert!(output.applied);
571 assert!(output.message.contains("1 edit(s)"));
572
573 let content = fs::read_to_string(&file_path).await.unwrap();
574 assert_eq!(content, "goodbye world\nfoo bar\n");
575 }
576
577 #[tokio::test]
578 async fn test_apply_edits_multiple_edits() {
579 let dir = tempfile::tempdir().unwrap();
580 let file_path = dir.path().join("test.txt");
581 fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
582
583 let input = EditInput {
584 path: file_path.to_str().unwrap().to_string(),
585 edits: vec![
586 EditEntry {
587 old_text: "aaa".to_string(),
588 new_text: "AAA".to_string(),
589 },
590 EditEntry {
591 old_text: "ccc".to_string(),
592 new_text: "CCC".to_string(),
593 },
594 ],
595 dry_run: false,
596 expected_hash: None,
597 ..Default::default()
598 };
599 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
600 assert!(output.applied);
601 assert!(output.message.contains("2 edit(s)"));
602
603 let content = fs::read_to_string(&file_path).await.unwrap();
604 assert_eq!(content, "AAA\nbbb\nCCC\n");
605 }
606
607 #[tokio::test]
608 async fn test_apply_edits_crlf_preserved() {
609 let dir = tempfile::tempdir().unwrap();
610 let file_path = dir.path().join("test.txt");
611 fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
612
613 let input = EditInput {
614 path: file_path.to_str().unwrap().to_string(),
615 edits: vec![EditEntry {
616 old_text: "hello".to_string(),
617 new_text: "goodbye".to_string(),
618 }],
619 dry_run: false,
620 expected_hash: None,
621 ..Default::default()
622 };
623 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
624
625 let content = fs::read_to_string(&file_path).await.unwrap();
626 assert_eq!(content, "goodbye\r\nworld\r\n");
627 }
628
629 #[tokio::test]
630 async fn test_apply_edits_bom_preserved() {
631 let dir = tempfile::tempdir().unwrap();
632 let file_path = dir.path().join("test.txt");
633 fs::write(&file_path, "\u{feff}hello world\n")
634 .await
635 .unwrap();
636
637 let input = EditInput {
638 path: file_path.to_str().unwrap().to_string(),
639 edits: vec![EditEntry {
640 old_text: "hello".to_string(),
641 new_text: "goodbye".to_string(),
642 }],
643 dry_run: false,
644 expected_hash: None,
645 ..Default::default()
646 };
647 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
648
649 let content = fs::read_to_string(&file_path).await.unwrap();
650 assert!(content.starts_with('\u{feff}'));
651 assert!(content.contains("goodbye"));
652 }
653
654 #[test]
655 fn test_prepare_arguments_expected_hash() {
656 let params = json!({
657 "path": "/tmp/test.txt",
658 "old_text": "hello",
659 "new_text": "world",
660 "expected_hash": "abcd1234"
661 });
662 let input = EditTool::prepare_arguments(¶ms);
663 assert_eq!(input.expected_hash.as_deref(), Some("abcd1234"));
664 }
665
666 #[test]
667 fn test_prepare_arguments_no_expected_hash() {
668 let params = json!({
669 "path": "/tmp/test.txt",
670 "old_text": "hello",
671 "new_text": "world"
672 });
673 let input = EditTool::prepare_arguments(¶ms);
674 assert!(input.expected_hash.is_none());
675 }
676
677 fn compute_hash(content: &str) -> String {
678 use std::hash::{Hash, Hasher};
679 let mut hasher = std::collections::hash_map::DefaultHasher::new();
680 content.hash(&mut hasher);
681 format!("{:016x}", hasher.finish())
682 }
683
684 #[tokio::test]
685 async fn test_conflict_detection_hash_mismatch() {
686 let dir = tempfile::tempdir().unwrap();
687 let file_path = dir.path().join("test.txt");
688 fs::write(&file_path, "hello world\n").await.unwrap();
689
690 let hash = compute_hash("hello world\n");
691
692 fs::write(&file_path, "hello modified world\n")
694 .await
695 .unwrap();
696
697 let input = EditInput {
698 path: file_path.to_str().unwrap().to_string(),
699 edits: vec![EditEntry {
700 old_text: "hello".to_string(),
701 new_text: "goodbye".to_string(),
702 }],
703 dry_run: false,
704 expected_hash: Some(hash),
705 ..Default::default()
706 };
707 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
708 assert!(!output.applied);
709 assert!(output.message.contains("modified since last read"));
710
711 let content = fs::read_to_string(&file_path).await.unwrap();
713 assert_eq!(content, "hello modified world\n");
714 }
715
716 #[tokio::test]
717 async fn test_conflict_detection_hash_match() {
718 let dir = tempfile::tempdir().unwrap();
719 let file_path = dir.path().join("test.txt");
720 fs::write(&file_path, "hello world\n").await.unwrap();
721
722 let hash = compute_hash("hello world\n");
723
724 let input = EditInput {
725 path: file_path.to_str().unwrap().to_string(),
726 edits: vec![EditEntry {
727 old_text: "hello".to_string(),
728 new_text: "goodbye".to_string(),
729 }],
730 dry_run: false,
731 expected_hash: Some(hash),
732 ..Default::default()
733 };
734 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
735 assert!(output.applied);
736
737 let content = fs::read_to_string(&file_path).await.unwrap();
738 assert_eq!(content, "goodbye world\n");
739 }
740}