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
53#[derive(Debug, Clone)]
56pub struct ApplyPatch {
57 pub root: PathBuf,
58}
59
60impl ApplyPatch {
61 pub fn new(root: impl Into<PathBuf>) -> Self {
62 Self { root: root.into() }
63 }
64}
65
66#[async_trait]
67impl Tool for ApplyPatch {
68 fn spec(&self) -> ToolSpec {
69 ToolSpec {
70 name: "apply_patch".into(),
71 description: concat!(
72 "Apply a V4A-format patch atomically. Format:\n",
73 "*** Begin Patch\n",
74 "*** Update File: <path>\n",
75 "[@@ optional_anchor_line]\n",
76 " context_line\n",
77 "-line_to_remove\n",
78 "+line_to_add\n",
79 " context_line\n",
80 "*** Add File: <path>\n",
81 "+line_one\n",
82 "+line_two\n",
83 "*** Delete File: <path>\n",
84 "*** End Patch\n",
85 "Context lines must match the file exactly. If a hunk's pattern ",
86 "appears multiple times in the file, add an @@ anchor (some unique ",
87 "line that appears earlier in the file) before the hunk."
88 )
89 .into(),
90 parameters: json!({
91 "type": "object",
92 "properties": {
93 "patch": {"type": "string", "description": "The V4A patch text"}
94 },
95 "required": ["patch"]
96 }),
97 }
98 }
99
100 async fn execute(&self, args: Value) -> Result<String> {
101 let input = args["patch"].as_str().ok_or_else(|| Error::BadToolArgs {
102 name: "apply_patch".into(),
103 message: "missing `patch`".into(),
104 })?;
105 let patch = parse_patch(input).map_err(|e| Error::Tool {
106 name: "apply_patch".into(),
107 message: e,
108 })?;
109 let writes = stage(&patch, &self.root).await.map_err(|e| Error::Tool {
110 name: "apply_patch".into(),
111 message: e,
112 })?;
113 commit(writes).await.map_err(|e| Error::Tool {
114 name: "apply_patch".into(),
115 message: e,
116 })
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
123pub(crate) enum HunkLine {
124 Context(String),
125 Add(String),
126 Remove(String),
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub(crate) struct Hunk {
131 pub anchor: Option<String>,
132 pub lines: Vec<HunkLine>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub(crate) enum FileOp {
137 Update { path: String, hunks: Vec<Hunk> },
138 Add { path: String, content: String },
139 Delete { path: String },
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
143pub(crate) struct Patch {
144 pub ops: Vec<FileOp>,
145}
146
147pub(crate) fn parse_patch(input: &str) -> std::result::Result<Patch, String> {
150 let mut lines = input.lines().peekable();
151
152 while matches!(lines.peek(), Some(l) if l.trim().is_empty()) {
154 lines.next();
155 }
156
157 let begin = lines.next().ok_or("empty patch")?;
158 if begin.trim() != BEGIN {
159 return Err(format!("first line must be `{BEGIN}`, got `{begin}`"));
160 }
161
162 let mut patch = Patch::default();
163 let mut current_file: Option<FileOp> = None;
164
165 loop {
166 let Some(line) = lines.next() else {
167 return Err(format!("patch terminated without `{END}`"));
168 };
169
170 if line.trim() == END {
171 if let Some(op) = current_file.take() {
172 patch.ops.push(op);
173 }
174 break;
175 }
176
177 if let Some(path) = line.strip_prefix(UPDATE) {
179 if let Some(op) = current_file.take() {
180 patch.ops.push(op);
181 }
182 let path = path.trim().to_string();
183 if path.is_empty() {
184 return Err(format!("`{UPDATE}` with empty path"));
185 }
186 current_file = Some(FileOp::Update {
187 path,
188 hunks: vec![Hunk {
189 anchor: None,
190 lines: vec![],
191 }],
192 });
193 continue;
194 }
195 if let Some(path) = line.strip_prefix(ADD) {
196 if let Some(op) = current_file.take() {
197 patch.ops.push(op);
198 }
199 let path = path.trim().to_string();
200 if path.is_empty() {
201 return Err(format!("`{ADD}` with empty path"));
202 }
203 current_file = Some(FileOp::Add {
204 path,
205 content: String::new(),
206 });
207 continue;
208 }
209 if let Some(path) = line.strip_prefix(DELETE) {
210 if let Some(op) = current_file.take() {
211 patch.ops.push(op);
212 }
213 let path = path.trim().to_string();
214 if path.is_empty() {
215 return Err(format!("`{DELETE}` with empty path"));
216 }
217 current_file = Some(FileOp::Delete { path });
218 continue;
219 }
220
221 let op = current_file.as_mut().ok_or_else(|| {
223 format!("body line `{line}` before any `*** Update/Add/Delete File:` header")
224 })?;
225
226 match op {
227 FileOp::Update { hunks, .. } => {
228 if let Some(rest) = line.strip_prefix(HUNK_SEP) {
229 let anchor_str = rest.trim();
231 let anchor = if anchor_str.is_empty() {
232 None
233 } else {
234 Some(anchor_str.to_string())
235 };
236 let last = hunks.last_mut().unwrap();
237 if last.lines.is_empty() {
238 last.anchor = anchor;
239 } else {
240 hunks.push(Hunk {
241 anchor,
242 lines: vec![],
243 });
244 }
245 continue;
246 }
247
248 let parsed = if let Some(rest) = line.strip_prefix('-') {
249 HunkLine::Remove(rest.to_string())
250 } else if let Some(rest) = line.strip_prefix('+') {
251 HunkLine::Add(rest.to_string())
252 } else if let Some(rest) = line.strip_prefix(' ') {
253 HunkLine::Context(rest.to_string())
254 } else if line.is_empty() {
255 HunkLine::Context(String::new())
258 } else {
259 return Err(format!("unexpected line in Update hunk: `{line}`"));
260 };
261 hunks.last_mut().unwrap().lines.push(parsed);
262 }
263 FileOp::Add { content, .. } => {
264 let body_line = if let Some(rest) = line.strip_prefix('+') {
265 rest
266 } else if line.is_empty() {
267 ""
268 } else {
269 return Err(format!(
270 "Add File body lines must start with `+`, got `{line}`"
271 ));
272 };
273 if !content.is_empty() {
274 content.push('\n');
275 }
276 content.push_str(body_line);
277 }
278 FileOp::Delete { .. } => {
279 if !line.trim().is_empty() {
281 return Err(format!("Delete File body must be empty, got `{line}`"));
282 }
283 }
284 }
285 }
286
287 let mut seen: HashSet<&String> = HashSet::new();
289 for op in &patch.ops {
290 let path = match op {
291 FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
292 path
293 }
294 };
295 if !seen.insert(path) {
296 return Err(format!("path `{path}` appears in patch more than once"));
297 }
298 if let FileOp::Update { hunks, .. } = op {
299 if hunks.iter().all(|h| h.lines.is_empty()) {
300 return Err(format!("Update File `{path}` has no hunks"));
301 }
302 for (i, h) in hunks.iter().enumerate() {
303 if h.lines.is_empty() {
304 return Err(format!("Update File `{path}` hunk {} is empty", i + 1));
305 }
306 if !h
307 .lines
308 .iter()
309 .any(|l| matches!(l, HunkLine::Add(_) | HunkLine::Remove(_)))
310 {
311 return Err(format!(
312 "Update File `{path}` hunk {} has no +/- lines",
313 i + 1
314 ));
315 }
316 }
317 }
318 }
319
320 Ok(patch)
321}
322
323struct StagedWrite {
326 abs_path: PathBuf,
327 rel_path: String,
328 kind: StagedKind,
329}
330
331enum StagedKind {
332 WriteText(String),
333 Delete,
334}
335
336async fn stage(
337 patch: &Patch,
338 root: &std::path::Path,
339) -> std::result::Result<Vec<StagedWrite>, String> {
340 let mut staged = Vec::new();
341 for op in &patch.ops {
342 let path = match op {
343 FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
344 path
345 }
346 };
347 let abs = resolve_within(root, path).map_err(|e| e.to_string())?;
348
349 match op {
350 FileOp::Update { hunks, .. } => {
351 let current = tokio::fs::read_to_string(&abs)
352 .await
353 .map_err(|e| format!("update `{path}`: {e}"))?;
354 let updated =
355 apply_hunks(¤t, hunks).map_err(|e| format!("update `{path}`: {e}"))?;
356 staged.push(StagedWrite {
357 abs_path: abs,
358 rel_path: path.clone(),
359 kind: StagedKind::WriteText(updated),
360 });
361 }
362 FileOp::Add { content, .. } => {
363 if abs.exists() {
364 return Err(format!("add `{path}`: file already exists"));
365 }
366 staged.push(StagedWrite {
367 abs_path: abs,
368 rel_path: path.clone(),
369 kind: StagedKind::WriteText(content.clone()),
370 });
371 }
372 FileOp::Delete { .. } => {
373 if !abs.exists() {
374 return Err(format!("delete `{path}`: file does not exist"));
375 }
376 staged.push(StagedWrite {
377 abs_path: abs,
378 rel_path: path.clone(),
379 kind: StagedKind::Delete,
380 });
381 }
382 }
383 }
384 Ok(staged)
385}
386
387async fn commit(staged: Vec<StagedWrite>) -> std::result::Result<String, String> {
388 let mut summary = Vec::new();
389 for w in &staged {
390 match &w.kind {
391 StagedKind::WriteText(content) => {
392 if let Some(parent) = w.abs_path.parent() {
393 tokio::fs::create_dir_all(parent)
394 .await
395 .map_err(|e| format!("mkdir for `{}`: {e}", w.rel_path))?;
396 }
397 tokio::fs::write(&w.abs_path, content)
398 .await
399 .map_err(|e| format!("write `{}`: {e}", w.rel_path))?;
400 summary.push(format!("wrote {} ({} bytes)", w.rel_path, content.len()));
401 }
402 StagedKind::Delete => {
403 tokio::fs::remove_file(&w.abs_path)
404 .await
405 .map_err(|e| format!("delete `{}`: {e}", w.rel_path))?;
406 summary.push(format!("deleted {}", w.rel_path));
407 }
408 }
409 }
410 Ok(format!(
411 "applied {} change(s):\n{}",
412 staged.len(),
413 summary.join("\n")
414 ))
415}
416
417fn apply_hunks(content: &str, hunks: &[Hunk]) -> std::result::Result<String, String> {
418 let trailing_newline = content.ends_with('\n');
419 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
420
421 for (idx, hunk) in hunks.iter().enumerate() {
422 let pattern: Vec<&str> = hunk
424 .lines
425 .iter()
426 .filter_map(|l| match l {
427 HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
428 HunkLine::Add(_) => None,
429 })
430 .collect();
431
432 let replacement: Vec<String> = hunk
433 .lines
434 .iter()
435 .filter_map(|l| match l {
436 HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
437 HunkLine::Remove(_) => None,
438 })
439 .collect();
440
441 if pattern.is_empty() {
442 if !lines.is_empty() || hunk.anchor.is_some() {
445 return Err(format!(
446 "hunk {} has no context/remove lines; need at least one for matching",
447 idx + 1
448 ));
449 }
450 lines.extend(replacement);
451 continue;
452 }
453
454 let mut matches: Vec<usize> = Vec::new();
456 if pattern.len() <= lines.len() {
457 for start in 0..=(lines.len() - pattern.len()) {
458 if lines[start..start + pattern.len()]
459 .iter()
460 .zip(pattern.iter())
461 .all(|(actual, expected)| actual == expected)
462 {
463 matches.push(start);
464 }
465 }
466 }
467
468 if let Some(anchor) = &hunk.anchor {
473 matches.retain(|&start| {
474 let end = start + pattern.len();
475 lines[..end].iter().any(|l| l.contains(anchor.as_str()))
476 });
477 }
478
479 let pos = match matches.len() {
480 0 => {
481 return Err(format!(
482 "hunk {} pattern not found{}.\nFirst 3 pattern lines:\n{}",
483 idx + 1,
484 hunk.anchor
485 .as_deref()
486 .map(|a| format!(" (anchor `{a}`)"))
487 .unwrap_or_default(),
488 pattern
489 .iter()
490 .take(3)
491 .map(|l| format!(" {l}"))
492 .collect::<Vec<_>>()
493 .join("\n")
494 ));
495 }
496 1 => matches[0],
497 n => {
498 return Err(format!(
499 "hunk {} pattern matches {} locations; add an `@@ anchor` line above the hunk to disambiguate",
500 idx + 1, n
501 ));
502 }
503 };
504
505 let end = pos + pattern.len();
506 lines.splice(pos..end, replacement);
507 }
508
509 let mut result = lines.join("\n");
510 if trailing_newline {
511 result.push('\n');
512 }
513 Ok(result)
514}
515
516#[cfg(test)]
519mod tests {
520 use super::*;
521 use tempfile::TempDir;
522
523 fn p(input: &str) -> Patch {
524 parse_patch(input).expect("parse")
525 }
526
527 #[test]
530 fn parse_update_single_hunk() {
531 let patch = p("\
532*** Begin Patch
533*** Update File: a.txt
534 keep
535-old
536+new
537*** End Patch
538");
539 assert_eq!(patch.ops.len(), 1);
540 match &patch.ops[0] {
541 FileOp::Update { path, hunks } => {
542 assert_eq!(path, "a.txt");
543 assert_eq!(hunks.len(), 1);
544 assert_eq!(hunks[0].anchor, None);
545 assert_eq!(
546 hunks[0].lines,
547 vec![
548 HunkLine::Context("keep".into()),
549 HunkLine::Remove("old".into()),
550 HunkLine::Add("new".into()),
551 ]
552 );
553 }
554 _ => panic!("wrong op"),
555 }
556 }
557
558 #[test]
559 fn parse_multi_hunk_with_anchors() {
560 let patch = p("\
561*** Begin Patch
562*** Update File: a.txt
563@@ fn foo
564 a
565-b
566+B
567@@ fn bar
568 c
569-d
570+D
571*** End Patch
572");
573 match &patch.ops[0] {
574 FileOp::Update { hunks, .. } => {
575 assert_eq!(hunks.len(), 2);
576 assert_eq!(hunks[0].anchor.as_deref(), Some("fn foo"));
577 assert_eq!(hunks[1].anchor.as_deref(), Some("fn bar"));
578 }
579 _ => panic!(),
580 }
581 }
582
583 #[test]
584 fn parse_add_file_collects_plus_lines() {
585 let patch = p("\
586*** Begin Patch
587*** Add File: new.txt
588+hello
589+world
590*** End Patch
591");
592 match &patch.ops[0] {
593 FileOp::Add { path, content } => {
594 assert_eq!(path, "new.txt");
595 assert_eq!(content, "hello\nworld");
596 }
597 _ => panic!(),
598 }
599 }
600
601 #[test]
602 fn parse_delete_file() {
603 let patch = p("\
604*** Begin Patch
605*** Delete File: doomed.txt
606*** End Patch
607");
608 assert!(matches!(&patch.ops[0], FileOp::Delete { path, .. } if path == "doomed.txt"));
609 }
610
611 #[test]
612 fn parse_rejects_missing_begin() {
613 let e = parse_patch("hello\n*** End Patch\n").unwrap_err();
614 assert!(e.contains("first line"));
615 }
616
617 #[test]
618 fn parse_rejects_missing_end() {
619 let e = parse_patch("*** Begin Patch\n*** Update File: a\n keep\n").unwrap_err();
620 assert!(e.contains("terminated"));
621 }
622
623 #[test]
624 fn parse_rejects_duplicate_path() {
625 let e = parse_patch(
626 "\
627*** Begin Patch
628*** Add File: a.txt
629+x
630*** Delete File: a.txt
631*** End Patch
632",
633 )
634 .unwrap_err();
635 assert!(e.contains("more than once"));
636 }
637
638 #[test]
639 fn parse_rejects_body_without_header() {
640 let e = parse_patch("*** Begin Patch\n+stray\n*** End Patch\n").unwrap_err();
641 assert!(e.contains("before any"));
642 }
643
644 #[test]
645 fn parse_rejects_hunk_without_changes() {
646 let e = parse_patch(
647 "\
648*** Begin Patch
649*** Update File: a
650 just
651 context
652*** End Patch
653",
654 )
655 .unwrap_err();
656 assert!(e.contains("no +/- lines"));
657 }
658
659 #[test]
662 fn applies_single_hunk_round_trip() {
663 let original = "line1\nold\nline3\n";
664 let patch = p("\
665*** Begin Patch
666*** Update File: f
667 line1
668-old
669+new
670 line3
671*** End Patch
672");
673 let FileOp::Update { hunks, .. } = &patch.ops[0] else {
674 panic!()
675 };
676 let updated = apply_hunks(original, hunks).unwrap();
677 assert_eq!(updated, "line1\nnew\nline3\n");
678 }
679
680 #[test]
681 fn applies_multi_hunk() {
682 let original = "alpha\nbeta\ngamma\ndelta\n";
683 let hunks = vec![
684 Hunk {
685 anchor: None,
686 lines: vec![
687 HunkLine::Context("alpha".into()),
688 HunkLine::Remove("beta".into()),
689 HunkLine::Add("BETA".into()),
690 ],
691 },
692 Hunk {
693 anchor: None,
694 lines: vec![
695 HunkLine::Context("gamma".into()),
696 HunkLine::Remove("delta".into()),
697 HunkLine::Add("DELTA".into()),
698 ],
699 },
700 ];
701 assert_eq!(
702 apply_hunks(original, &hunks).unwrap(),
703 "alpha\nBETA\ngamma\nDELTA\n"
704 );
705 }
706
707 #[test]
708 fn errors_when_pattern_not_found() {
709 let e = apply_hunks(
710 "hello\nworld\n",
711 &[Hunk {
712 anchor: None,
713 lines: vec![
714 HunkLine::Context("foo".into()),
715 HunkLine::Remove("bar".into()),
716 HunkLine::Add("baz".into()),
717 ],
718 }],
719 )
720 .unwrap_err();
721 assert!(e.contains("not found"));
722 }
723
724 #[test]
725 fn errors_when_pattern_ambiguous() {
726 let original = "x\ny\nx\ny\n";
727 let e = apply_hunks(
728 original,
729 &[Hunk {
730 anchor: None,
731 lines: vec![
732 HunkLine::Context("x".into()),
733 HunkLine::Remove("y".into()),
734 HunkLine::Add("Y".into()),
735 ],
736 }],
737 )
738 .unwrap_err();
739 assert!(e.contains("matches"));
740 }
741
742 #[test]
743 fn anchor_disambiguates_repeated_context() {
744 let original = "fn foo {\n x\n y\n}\nfn bar {\n x\n y\n}\n";
746 let hunks = vec![Hunk {
747 anchor: Some("fn bar".into()),
748 lines: vec![
749 HunkLine::Context(" x".into()),
750 HunkLine::Remove(" y".into()),
751 HunkLine::Add(" Y".into()),
752 ],
753 }];
754 let out = apply_hunks(original, &hunks).unwrap();
755 assert_eq!(out, "fn foo {\n x\n y\n}\nfn bar {\n x\n Y\n}\n");
756 }
757
758 #[test]
759 fn preserves_no_trailing_newline() {
760 let original = "a\nb";
761 let hunks = vec![Hunk {
762 anchor: None,
763 lines: vec![
764 HunkLine::Remove("a".into()),
765 HunkLine::Add("A".into()),
766 HunkLine::Context("b".into()),
767 ],
768 }];
769 assert_eq!(apply_hunks(original, &hunks).unwrap(), "A\nb");
770 }
771
772 #[tokio::test]
775 async fn tool_updates_existing_file() {
776 let tmp = TempDir::new().unwrap();
777 std::fs::write(tmp.path().join("hello.txt"), "before\n").unwrap();
778 let tool = ApplyPatch::new(tmp.path());
779 let result = tool
780 .execute(json!({"patch": "\
781*** Begin Patch
782*** Update File: hello.txt
783-before
784+after
785*** End Patch
786"}))
787 .await
788 .unwrap();
789 assert!(result.contains("wrote hello.txt"));
790 assert_eq!(
791 std::fs::read_to_string(tmp.path().join("hello.txt")).unwrap(),
792 "after\n"
793 );
794 }
795
796 #[tokio::test]
797 async fn tool_adds_new_file() {
798 let tmp = TempDir::new().unwrap();
799 let tool = ApplyPatch::new(tmp.path());
800 tool.execute(json!({"patch": "\
801*** Begin Patch
802*** Add File: sub/new.txt
803+hi
804+there
805*** End Patch
806"}))
807 .await
808 .unwrap();
809 assert_eq!(
810 std::fs::read_to_string(tmp.path().join("sub/new.txt")).unwrap(),
811 "hi\nthere"
812 );
813 }
814
815 #[tokio::test]
816 async fn tool_deletes_existing_file() {
817 let tmp = TempDir::new().unwrap();
818 std::fs::write(tmp.path().join("doomed.txt"), "x").unwrap();
819 ApplyPatch::new(tmp.path())
820 .execute(json!({"patch": "\
821*** Begin Patch
822*** Delete File: doomed.txt
823*** End Patch
824"}))
825 .await
826 .unwrap();
827 assert!(!tmp.path().join("doomed.txt").exists());
828 }
829
830 #[tokio::test]
831 async fn tool_is_atomic_on_failure() {
832 let tmp = TempDir::new().unwrap();
835 std::fs::write(tmp.path().join("a.txt"), "original-a\n").unwrap();
836 std::fs::write(tmp.path().join("b.txt"), "original-b\n").unwrap();
837 let err = ApplyPatch::new(tmp.path())
838 .execute(json!({"patch": "\
839*** Begin Patch
840*** Update File: a.txt
841-original-a
842+changed-a
843*** Update File: b.txt
844-nonexistent-line
845+changed-b
846*** End Patch
847"}))
848 .await
849 .unwrap_err();
850 assert!(matches!(err, Error::Tool { .. }));
851 assert_eq!(
852 std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
853 "original-a\n"
854 );
855 assert_eq!(
856 std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
857 "original-b\n"
858 );
859 }
860
861 #[tokio::test]
862 async fn tool_rejects_add_when_file_exists() {
863 let tmp = TempDir::new().unwrap();
864 std::fs::write(tmp.path().join("exists.txt"), "x").unwrap();
865 let err = ApplyPatch::new(tmp.path())
866 .execute(json!({"patch": "\
867*** Begin Patch
868*** Add File: exists.txt
869+content
870*** End Patch
871"}))
872 .await
873 .unwrap_err();
874 assert!(matches!(err, Error::Tool { .. }));
875 }
876
877 #[tokio::test]
878 async fn tool_rejects_delete_when_file_missing() {
879 let tmp = TempDir::new().unwrap();
880 let err = ApplyPatch::new(tmp.path())
881 .execute(json!({"patch": "\
882*** Begin Patch
883*** Delete File: ghost.txt
884*** End Patch
885"}))
886 .await
887 .unwrap_err();
888 assert!(matches!(err, Error::Tool { .. }));
889 }
890
891 #[tokio::test]
892 async fn tool_rejects_sandbox_escape() {
893 let tmp = TempDir::new().unwrap();
894 let err = ApplyPatch::new(tmp.path())
895 .execute(json!({"patch": "\
896*** Begin Patch
897*** Add File: ../escape.txt
898+evil
899*** End Patch
900"}))
901 .await
902 .unwrap_err();
903 assert!(matches!(err, Error::Tool { .. }));
906 assert!(!tmp.path().parent().unwrap().join("escape.txt").exists());
907 }
908
909 #[tokio::test]
910 async fn tool_round_trips_a_real_change() {
911 let tmp = TempDir::new().unwrap();
912 std::fs::write(
913 tmp.path().join("lib.rs"),
914 "\
915fn add(a: i32, b: i32) -> i32 {
916 a + b
917}
918
919fn sub(a: i32, b: i32) -> i32 {
920 a - b
921}
922",
923 )
924 .unwrap();
925 let tool = ApplyPatch::new(tmp.path());
926 tool.execute(json!({"patch": "\
927*** Begin Patch
928*** Update File: lib.rs
929@@ fn sub
930 fn sub(a: i32, b: i32) -> i32 {
931- a - b
932+ a.saturating_sub(b)
933 }
934*** End Patch
935"}))
936 .await
937 .unwrap();
938 let got = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
939 assert!(got.contains("a.saturating_sub(b)"));
940 assert!(got.contains("a + b"), "add() unchanged");
941 }
942}