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
413 match output {
414 Ok(output) => {
415 let mut result =
416 AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
417
418 if let Some(line) = output.first_changed_line {
420 result = result.with_metadata(json!({
421 "firstChangedLine": line,
422 }));
423 }
424
425 Ok(result)
426 }
427 Err(e) => Ok(AgentToolResult::error(e)),
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_prepare_arguments_legacy() {
438 let params = json!({
439 "path": "/tmp/test.txt",
440 "old_text": "hello",
441 "new_text": "world"
442 });
443 let input = EditTool::prepare_arguments(¶ms);
444 assert_eq!(input.path, "/tmp/test.txt");
445 assert_eq!(input.edits.len(), 1);
446 assert_eq!(input.edits[0].old_text, "hello");
447 assert_eq!(input.edits[0].new_text, "world");
448 assert!(!input.dry_run);
449 }
450
451 #[test]
452 fn test_prepare_arguments_multi_edit() {
453 let params = json!({
454 "path": "/tmp/test.txt",
455 "edits": [
456 {"oldText": "foo", "newText": "bar"},
457 {"oldText": "baz", "newText": "qux"}
458 ]
459 });
460 let input = EditTool::prepare_arguments(¶ms);
461 assert_eq!(input.edits.len(), 2);
462 }
463
464 #[test]
465 fn test_prepare_arguments_edits_as_string() {
466 let params = json!({
467 "path": "/tmp/test.txt",
468 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
469 });
470 let input = EditTool::prepare_arguments(¶ms);
471 assert_eq!(input.edits.len(), 1);
472 assert_eq!(input.edits[0].old_text, "a");
473 }
474
475 #[test]
476 fn test_prepare_arguments_dry_run() {
477 let params = json!({
478 "path": "/tmp/test.txt",
479 "old_text": "hello",
480 "new_text": "world",
481 "dry_run": true
482 });
483 let input = EditTool::prepare_arguments(¶ms);
484 assert!(input.dry_run);
485 }
486
487 #[tokio::test]
488 async fn test_apply_edits_file_not_found() {
489 let input = EditInput {
490 path: "/tmp/nonexistent_file_12345.txt".to_string(),
491 edits: vec![EditEntry {
492 old_text: "foo".to_string(),
493 new_text: "bar".to_string(),
494 }],
495 dry_run: false,
496 expected_hash: None,
497 ..Default::default()
498 };
499 let result = EditTool::apply_edits(Path::new("."), &input).await;
500 assert!(result.is_err());
501 assert!(result.unwrap_err().contains("Cannot read file"));
502 }
503
504 #[tokio::test]
505 async fn test_apply_edits_dry_run() {
506 let dir = tempfile::tempdir().unwrap();
507 let file_path = dir.path().join("test.txt");
508 fs::write(&file_path, "hello world\n").await.unwrap();
509
510 let input = EditInput {
511 path: file_path.to_str().unwrap().to_string(),
512 edits: vec![EditEntry {
513 old_text: "hello".to_string(),
514 new_text: "goodbye".to_string(),
515 }],
516 dry_run: true,
517 expected_hash: None,
518 ..Default::default()
519 };
520 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
521 assert!(!output.applied);
522 assert!(output.diff.contains("-hello"));
523 assert!(output.diff.contains("+goodbye"));
524
525 let content = fs::read_to_string(&file_path).await.unwrap();
527 assert_eq!(content, "hello world\n");
528 }
529
530 #[tokio::test]
531 async fn test_apply_edits_single_edit() {
532 let dir = tempfile::tempdir().unwrap();
533 let file_path = dir.path().join("test.txt");
534 fs::write(&file_path, "hello world\nfoo bar\n")
535 .await
536 .unwrap();
537
538 let input = EditInput {
539 path: file_path.to_str().unwrap().to_string(),
540 edits: vec![EditEntry {
541 old_text: "hello".to_string(),
542 new_text: "goodbye".to_string(),
543 }],
544 dry_run: false,
545 expected_hash: None,
546 ..Default::default()
547 };
548 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
549 assert!(output.applied);
550 assert!(output.message.contains("1 edit(s)"));
551
552 let content = fs::read_to_string(&file_path).await.unwrap();
553 assert_eq!(content, "goodbye world\nfoo bar\n");
554 }
555
556 #[tokio::test]
557 async fn test_apply_edits_multiple_edits() {
558 let dir = tempfile::tempdir().unwrap();
559 let file_path = dir.path().join("test.txt");
560 fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
561
562 let input = EditInput {
563 path: file_path.to_str().unwrap().to_string(),
564 edits: vec![
565 EditEntry {
566 old_text: "aaa".to_string(),
567 new_text: "AAA".to_string(),
568 },
569 EditEntry {
570 old_text: "ccc".to_string(),
571 new_text: "CCC".to_string(),
572 },
573 ],
574 dry_run: false,
575 expected_hash: None,
576 ..Default::default()
577 };
578 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
579 assert!(output.applied);
580 assert!(output.message.contains("2 edit(s)"));
581
582 let content = fs::read_to_string(&file_path).await.unwrap();
583 assert_eq!(content, "AAA\nbbb\nCCC\n");
584 }
585
586 #[tokio::test]
587 async fn test_apply_edits_crlf_preserved() {
588 let dir = tempfile::tempdir().unwrap();
589 let file_path = dir.path().join("test.txt");
590 fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
591
592 let input = EditInput {
593 path: file_path.to_str().unwrap().to_string(),
594 edits: vec![EditEntry {
595 old_text: "hello".to_string(),
596 new_text: "goodbye".to_string(),
597 }],
598 dry_run: false,
599 expected_hash: None,
600 ..Default::default()
601 };
602 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
603
604 let content = fs::read_to_string(&file_path).await.unwrap();
605 assert_eq!(content, "goodbye\r\nworld\r\n");
606 }
607
608 #[tokio::test]
609 async fn test_apply_edits_bom_preserved() {
610 let dir = tempfile::tempdir().unwrap();
611 let file_path = dir.path().join("test.txt");
612 fs::write(&file_path, "\u{feff}hello world\n")
613 .await
614 .unwrap();
615
616 let input = EditInput {
617 path: file_path.to_str().unwrap().to_string(),
618 edits: vec![EditEntry {
619 old_text: "hello".to_string(),
620 new_text: "goodbye".to_string(),
621 }],
622 dry_run: false,
623 expected_hash: None,
624 ..Default::default()
625 };
626 EditTool::apply_edits(Path::new("."), &input).await.unwrap();
627
628 let content = fs::read_to_string(&file_path).await.unwrap();
629 assert!(content.starts_with('\u{feff}'));
630 assert!(content.contains("goodbye"));
631 }
632
633 #[test]
634 fn test_prepare_arguments_expected_hash() {
635 let params = json!({
636 "path": "/tmp/test.txt",
637 "old_text": "hello",
638 "new_text": "world",
639 "expected_hash": "abcd1234"
640 });
641 let input = EditTool::prepare_arguments(¶ms);
642 assert_eq!(input.expected_hash.as_deref(), Some("abcd1234"));
643 }
644
645 #[test]
646 fn test_prepare_arguments_no_expected_hash() {
647 let params = json!({
648 "path": "/tmp/test.txt",
649 "old_text": "hello",
650 "new_text": "world"
651 });
652 let input = EditTool::prepare_arguments(¶ms);
653 assert!(input.expected_hash.is_none());
654 }
655
656 fn compute_hash(content: &str) -> String {
657 use std::hash::{Hash, Hasher};
658 let mut hasher = std::collections::hash_map::DefaultHasher::new();
659 content.hash(&mut hasher);
660 format!("{:016x}", hasher.finish())
661 }
662
663 #[tokio::test]
664 async fn test_conflict_detection_hash_mismatch() {
665 let dir = tempfile::tempdir().unwrap();
666 let file_path = dir.path().join("test.txt");
667 fs::write(&file_path, "hello world\n").await.unwrap();
668
669 let hash = compute_hash("hello world\n");
670
671 fs::write(&file_path, "hello modified world\n")
673 .await
674 .unwrap();
675
676 let input = EditInput {
677 path: file_path.to_str().unwrap().to_string(),
678 edits: vec![EditEntry {
679 old_text: "hello".to_string(),
680 new_text: "goodbye".to_string(),
681 }],
682 dry_run: false,
683 expected_hash: Some(hash),
684 ..Default::default()
685 };
686 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
687 assert!(!output.applied);
688 assert!(output.message.contains("modified since last read"));
689
690 let content = fs::read_to_string(&file_path).await.unwrap();
692 assert_eq!(content, "hello modified world\n");
693 }
694
695 #[tokio::test]
696 async fn test_conflict_detection_hash_match() {
697 let dir = tempfile::tempdir().unwrap();
698 let file_path = dir.path().join("test.txt");
699 fs::write(&file_path, "hello world\n").await.unwrap();
700
701 let hash = compute_hash("hello world\n");
702
703 let input = EditInput {
704 path: file_path.to_str().unwrap().to_string(),
705 edits: vec![EditEntry {
706 old_text: "hello".to_string(),
707 new_text: "goodbye".to_string(),
708 }],
709 dry_run: false,
710 expected_hash: Some(hash),
711 ..Default::default()
712 };
713 let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
714 assert!(output.applied);
715
716 let content = fs::read_to_string(&file_path).await.unwrap();
717 assert_eq!(content, "goodbye world\n");
718 }
719}