1use async_trait::async_trait;
38use serde_json::{json, Value};
39use std::collections::HashSet;
40use std::path::PathBuf;
41
42use super::{resolve_within, Tool};
43use crate::error::{Error, Result};
44use crate::llm::ToolSpec;
45
46const BEGIN: &str = "*** Begin Patch";
47const END: &str = "*** End Patch";
48const UPDATE: &str = "*** Update File: ";
49const ADD: &str = "*** Add File: ";
50const DELETE: &str = "*** Delete File: ";
51const HUNK_SEP: &str = "@@";
52
53fn normalize_hunk_header(rest: &str) -> Option<String> {
61 let trimmed = rest.trim();
62 if trimmed.is_empty() {
63 return None;
64 }
65
66 let bytes = trimmed.as_bytes();
69 let len = bytes.len();
70 let mut i = 0;
71
72 while i < len && bytes[i].is_ascii_whitespace() {
74 i += 1;
75 }
76
77 if i < len && bytes[i] == b'-' {
79 i += 1;
80 let mut has_digits = false;
82 while i < len && bytes[i].is_ascii_digit() {
83 has_digits = true;
84 i += 1;
85 }
86 if i < len && bytes[i] == b',' {
87 i += 1;
88 while i < len && bytes[i].is_ascii_digit() {
89 has_digits = true;
90 i += 1;
91 }
92 }
93 if !has_digits {
94 return Some(trimmed.to_string());
96 }
97
98 while i < len && bytes[i].is_ascii_whitespace() {
100 i += 1;
101 }
102
103 if i < len && bytes[i] == b'+' {
105 i += 1;
106 let mut has_digits2 = false;
108 while i < len && bytes[i].is_ascii_digit() {
109 has_digits2 = true;
110 i += 1;
111 }
112 if i < len && bytes[i] == b',' {
113 i += 1;
114 while i < len && bytes[i].is_ascii_digit() {
115 has_digits2 = true;
116 i += 1;
117 }
118 }
119 if !has_digits2 {
120 return Some(trimmed.to_string());
121 }
122
123 while i < len && bytes[i].is_ascii_whitespace() {
125 i += 1;
126 }
127
128 if i + 1 < len && bytes[i] == b'@' && bytes[i + 1] == b'@' {
130 i += 2;
131 while i < len && bytes[i].is_ascii_whitespace() {
133 i += 1;
134 }
135 let trailing = trimmed[i..].trim().to_string();
136 if trailing.is_empty() {
137 return None;
138 }
139 return Some(trailing);
140 }
141 }
142 }
143
144 Some(trimmed.to_string())
146}
147
148#[derive(Debug, Clone)]
151pub struct ApplyPatch {
152 pub root: PathBuf,
153}
154
155impl ApplyPatch {
156 pub fn new(root: impl Into<PathBuf>) -> Self {
157 Self { root: root.into() }
158 }
159}
160
161#[async_trait]
162impl Tool for ApplyPatch {
163 fn spec(&self) -> ToolSpec {
164 ToolSpec {
165 name: "apply_patch".into(),
166 description: concat!(
167 "Apply a V4A-format patch atomically. Format:\n",
168 "*** Begin Patch\n",
169 "*** Update File: <path>\n",
170 "[@@ optional_anchor_line]\n",
171 " context_line\n",
172 "-line_to_remove\n",
173 "+line_to_add\n",
174 " context_line\n",
175 "*** Add File: <path>\n",
176 "+line_one\n",
177 "+line_two\n",
178 "*** Delete File: <path>\n",
179 "*** End Patch\n",
180 "Context lines must match the file exactly. If a hunk's pattern ",
181 "appears multiple times in the file, add an @@ anchor (some unique ",
182 "line that appears earlier in the file) before the hunk. ",
183 "Both `@@ <unique_line>` (V4A) and ",
184 "`@@ -N,M +N,M @@ <unique_line>` (unified-diff style) headers are ",
185 "accepted; the line-number range is ignored, only the anchor text ",
186 "after the final `@@` is used to locate the hunk."
187 )
188 .into(),
189 parameters: json!({
190 "type": "object",
191 "properties": {
192 "patch": {"type": "string", "description": "The V4A patch text"},
193 "dry_run": {"type": "boolean", "description": "If true, validate and resolve the patch but do not write any files. Returns the same success/error message it would produce in a normal run.", "default": false}
194 },
195 "required": ["patch"]
196 }),
197 }
198 }
199
200 async fn execute(&self, args: Value) -> Result<String> {
201 let input = args["patch"].as_str().ok_or_else(|| Error::BadToolArgs {
202 name: "apply_patch".into(),
203 message: "missing `patch`".into(),
204 })?;
205 let dry_run = args
206 .get("dry_run")
207 .and_then(|v| v.as_bool())
208 .unwrap_or(false);
209 let patch = parse_patch(input).map_err(|e| Error::Tool {
210 name: "apply_patch".into(),
211 message: e,
212 })?;
213 let writes = stage(&patch, &self.root).await.map_err(|e| Error::Tool {
214 name: "apply_patch".into(),
215 message: e,
216 })?;
217 if dry_run {
218 let file_count = writes.len();
219 let hunk_count: usize = patch
220 .ops
221 .iter()
222 .map(|op| match op {
223 FileOp::Update { hunks, .. } => hunks.len(),
224 _ => 1,
225 })
226 .sum();
227 let paths: Vec<&str> = writes.iter().map(|w| w.rel_path.as_str()).collect();
228 return Ok(format!(
229 "dry-run: would apply {} hunk(s) across {} file(s): {}",
230 hunk_count,
231 file_count,
232 paths.join(", ")
233 ));
234 }
235 commit(writes).await.map_err(|e| Error::Tool {
236 name: "apply_patch".into(),
237 message: e,
238 })
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
245pub(crate) enum HunkLine {
246 Context(String),
247 Add(String),
248 Remove(String),
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub(crate) struct Hunk {
253 pub anchor: Option<String>,
254 pub lines: Vec<HunkLine>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub(crate) enum FileOp {
259 Update { path: String, hunks: Vec<Hunk> },
260 Add { path: String, content: String },
261 Delete { path: String },
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub(crate) struct Patch {
266 pub ops: Vec<FileOp>,
267}
268
269pub(crate) fn parse_patch(input: &str) -> std::result::Result<Patch, String> {
272 let mut lines = input.lines().peekable();
273
274 while matches!(lines.peek(), Some(l) if l.trim().is_empty()) {
276 lines.next();
277 }
278
279 let begin = lines.next().ok_or("empty patch")?;
280 if begin.trim() != BEGIN {
281 return Err(format!("first line must be `{BEGIN}`, got `{begin}`"));
282 }
283
284 let mut patch = Patch::default();
285 let mut current_file: Option<FileOp> = None;
286
287 loop {
288 let Some(line) = lines.next() else {
289 return Err(format!("patch terminated without `{END}`"));
290 };
291
292 if line.trim() == END {
293 if let Some(op) = current_file.take() {
294 patch.ops.push(op);
295 }
296 break;
297 }
298
299 if let Some(path) = line.strip_prefix(UPDATE) {
301 if let Some(op) = current_file.take() {
302 patch.ops.push(op);
303 }
304 let path = path.trim().to_string();
305 if path.is_empty() {
306 return Err(format!("`{UPDATE}` with empty path"));
307 }
308 current_file = Some(FileOp::Update {
309 path,
310 hunks: vec![Hunk {
311 anchor: None,
312 lines: vec![],
313 }],
314 });
315 continue;
316 }
317 if let Some(path) = line.strip_prefix(ADD) {
318 if let Some(op) = current_file.take() {
319 patch.ops.push(op);
320 }
321 let path = path.trim().to_string();
322 if path.is_empty() {
323 return Err(format!("`{ADD}` with empty path"));
324 }
325 current_file = Some(FileOp::Add {
326 path,
327 content: String::new(),
328 });
329 continue;
330 }
331 if let Some(path) = line.strip_prefix(DELETE) {
332 if let Some(op) = current_file.take() {
333 patch.ops.push(op);
334 }
335 let path = path.trim().to_string();
336 if path.is_empty() {
337 return Err(format!("`{DELETE}` with empty path"));
338 }
339 current_file = Some(FileOp::Delete { path });
340 continue;
341 }
342
343 let op = current_file.as_mut().ok_or_else(|| {
345 format!("body line `{line}` before any `*** Update/Add/Delete File:` header")
346 })?;
347
348 match op {
349 FileOp::Update { hunks, .. } => {
350 if let Some(rest) = line.strip_prefix(HUNK_SEP) {
351 let anchor = normalize_hunk_header(rest);
353 let last = hunks.last_mut().unwrap();
354 if last.lines.is_empty() {
355 last.anchor = anchor;
356 } else {
357 hunks.push(Hunk {
358 anchor,
359 lines: vec![],
360 });
361 }
362 continue;
363 }
364
365 let parsed = if let Some(rest) = line.strip_prefix('-') {
366 HunkLine::Remove(rest.to_string())
367 } else if let Some(rest) = line.strip_prefix('+') {
368 HunkLine::Add(rest.to_string())
369 } else if let Some(rest) = line.strip_prefix(' ') {
370 HunkLine::Context(rest.to_string())
371 } else if line.is_empty() {
372 HunkLine::Context(String::new())
375 } else {
376 return Err(format!("unexpected line in Update hunk: `{line}`"));
377 };
378 hunks.last_mut().unwrap().lines.push(parsed);
379 }
380 FileOp::Add { content, .. } => {
381 let body_line = if let Some(rest) = line.strip_prefix('+') {
382 rest
383 } else if line.is_empty() {
384 ""
385 } else {
386 return Err(format!(
387 "Add File body lines must start with `+`, got `{line}`"
388 ));
389 };
390 if !content.is_empty() {
391 content.push('\n');
392 }
393 content.push_str(body_line);
394 }
395 FileOp::Delete { .. } => {
396 if !line.trim().is_empty() {
398 return Err(format!("Delete File body must be empty, got `{line}`"));
399 }
400 }
401 }
402 }
403
404 let mut seen: HashSet<&String> = HashSet::new();
406 for op in &patch.ops {
407 let path = match op {
408 FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
409 path
410 }
411 };
412 if !seen.insert(path) {
413 return Err(format!("path `{path}` appears in patch more than once"));
414 }
415 if let FileOp::Update { hunks, .. } = op {
416 if hunks.iter().all(|h| h.lines.is_empty()) {
417 return Err(format!("Update File `{path}` has no hunks"));
418 }
419 for (i, h) in hunks.iter().enumerate() {
420 if h.lines.is_empty() {
421 return Err(format!("Update File `{path}` hunk {} is empty", i + 1));
422 }
423 if !h
424 .lines
425 .iter()
426 .any(|l| matches!(l, HunkLine::Add(_) | HunkLine::Remove(_)))
427 {
428 return Err(format!(
429 "Update File `{path}` hunk {} has no +/- lines",
430 i + 1
431 ));
432 }
433 }
434 }
435 }
436
437 Ok(patch)
438}
439
440struct StagedWrite {
443 abs_path: PathBuf,
444 rel_path: String,
445 kind: StagedKind,
446}
447
448enum StagedKind {
449 WriteText(String),
450 Delete,
451}
452
453async fn stage(
454 patch: &Patch,
455 root: &std::path::Path,
456) -> std::result::Result<Vec<StagedWrite>, String> {
457 let mut staged = Vec::new();
458 for op in &patch.ops {
459 let path = match op {
460 FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
461 path
462 }
463 };
464 let abs = resolve_within(root, path).map_err(|e| e.to_string())?;
465
466 match op {
467 FileOp::Update { hunks, .. } => {
468 let current = tokio::fs::read_to_string(&abs)
469 .await
470 .map_err(|e| format!("update `{path}`: {e}"))?;
471 let updated =
472 apply_hunks(¤t, hunks).map_err(|e| format!("update `{path}`: {e}"))?;
473 staged.push(StagedWrite {
474 abs_path: abs,
475 rel_path: path.clone(),
476 kind: StagedKind::WriteText(updated),
477 });
478 }
479 FileOp::Add { content, .. } => {
480 if abs.exists() {
481 return Err(format!("add `{path}`: file already exists"));
482 }
483 staged.push(StagedWrite {
484 abs_path: abs,
485 rel_path: path.clone(),
486 kind: StagedKind::WriteText(content.clone()),
487 });
488 }
489 FileOp::Delete { .. } => {
490 if !abs.exists() {
491 return Err(format!("delete `{path}`: file does not exist"));
492 }
493 staged.push(StagedWrite {
494 abs_path: abs,
495 rel_path: path.clone(),
496 kind: StagedKind::Delete,
497 });
498 }
499 }
500 }
501 Ok(staged)
502}
503
504async fn commit(staged: Vec<StagedWrite>) -> std::result::Result<String, String> {
505 let mut summary = Vec::new();
506 for w in &staged {
507 match &w.kind {
508 StagedKind::WriteText(content) => {
509 if let Some(parent) = w.abs_path.parent() {
510 tokio::fs::create_dir_all(parent)
511 .await
512 .map_err(|e| format!("mkdir for `{}`: {e}", w.rel_path))?;
513 }
514 tokio::fs::write(&w.abs_path, content)
515 .await
516 .map_err(|e| format!("write `{}`: {e}", w.rel_path))?;
517 summary.push(format!("wrote {} ({} bytes)", w.rel_path, content.len()));
518 }
519 StagedKind::Delete => {
520 tokio::fs::remove_file(&w.abs_path)
521 .await
522 .map_err(|e| format!("delete `{}`: {e}", w.rel_path))?;
523 summary.push(format!("deleted {}", w.rel_path));
524 }
525 }
526 }
527 Ok(format!(
528 "applied {} change(s):\n{}",
529 staged.len(),
530 summary.join("\n")
531 ))
532}
533
534fn find_unique_anchor_suggestions(
538 lines: &[String],
539 match_start: usize,
540 pattern: &[&str],
541) -> Vec<String> {
542 let mut suggestions = Vec::new();
543 let window = 5;
544
545 let start = match_start.saturating_sub(window);
547 let end = (match_start + pattern.len() + window).min(lines.len());
548
549 for i in start..end {
550 let line = &lines[i];
551 if line.is_empty() {
552 continue;
553 }
554
555 let count = lines.iter().filter(|l| l.as_str() == line.as_str()).count();
557 if count == 1 {
558 suggestions.push(line.clone());
559 }
560
561 if suggestions.len() >= 3 {
562 break;
563 }
564 }
565
566 suggestions
567}
568
569fn apply_hunks(content: &str, hunks: &[Hunk]) -> std::result::Result<String, String> {
570 let trailing_newline = content.ends_with('\n');
571 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
572
573 for (idx, hunk) in hunks.iter().enumerate() {
574 let pattern: Vec<&str> = hunk
576 .lines
577 .iter()
578 .filter_map(|l| match l {
579 HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
580 HunkLine::Add(_) => None,
581 })
582 .collect();
583
584 let replacement: Vec<String> = hunk
585 .lines
586 .iter()
587 .filter_map(|l| match l {
588 HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
589 HunkLine::Remove(_) => None,
590 })
591 .collect();
592
593 if pattern.is_empty() {
594 if !lines.is_empty() || hunk.anchor.is_some() {
597 return Err(format!(
598 "hunk {} has no context/remove lines; need at least one for matching",
599 idx + 1
600 ));
601 }
602 lines.extend(replacement);
603 continue;
604 }
605
606 let mut matches: Vec<usize> = Vec::new();
608 if pattern.len() <= lines.len() {
609 for start in 0..=(lines.len() - pattern.len()) {
610 if lines[start..start + pattern.len()]
611 .iter()
612 .zip(pattern.iter())
613 .all(|(actual, expected)| actual == expected)
614 {
615 matches.push(start);
616 }
617 }
618 }
619
620 if let Some(anchor) = &hunk.anchor {
625 matches.retain(|&start| {
626 let end = start + pattern.len();
627 lines[..end].iter().any(|l| l.contains(anchor.as_str()))
628 });
629 }
630
631 let pos = match matches.len() {
632 0 => {
633 return Err(format!(
634 "hunk {} pattern not found{}.\nFirst 3 pattern lines:\n{}",
635 idx + 1,
636 hunk.anchor
637 .as_deref()
638 .map(|a| format!(" (anchor `{a}`)"))
639 .unwrap_or_default(),
640 pattern
641 .iter()
642 .take(3)
643 .map(|l| format!(" {l}"))
644 .collect::<Vec<_>>()
645 .join("\n")
646 ));
647 }
648 1 => matches[0],
649 n => {
650 let suggestions = find_unique_anchor_suggestions(&lines, matches[0], &pattern);
652
653 let suggestion_text = if suggestions.is_empty() {
654 String::new()
655 } else {
656 format!(
657 "\nAdd an `@@ <anchor>` line above the hunk with one of these unique nearby lines:\n{}",
658 suggestions
659 .iter()
660 .map(|s| format!(" @@ {}", s))
661 .collect::<Vec<_>>()
662 .join("\n")
663 )
664 };
665
666 return Err(format!(
667 "hunk {} pattern matches {} locations; add an `@@ anchor` line above the hunk to disambiguate{}",
668 idx + 1, n, suggestion_text
669 ));
670 }
671 };
672
673 let end = pos + pattern.len();
674 lines.splice(pos..end, replacement);
675 }
676
677 let mut result = lines.join("\n");
678 if trailing_newline {
679 result.push('\n');
680 }
681 Ok(result)
682}
683
684#[cfg(test)]
687mod tests {
688 use super::*;
689 use tempfile::TempDir;
690
691 fn p(input: &str) -> Patch {
692 parse_patch(input).expect("parse")
693 }
694
695 #[test]
698 fn parses_v4a_anchor() {
699 assert_eq!(normalize_hunk_header("fn foo"), Some("fn foo".to_string()));
700 }
701
702 #[test]
703 fn parses_unified_header_with_anchor() {
704 assert_eq!(
705 normalize_hunk_header("-10,5 +10,7 @@ pub use foo;"),
706 Some("pub use foo;".to_string())
707 );
708 }
709
710 #[test]
711 fn parses_unified_header_without_anchor() {
712 assert_eq!(normalize_hunk_header("-10,5 +10,7 @@"), None);
713 }
714
715 #[test]
716 fn parses_unified_header_singular_counts() {
717 assert_eq!(
718 normalize_hunk_header("-10 +10 @@ fn bar"),
719 Some("fn bar".to_string())
720 );
721 }
722
723 #[test]
724 fn normalize_empty_returns_none() {
725 assert_eq!(normalize_hunk_header(""), None);
726 assert_eq!(normalize_hunk_header(" "), None);
727 }
728
729 #[test]
730 fn normalize_non_unified_returns_whole() {
731 assert_eq!(
732 normalize_hunk_header("some random text"),
733 Some("some random text".to_string())
734 );
735 }
736
737 #[test]
738 fn normalize_unified_with_extra_whitespace() {
739 assert_eq!(
740 normalize_hunk_header(" -14,6 +14,28 @@ pub use mock::MockProvider; "),
741 Some("pub use mock::MockProvider;".to_string())
742 );
743 }
744
745 #[test]
748 fn parse_update_single_hunk() {
749 let patch = p("\
750*** Begin Patch
751*** Update File: a.txt
752 keep
753-old
754+new
755*** End Patch
756");
757 assert_eq!(patch.ops.len(), 1);
758 match &patch.ops[0] {
759 FileOp::Update { path, hunks } => {
760 assert_eq!(path, "a.txt");
761 assert_eq!(hunks.len(), 1);
762 assert_eq!(hunks[0].anchor, None);
763 assert_eq!(
764 hunks[0].lines,
765 vec![
766 HunkLine::Context("keep".into()),
767 HunkLine::Remove("old".into()),
768 HunkLine::Add("new".into()),
769 ]
770 );
771 }
772 _ => panic!("wrong op"),
773 }
774 }
775
776 #[test]
777 fn parse_multi_hunk_with_anchors() {
778 let patch = p("\
779*** Begin Patch
780*** Update File: a.txt
781@@ fn foo
782 a
783-b
784+B
785@@ fn bar
786 c
787-d
788+D
789*** End Patch
790");
791 match &patch.ops[0] {
792 FileOp::Update { hunks, .. } => {
793 assert_eq!(hunks.len(), 2);
794 assert_eq!(hunks[0].anchor.as_deref(), Some("fn foo"));
795 assert_eq!(hunks[1].anchor.as_deref(), Some("fn bar"));
796 }
797 _ => panic!(),
798 }
799 }
800
801 #[test]
802 fn parse_unified_header_hunk() {
803 let patch = p("\
805*** Begin Patch
806*** Update File: a.txt
807@@ -10,5 +10,7 @@ pub use foo;
808 a
809-b
810+B
811*** End Patch
812");
813 match &patch.ops[0] {
814 FileOp::Update { hunks, .. } => {
815 assert_eq!(hunks.len(), 1);
816 assert_eq!(hunks[0].anchor.as_deref(), Some("pub use foo;"));
817 }
818 _ => panic!(),
819 }
820 }
821
822 #[test]
823 fn parse_unified_header_no_anchor() {
824 let patch = p("\
825*** Begin Patch
826*** Update File: a.txt
827@@ -10,5 +10,7 @@
828 a
829-b
830+B
831*** End Patch
832");
833 match &patch.ops[0] {
834 FileOp::Update { hunks, .. } => {
835 assert_eq!(hunks.len(), 1);
836 assert_eq!(hunks[0].anchor, None);
837 }
838 _ => panic!(),
839 }
840 }
841
842 #[test]
843 fn parse_add_file_collects_plus_lines() {
844 let patch = p("\
845*** Begin Patch
846*** Add File: new.txt
847+hello
848+world
849*** End Patch
850");
851 match &patch.ops[0] {
852 FileOp::Add { path, content } => {
853 assert_eq!(path, "new.txt");
854 assert_eq!(content, "hello\nworld");
855 }
856 _ => panic!(),
857 }
858 }
859
860 #[test]
861 fn parse_delete_file() {
862 let patch = p("\
863*** Begin Patch
864*** Delete File: doomed.txt
865*** End Patch
866");
867 assert!(matches!(&patch.ops[0], FileOp::Delete { path, .. } if path == "doomed.txt"));
868 }
869
870 #[test]
871 fn parse_rejects_missing_begin() {
872 let e = parse_patch("hello\n*** End Patch\n").unwrap_err();
873 assert!(e.contains("first line"));
874 }
875
876 #[test]
877 fn parse_rejects_missing_end() {
878 let e = parse_patch("*** Begin Patch\n*** Update File: a\n keep\n").unwrap_err();
879 assert!(e.contains("terminated"));
880 }
881
882 #[test]
883 fn parse_rejects_duplicate_path() {
884 let e = parse_patch(
885 "\
886*** Begin Patch
887*** Add File: a.txt
888+x
889*** Delete File: a.txt
890*** End Patch
891",
892 )
893 .unwrap_err();
894 assert!(e.contains("more than once"));
895 }
896
897 #[test]
898 fn parse_rejects_body_without_header() {
899 let e = parse_patch("*** Begin Patch\n+stray\n*** End Patch\n").unwrap_err();
900 assert!(e.contains("before any"));
901 }
902
903 #[test]
904 fn parse_rejects_hunk_without_changes() {
905 let e = parse_patch(
906 "\
907*** Begin Patch
908*** Update File: a
909 just
910 context
911*** End Patch
912",
913 )
914 .unwrap_err();
915 assert!(e.contains("no +/- lines"));
916 }
917
918 #[test]
921 fn applies_single_hunk_round_trip() {
922 let original = "line1\nold\nline3\n";
923 let patch = p("\
924*** Begin Patch
925*** Update File: f
926 line1
927-old
928+new
929 line3
930*** End Patch
931");
932 let FileOp::Update { hunks, .. } = &patch.ops[0] else {
933 panic!()
934 };
935 let updated = apply_hunks(original, hunks).unwrap();
936 assert_eq!(updated, "line1\nnew\nline3\n");
937 }
938
939 #[test]
940 fn applies_multi_hunk() {
941 let original = "alpha\nbeta\ngamma\ndelta\n";
942 let hunks = vec![
943 Hunk {
944 anchor: None,
945 lines: vec![
946 HunkLine::Context("alpha".into()),
947 HunkLine::Remove("beta".into()),
948 HunkLine::Add("BETA".into()),
949 ],
950 },
951 Hunk {
952 anchor: None,
953 lines: vec![
954 HunkLine::Context("gamma".into()),
955 HunkLine::Remove("delta".into()),
956 HunkLine::Add("DELTA".into()),
957 ],
958 },
959 ];
960 assert_eq!(
961 apply_hunks(original, &hunks).unwrap(),
962 "alpha\nBETA\ngamma\nDELTA\n"
963 );
964 }
965
966 #[test]
967 fn errors_when_pattern_not_found() {
968 let e = apply_hunks(
969 "hello\nworld\n",
970 &[Hunk {
971 anchor: None,
972 lines: vec![
973 HunkLine::Context("foo".into()),
974 HunkLine::Remove("bar".into()),
975 HunkLine::Add("baz".into()),
976 ],
977 }],
978 )
979 .unwrap_err();
980 assert!(e.contains("not found"));
981 }
982
983 #[test]
984 fn errors_when_pattern_ambiguous() {
985 let original = "x\ny\nx\ny\n";
986 let e = apply_hunks(
987 original,
988 &[Hunk {
989 anchor: None,
990 lines: vec![
991 HunkLine::Context("x".into()),
992 HunkLine::Remove("y".into()),
993 HunkLine::Add("Y".into()),
994 ],
995 }],
996 )
997 .unwrap_err();
998 assert!(e.contains("matches"));
999 }
1000
1001 #[test]
1002 fn ambiguous_error_shows_unique_anchor_suggestions() {
1003 let original = "UNIQUE_FUNC_A()\n x\n y\n}\nUNIQUE_FUNC_B()\n x\n y\n}\nUNIQUE_FUNC_C() {}\n";
1005 let hunks = vec![Hunk {
1006 anchor: None,
1007 lines: vec![
1008 HunkLine::Context(" x".into()),
1009 HunkLine::Remove(" y".into()),
1010 HunkLine::Add(" Y".into()),
1011 ],
1012 }];
1013 let e = apply_hunks(original, &hunks).unwrap_err();
1014
1015 assert!(e.contains("matches"));
1017 assert!(e.contains("@@ <anchor>"));
1019 assert!(
1021 e.contains("UNIQUE_FUNC_A")
1022 || e.contains("UNIQUE_FUNC_B")
1023 || e.contains("UNIQUE_FUNC_C")
1024 );
1025 }
1026
1027 #[test]
1028 fn anchor_disambiguates_repeated_context() {
1029 let original = "fn foo {\n x\n y\n}\nfn bar {\n x\n y\n}\n";
1031 let hunks = vec![Hunk {
1032 anchor: Some("fn bar".into()),
1033 lines: vec![
1034 HunkLine::Context(" x".into()),
1035 HunkLine::Remove(" y".into()),
1036 HunkLine::Add(" Y".into()),
1037 ],
1038 }];
1039 let out = apply_hunks(original, &hunks).unwrap();
1040 assert_eq!(out, "fn foo {\n x\n y\n}\nfn bar {\n x\n Y\n}\n");
1041 }
1042
1043 #[test]
1044 fn preserves_no_trailing_newline() {
1045 let original = "a\nb";
1046 let hunks = vec![Hunk {
1047 anchor: None,
1048 lines: vec![
1049 HunkLine::Remove("a".into()),
1050 HunkLine::Add("A".into()),
1051 HunkLine::Context("b".into()),
1052 ],
1053 }];
1054 assert_eq!(apply_hunks(original, &hunks).unwrap(), "A\nb");
1055 }
1056
1057 #[tokio::test]
1060 async fn tool_updates_existing_file() {
1061 let tmp = TempDir::new().unwrap();
1062 std::fs::write(tmp.path().join("hello.txt"), "before\n").unwrap();
1063 let tool = ApplyPatch::new(tmp.path());
1064 let result = tool
1065 .execute(json!({"patch": "\
1066*** Begin Patch
1067*** Update File: hello.txt
1068-before
1069+after
1070*** End Patch
1071"}))
1072 .await
1073 .unwrap();
1074 assert!(result.contains("wrote hello.txt"));
1075 assert_eq!(
1076 std::fs::read_to_string(tmp.path().join("hello.txt")).unwrap(),
1077 "after\n"
1078 );
1079 }
1080
1081 #[tokio::test]
1082 async fn tool_adds_new_file() {
1083 let tmp = TempDir::new().unwrap();
1084 let tool = ApplyPatch::new(tmp.path());
1085 tool.execute(json!({"patch": "\
1086*** Begin Patch
1087*** Add File: sub/new.txt
1088+hi
1089+there
1090*** End Patch
1091"}))
1092 .await
1093 .unwrap();
1094 assert_eq!(
1095 std::fs::read_to_string(tmp.path().join("sub/new.txt")).unwrap(),
1096 "hi\nthere"
1097 );
1098 }
1099
1100 #[tokio::test]
1101 async fn tool_deletes_existing_file() {
1102 let tmp = TempDir::new().unwrap();
1103 std::fs::write(tmp.path().join("doomed.txt"), "x").unwrap();
1104 ApplyPatch::new(tmp.path())
1105 .execute(json!({"patch": "\
1106*** Begin Patch
1107*** Delete File: doomed.txt
1108*** End Patch
1109"}))
1110 .await
1111 .unwrap();
1112 assert!(!tmp.path().join("doomed.txt").exists());
1113 }
1114
1115 #[tokio::test]
1116 async fn tool_is_atomic_on_failure() {
1117 let tmp = TempDir::new().unwrap();
1120 std::fs::write(tmp.path().join("a.txt"), "original-a\n").unwrap();
1121 std::fs::write(tmp.path().join("b.txt"), "original-b\n").unwrap();
1122 let err = ApplyPatch::new(tmp.path())
1123 .execute(json!({"patch": "\
1124*** Begin Patch
1125*** Update File: a.txt
1126-original-a
1127+changed-a
1128*** Update File: b.txt
1129-nonexistent-line
1130+changed-b
1131*** End Patch
1132"}))
1133 .await
1134 .unwrap_err();
1135 assert!(matches!(err, Error::Tool { .. }));
1136 assert_eq!(
1137 std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
1138 "original-a\n"
1139 );
1140 assert_eq!(
1141 std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
1142 "original-b\n"
1143 );
1144 }
1145
1146 #[tokio::test]
1147 async fn tool_rejects_add_when_file_exists() {
1148 let tmp = TempDir::new().unwrap();
1149 std::fs::write(tmp.path().join("exists.txt"), "x").unwrap();
1150 let err = ApplyPatch::new(tmp.path())
1151 .execute(json!({"patch": "\
1152*** Begin Patch
1153*** Add File: exists.txt
1154+content
1155*** End Patch
1156"}))
1157 .await
1158 .unwrap_err();
1159 assert!(matches!(err, Error::Tool { .. }));
1160 }
1161
1162 #[tokio::test]
1163 async fn tool_rejects_delete_when_file_missing() {
1164 let tmp = TempDir::new().unwrap();
1165 let err = ApplyPatch::new(tmp.path())
1166 .execute(json!({"patch": "\
1167*** Begin Patch
1168*** Delete File: ghost.txt
1169*** End Patch
1170"}))
1171 .await
1172 .unwrap_err();
1173 assert!(matches!(err, Error::Tool { .. }));
1174 }
1175
1176 #[tokio::test]
1177 async fn tool_rejects_sandbox_escape() {
1178 let tmp = TempDir::new().unwrap();
1179 let err = ApplyPatch::new(tmp.path())
1180 .execute(json!({"patch": "\
1181*** Begin Patch
1182*** Add File: ../escape.txt
1183+evil
1184*** End Patch
1185"}))
1186 .await
1187 .unwrap_err();
1188 assert!(matches!(err, Error::Tool { .. }));
1191 assert!(!tmp.path().parent().unwrap().join("escape.txt").exists());
1192 }
1193
1194 #[tokio::test]
1195 async fn tool_round_trips_a_real_change() {
1196 let tmp = TempDir::new().unwrap();
1197 std::fs::write(
1198 tmp.path().join("lib.rs"),
1199 "\
1200fn add(a: i32, b: i32) -> i32 {
1201 a + b
1202}
1203
1204fn sub(a: i32, b: i32) -> i32 {
1205 a - b
1206}
1207",
1208 )
1209 .unwrap();
1210 let tool = ApplyPatch::new(tmp.path());
1211 tool.execute(json!({"patch": "\
1212*** Begin Patch
1213*** Update File: lib.rs
1214@@ fn sub
1215 fn sub(a: i32, b: i32) -> i32 {
1216- a - b
1217+ a.saturating_sub(b)
1218 }
1219*** End Patch
1220"}))
1221 .await
1222 .unwrap();
1223 let got = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
1224 assert!(got.contains("a.saturating_sub(b)"));
1225 assert!(got.contains("a + b"), "add() unchanged");
1226 }
1227
1228 #[tokio::test]
1229 async fn tool_applies_patch_with_unified_header() {
1230 let tmp = TempDir::new().unwrap();
1232 std::fs::write(tmp.path().join("greeting.txt"), "hello\nworld\n").unwrap();
1233 let tool = ApplyPatch::new(tmp.path());
1234 tool.execute(json!({"patch": "\
1235*** Begin Patch
1236*** Update File: greeting.txt
1237@@ -1,3 +1,4 @@ hello
1238 hello
1239-world
1240+earth
1241*** End Patch
1242"}))
1243 .await
1244 .unwrap();
1245 let got = std::fs::read_to_string(tmp.path().join("greeting.txt")).unwrap();
1246 assert_eq!(got, "hello\nearth\n");
1247 }
1248
1249 #[tokio::test]
1250 async fn dry_run_returns_summary_and_does_not_write() {
1251 let tmp = TempDir::new().unwrap();
1252 std::fs::write(tmp.path().join("target.txt"), "before\n").unwrap();
1253 let tool = ApplyPatch::new(tmp.path());
1254 let result = tool
1255 .execute(json!({"patch": "\
1256*** Begin Patch
1257*** Update File: target.txt
1258-before
1259+after
1260*** End Patch
1261", "dry_run": true}))
1262 .await
1263 .unwrap();
1264 assert!(result.contains("dry-run: would apply"));
1265 assert!(result.contains("target.txt"));
1266 assert_eq!(
1268 std::fs::read_to_string(tmp.path().join("target.txt")).unwrap(),
1269 "before\n"
1270 );
1271 }
1272
1273 #[tokio::test]
1274 async fn dry_run_errors_on_ambiguous_anchor() {
1275 let tmp = TempDir::new().unwrap();
1276 std::fs::write(tmp.path().join("dup.txt"), "x\ny\nx\ny\n").unwrap();
1277 let tool = ApplyPatch::new(tmp.path());
1278 let err = tool
1279 .execute(json!({"patch": "\
1280*** Begin Patch
1281*** Update File: dup.txt
1282 x
1283-y
1284+Y
1285*** End Patch
1286", "dry_run": true}))
1287 .await
1288 .unwrap_err();
1289 assert!(matches!(err, Error::Tool { .. }));
1290 let msg = format!("{}", err);
1291 assert!(
1292 msg.contains("matches"),
1293 "error should mention multiple matches: {msg}"
1294 );
1295 }
1296
1297 #[tokio::test]
1298 async fn dry_run_false_is_same_as_omitting_field() {
1299 let tmp = TempDir::new().unwrap();
1300 std::fs::write(tmp.path().join("test.txt"), "old\n").unwrap();
1301 let tool = ApplyPatch::new(tmp.path());
1302 tool.execute(json!({"patch": "\
1303*** Begin Patch
1304*** Update File: test.txt
1305-old
1306+new
1307*** End Patch
1308", "dry_run": false}))
1309 .await
1310 .unwrap();
1311 assert_eq!(
1312 std::fs::read_to_string(tmp.path().join("test.txt")).unwrap(),
1313 "new\n"
1314 );
1315 }
1316}