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