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