1mod error;
2mod parser;
3mod seek_sequence;
4
5use std::collections::HashMap;
6use std::path::Path;
7use std::path::PathBuf;
8use std::str::Utf8Error;
9
10use error::PatchError;
11use error::Result;
12pub use parser::Hunk;
13pub use parser::ParseError;
14use parser::ParseError::*;
15use parser::UpdateFileChunk;
16pub use parser::parse_patch;
17use similar::TextDiff;
18use tree_sitter::LanguageError;
19use tree_sitter::Parser;
20use tree_sitter_bash::LANGUAGE as BASH;
21
22pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
24
25use error::ApplyPatchError;
27
28impl From<std::io::Error> for PatchError {
29 fn from(err: std::io::Error) -> Self {
30 PatchError::IoError {
31 context: "I/O error".to_string(),
32 source: err,
33 }
34 }
35}
36
37impl From<&std::io::Error> for ApplyPatchError {
38 fn from(err: &std::io::Error) -> Self {
39 ApplyPatchError::IoError {
40 context: "I/O error".to_string(),
41 source: std::io::Error::new(err.kind(), err.to_string()),
42 }
43 }
44}
45
46#[derive(Debug, PartialEq)]
47pub enum MaybeApplyPatch {
48 Body(ApplyPatchArgs),
49 ShellParseError(ExtractHeredocError),
50 PatchParseError(ParseError),
51 NotApplyPatch,
52}
53
54#[derive(Debug, PartialEq)]
57pub struct ApplyPatchArgs {
58 pub patch: String,
59 pub hunks: Vec<Hunk>,
60}
61
62pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
63 const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
64 match argv {
65 [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
66 Ok(source) => MaybeApplyPatch::Body(source),
67 Err(e) => MaybeApplyPatch::PatchParseError(e),
68 },
69 [bash, flag, script]
70 if bash == "bash"
71 && flag == "-lc"
72 && script.trim_start().starts_with("apply_patch") =>
73 {
74 match extract_heredoc_body_from_apply_patch_command(script) {
75 Ok(body) => match parse_patch(&body) {
76 Ok(source) => MaybeApplyPatch::Body(source),
77 Err(e) => MaybeApplyPatch::PatchParseError(e),
78 },
79 Err(e) => MaybeApplyPatch::ShellParseError(e),
80 }
81 }
82 _ => MaybeApplyPatch::NotApplyPatch,
83 }
84}
85
86#[derive(Debug, PartialEq)]
87pub enum ApplyPatchFileChange {
88 Add {
89 content: String,
90 },
91 Delete,
92 Update {
93 unified_diff: String,
94 move_path: Option<PathBuf>,
95 new_content: String,
97 },
98}
99
100#[derive(Debug, PartialEq)]
101pub enum MaybeApplyPatchVerified {
102 Body(ApplyPatchAction),
105 ShellParseError(ExtractHeredocError),
108 CorrectnessError(ApplyPatchError),
111 NotApplyPatch,
113}
114
115#[derive(Debug, PartialEq)]
118pub struct ApplyPatchAction {
119 changes: HashMap<PathBuf, ApplyPatchFileChange>,
120
121 pub patch: String,
125
126 pub cwd: PathBuf,
128}
129
130impl ApplyPatchAction {
131 pub fn is_empty(&self) -> bool {
132 self.changes.is_empty()
133 }
134
135 pub const fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
137 &self.changes
138 }
139
140 pub fn new_add_for_test(path: &Path, content: String) -> Self {
143 if !path.is_absolute() {
144 panic!("path must be absolute");
145 }
146
147 #[expect(clippy::expect_used)]
148 let filename = path
149 .file_name()
150 .expect("path should not be empty")
151 .to_string_lossy();
152 let patch = format!(
153 r#"*** Begin Patch
154*** Update File: {filename}
155@@
156+ {content}
157*** End Patch"#,
158 );
159 let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
160 #[expect(clippy::expect_used)]
161 Self {
162 changes,
163 cwd: path
164 .parent()
165 .expect("path should have parent")
166 .to_path_buf(),
167 patch,
168 }
169 }
170}
171
172pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
175 match maybe_parse_apply_patch(argv) {
176 MaybeApplyPatch::Body(ApplyPatchArgs { patch, hunks }) => {
177 let mut changes = HashMap::new();
178 for hunk in hunks {
179 let path = hunk.resolve_path(cwd);
180 match hunk {
181 Hunk::AddFile { contents, .. } => {
182 changes.insert(path, ApplyPatchFileChange::Add { content: contents });
183 }
184 Hunk::DeleteFile { .. } => {
185 changes.insert(path, ApplyPatchFileChange::Delete);
186 }
187 Hunk::UpdateFile {
188 move_path, chunks, ..
189 } => {
190 let ApplyPatchFileUpdate {
191 unified_diff,
192 content: contents,
193 } = match unified_diff_from_chunks(&path, &chunks) {
194 Ok(diff) => diff,
195 Err(e) => {
196 return MaybeApplyPatchVerified::CorrectnessError(e);
197 }
198 };
199 changes.insert(
200 path,
201 ApplyPatchFileChange::Update {
202 unified_diff,
203 move_path: move_path.map(|p| cwd.join(p)),
204 new_content: contents,
205 },
206 );
207 }
208 }
209 }
210 MaybeApplyPatchVerified::Body(ApplyPatchAction {
211 changes,
212 patch,
213 cwd: cwd.to_path_buf(),
214 })
215 }
216 MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
217 MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
218 MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
219 }
220}
221
222fn extract_heredoc_body_from_apply_patch_command(
241 src: &str,
242) -> std::result::Result<String, ExtractHeredocError> {
243 if !src.trim_start().starts_with("apply_patch") {
244 return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch);
245 }
246
247 let lang = BASH.into();
248 let mut parser = Parser::new();
249 parser
250 .set_language(&lang)
251 .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
252 let tree = parser
253 .parse(src, None)
254 .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
255
256 let bytes = src.as_bytes();
257 let mut c = tree.root_node().walk();
258
259 loop {
260 let node = c.node();
261 if node.kind() == "heredoc_body" {
262 let text = node
263 .utf8_text(bytes)
264 .map_err(ExtractHeredocError::HeredocNotUtf8)?;
265 return Ok(text.trim_end_matches('\n').to_owned());
266 }
267
268 if c.goto_first_child() {
269 continue;
270 }
271 while !c.goto_next_sibling() {
272 if !c.goto_parent() {
273 return Err(ExtractHeredocError::FailedToFindHeredocBody);
274 }
275 }
276 }
277}
278
279#[derive(Debug, PartialEq)]
280pub enum ExtractHeredocError {
281 CommandDidNotStartWithApplyPatch,
282 FailedToLoadBashGrammar(LanguageError),
283 HeredocNotUtf8(Utf8Error),
284 FailedToParsePatchIntoAst,
285 FailedToFindHeredocBody,
286}
287
288pub fn apply_patch(
290 patch: &str,
291 stdout: &mut impl std::io::Write,
292 stderr: &mut impl std::io::Write,
293) -> Result<()> {
294 let hunks = match parse_patch(patch) {
295 Ok(source) => source.hunks,
296 Err(e) => {
297 match &e {
298 InvalidPatchError(message) => {
299 writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?;
300 }
301 InvalidHunkError {
302 message,
303 line_number,
304 } => {
305 writeln!(
306 stderr,
307 "Invalid patch hunk on line {line_number}: {message}"
308 )
309 .map_err(ApplyPatchError::from)?;
310 }
311 }
312 return Err(ApplyPatchError::ParseError(e));
313 }
314 };
315
316 apply_hunks(&hunks, stdout, stderr)?;
317
318 Ok(())
319}
320
321pub fn apply_hunks(
323 hunks: &[Hunk],
324 stdout: &mut impl std::io::Write,
325 stderr: &mut impl std::io::Write,
326) -> Result<()> {
327 let _existing_paths: Vec<&Path> = hunks
328 .iter()
329 .filter_map(|hunk| match hunk {
330 Hunk::AddFile { .. } => {
331 None
333 }
334 Hunk::DeleteFile { path } => Some(path.as_path()),
335 Hunk::UpdateFile {
336 path, move_path, ..
337 } => match move_path {
338 Some(move_path) => {
339 if std::fs::metadata(move_path)
340 .map(|m| m.is_file())
341 .unwrap_or(false)
342 {
343 Some(move_path.as_path())
344 } else {
345 None
346 }
347 }
348 None => Some(path.as_path()),
349 },
350 })
351 .collect::<Vec<&Path>>();
352
353 match apply_hunks_to_files(hunks) {
355 Ok(affected) => {
356 print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
357 Ok(())
358 }
359 Err(err) => {
360 let msg = err.to_string();
361 writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
362 Err(ApplyPatchError::IoError {
364 context: msg.clone(),
365 source: std::io::Error::other(msg),
366 })
367 }
368 }
369}
370
371pub struct AffectedPaths {
375 pub added: Vec<PathBuf>,
376 pub modified: Vec<PathBuf>,
377 pub deleted: Vec<PathBuf>,
378}
379
380fn apply_hunks_to_files(hunks: &[Hunk]) -> Result<AffectedPaths> {
383 if hunks.is_empty() {
384 return Err(PatchError::NoFilesModified);
385 }
386
387 let mut added: Vec<PathBuf> = Vec::new();
388 let mut modified: Vec<PathBuf> = Vec::new();
389 let mut deleted: Vec<PathBuf> = Vec::new();
390 for hunk in hunks {
391 match hunk {
392 Hunk::AddFile { path, contents } => {
393 if let Some(parent) = path.parent()
394 && !parent.as_os_str().is_empty()
395 {
396 std::fs::create_dir_all(parent).map_err(|e| PatchError::IoError {
397 context: format!(
398 "Failed to create parent directories for {}",
399 path.display()
400 ),
401 source: e,
402 })?;
403 }
404 std::fs::write(path, contents).map_err(|e| PatchError::IoError {
405 context: format!("Failed to write file {}", path.display()),
406 source: e,
407 })?;
408 added.push(path.clone());
409 }
410 Hunk::DeleteFile { path } => {
411 std::fs::remove_file(path).map_err(|e| PatchError::IoError {
412 context: format!("Failed to delete file {}", path.display()),
413 source: e,
414 })?;
415 deleted.push(path.clone());
416 }
417 Hunk::UpdateFile {
418 path,
419 move_path,
420 chunks,
421 } => {
422 let AppliedPatch { new_contents, .. } =
423 derive_new_contents_from_chunks(path, chunks)?;
424 if let Some(dest) = move_path {
425 if let Some(parent) = dest.parent()
426 && !parent.as_os_str().is_empty()
427 {
428 std::fs::create_dir_all(parent).map_err(|e| PatchError::IoError {
429 context: format!(
430 "Failed to create parent directories for {}",
431 dest.display()
432 ),
433 source: e,
434 })?;
435 }
436 std::fs::write(dest, new_contents).map_err(|e| PatchError::IoError {
437 context: format!("Failed to write file {}", dest.display()),
438 source: e,
439 })?;
440 std::fs::remove_file(path).map_err(|e| PatchError::IoError {
441 context: format!("Failed to remove original {}", path.display()),
442 source: e,
443 })?;
444 modified.push(dest.clone());
445 } else {
446 std::fs::write(path, new_contents).map_err(|e| PatchError::IoError {
447 context: format!("Failed to write file {}", path.display()),
448 source: e,
449 })?;
450 modified.push(path.clone());
451 }
452 }
453 }
454 }
455 Ok(AffectedPaths {
456 added,
457 modified,
458 deleted,
459 })
460}
461
462struct AppliedPatch {
463 original_contents: String,
464 new_contents: String,
465}
466
467fn derive_new_contents_from_chunks(
470 path: &Path,
471 chunks: &[UpdateFileChunk],
472) -> Result<AppliedPatch> {
473 let original_contents = match std::fs::read_to_string(path) {
474 Ok(contents) => contents,
475 Err(err) => {
476 return Err(ApplyPatchError::IoError {
477 context: format!("Failed to read file to update {}", path.display()),
478 source: err,
479 });
480 }
481 };
482
483 let mut original_lines: Vec<String> = original_contents
484 .split('\n')
485 .map(|s| s.to_string())
486 .collect();
487
488 if original_lines.last().is_some_and(|s| s.is_empty()) {
491 original_lines.pop();
492 }
493
494 let replacements = compute_replacements(&original_lines, path, chunks)?;
495 let new_lines = apply_replacements(original_lines, &replacements);
496 let mut new_lines = new_lines;
497 if !new_lines.last().is_some_and(|s| s.is_empty()) {
498 new_lines.push(String::new());
499 }
500 let new_contents = new_lines.join("\n");
501 Ok(AppliedPatch {
502 original_contents,
503 new_contents,
504 })
505}
506
507fn compute_replacements(
511 original_lines: &[String],
512 path: &Path,
513 chunks: &[UpdateFileChunk],
514) -> Result<Vec<(usize, usize, Vec<String>)>> {
515 let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
516 let mut line_index: usize = 0;
517
518 for chunk in chunks {
519 if let Some(ctx_line) = &chunk.change_context {
522 if let Some(idx) = seek_sequence::seek_sequence(
523 original_lines,
524 std::slice::from_ref(ctx_line),
525 line_index,
526 false,
527 ) {
528 line_index = idx + 1;
529 } else {
530 return Err(ApplyPatchError::ComputeReplacements(format!(
531 "Failed to find context '{}' in {}",
532 ctx_line,
533 path.display()
534 )));
535 }
536 }
537
538 if chunk.old_lines.is_empty() {
539 let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
542 original_lines.len() - 1
543 } else {
544 original_lines.len()
545 };
546 replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
547 continue;
548 }
549
550 let mut pattern: &[String] = &chunk.old_lines;
562 let mut found =
563 seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
564
565 let mut new_slice: &[String] = &chunk.new_lines;
566
567 if found.is_none() && pattern.last().is_some_and(|s| s.is_empty()) {
568 pattern = &pattern[..pattern.len() - 1];
571 if new_slice.last().is_some_and(|s| s.is_empty()) {
572 new_slice = &new_slice[..new_slice.len() - 1];
573 }
574
575 found = seek_sequence::seek_sequence(
576 original_lines,
577 pattern,
578 line_index,
579 chunk.is_end_of_file,
580 );
581 }
582
583 if let Some(start_idx) = found {
584 replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
585 line_index = start_idx + pattern.len();
586 } else {
587 return Err(ApplyPatchError::ComputeReplacements(format!(
588 "Failed to find expected lines {:?} in {}",
589 chunk.old_lines,
590 path.display()
591 )));
592 }
593 }
594
595 Ok(replacements)
596}
597
598fn apply_replacements(
601 mut lines: Vec<String>,
602 replacements: &[(usize, usize, Vec<String>)],
603) -> Vec<String> {
604 for (start_idx, old_len, new_segment) in replacements.iter().rev() {
607 let start_idx = *start_idx;
608 let old_len = *old_len;
609
610 for _ in 0..old_len {
612 if start_idx < lines.len() {
613 lines.remove(start_idx);
614 }
615 }
616
617 for (offset, new_line) in new_segment.iter().enumerate() {
619 lines.insert(start_idx + offset, new_line.clone());
620 }
621 }
622
623 lines
624}
625
626#[derive(Debug, Eq, PartialEq)]
628pub struct ApplyPatchFileUpdate {
629 unified_diff: String,
630 content: String,
631}
632
633pub fn unified_diff_from_chunks(
634 path: &Path,
635 chunks: &[UpdateFileChunk],
636) -> Result<ApplyPatchFileUpdate> {
637 unified_diff_from_chunks_with_context(path, chunks, 1)
638}
639
640pub fn unified_diff_from_chunks_with_context(
641 path: &Path,
642 chunks: &[UpdateFileChunk],
643 context: usize,
644) -> Result<ApplyPatchFileUpdate> {
645 let AppliedPatch {
646 original_contents,
647 new_contents,
648 } = derive_new_contents_from_chunks(path, chunks)?;
649 let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
650 let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
651 Ok(ApplyPatchFileUpdate {
652 unified_diff,
653 content: new_contents,
654 })
655}
656
657pub fn print_summary(
660 affected: &AffectedPaths,
661 out: &mut impl std::io::Write,
662) -> std::io::Result<()> {
663 writeln!(out, "Success. Updated the following files:")?;
664 for path in &affected.added {
665 writeln!(out, "A {}", path.display())?;
666 }
667 for path in &affected.modified {
668 writeln!(out, "M {}", path.display())?;
669 }
670 for path in &affected.deleted {
671 writeln!(out, "D {}", path.display())?;
672 }
673 Ok(())
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use pretty_assertions::assert_eq;
680 use std::fs;
681 use tempfile::tempdir;
682
683 fn wrap_patch(body: &str) -> String {
685 format!("*** Begin Patch\n{body}\n*** End Patch")
686 }
687
688 fn strs_to_strings(strs: &[&str]) -> Vec<String> {
689 strs.iter().map(|s| (*s).to_string()).collect()
690 }
691
692 #[test]
693 fn test_literal() {
694 let args = strs_to_strings(&[
695 "apply_patch",
696 r#"*** Begin Patch
697*** Add File: foo
698+hi
699*** End Patch
700"#,
701 ]);
702
703 match maybe_parse_apply_patch(&args) {
704 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
705 assert_eq!(
706 hunks,
707 vec![Hunk::AddFile {
708 path: PathBuf::from("foo"),
709 contents: "hi\n".to_string()
710 }]
711 );
712 }
713 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
714 }
715 }
716
717 #[test]
718 fn test_literal_applypatch() {
719 let args = strs_to_strings(&[
720 "applypatch",
721 r#"*** Begin Patch
722*** Add File: foo
723+hi
724*** End Patch
725"#,
726 ]);
727
728 match maybe_parse_apply_patch(&args) {
729 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
730 assert_eq!(
731 hunks,
732 vec![Hunk::AddFile {
733 path: PathBuf::from("foo"),
734 contents: "hi\n".to_string()
735 }]
736 );
737 }
738 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
739 }
740 }
741
742 #[test]
743 fn test_heredoc() {
744 let args = strs_to_strings(&[
745 "bash",
746 "-lc",
747 r#"apply_patch <<'PATCH'
748*** Begin Patch
749*** Add File: foo
750+hi
751*** End Patch
752PATCH"#,
753 ]);
754
755 match maybe_parse_apply_patch(&args) {
756 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
757 assert_eq!(
758 hunks,
759 vec![Hunk::AddFile {
760 path: PathBuf::from("foo"),
761 contents: "hi\n".to_string()
762 }]
763 );
764 }
765 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
766 }
767 }
768
769 #[test]
770 fn test_add_file_hunk_creates_file_with_contents() {
771 let dir = tempdir().unwrap();
772 let path = dir.path().join("add.txt");
773 let patch = wrap_patch(&format!(
774 r#"*** Add File: {}
775+ab
776+cd"#,
777 path.display()
778 ));
779 let mut stdout = Vec::new();
780 let mut stderr = Vec::new();
781 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
782 let stdout_str = String::from_utf8(stdout).unwrap();
784 let stderr_str = String::from_utf8(stderr).unwrap();
785 let expected_out = format!(
786 "Success. Updated the following files:\nA {}\n",
787 path.display()
788 );
789 assert_eq!(stdout_str, expected_out);
790 assert_eq!(stderr_str, "");
791 let contents = fs::read_to_string(path).unwrap();
792 assert_eq!(contents, "ab\ncd\n");
793 }
794
795 #[test]
796 fn test_delete_file_hunk_removes_file() {
797 let dir = tempdir().unwrap();
798 let path = dir.path().join("del.txt");
799 fs::write(&path, "x").unwrap();
800 let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
801 let mut stdout = Vec::new();
802 let mut stderr = Vec::new();
803 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
804 let stdout_str = String::from_utf8(stdout).unwrap();
805 let stderr_str = String::from_utf8(stderr).unwrap();
806 let expected_out = format!(
807 "Success. Updated the following files:\nD {}\n",
808 path.display()
809 );
810 assert_eq!(stdout_str, expected_out);
811 assert_eq!(stderr_str, "");
812 assert!(!path.exists());
813 }
814
815 #[test]
816 fn test_update_file_hunk_modifies_content() {
817 let dir = tempdir().unwrap();
818 let path = dir.path().join("update.txt");
819 fs::write(&path, "foo\nbar\n").unwrap();
820 let patch = wrap_patch(&format!(
821 r#"*** Update File: {}
822@@
823 foo
824-bar
825+baz"#,
826 path.display()
827 ));
828 let mut stdout = Vec::new();
829 let mut stderr = Vec::new();
830 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
831 let stdout_str = String::from_utf8(stdout).unwrap();
833 let stderr_str = String::from_utf8(stderr).unwrap();
834 let expected_out = format!(
835 "Success. Updated the following files:\nM {}\n",
836 path.display()
837 );
838 assert_eq!(stdout_str, expected_out);
839 assert_eq!(stderr_str, "");
840 let contents = fs::read_to_string(&path).unwrap();
841 assert_eq!(contents, "foo\nbaz\n");
842 }
843
844 #[test]
845 fn test_update_file_hunk_can_move_file() {
846 let dir = tempdir().unwrap();
847 let src = dir.path().join("src.txt");
848 let dest = dir.path().join("dst.txt");
849 fs::write(&src, "line\n").unwrap();
850 let patch = wrap_patch(&format!(
851 r#"*** Update File: {}
852*** Move to: {}
853@@
854-line
855+line2"#,
856 src.display(),
857 dest.display()
858 ));
859 let mut stdout = Vec::new();
860 let mut stderr = Vec::new();
861 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
862 let stdout_str = String::from_utf8(stdout).unwrap();
864 let stderr_str = String::from_utf8(stderr).unwrap();
865 let expected_out = format!(
866 "Success. Updated the following files:\nM {}\n",
867 dest.display()
868 );
869 assert_eq!(stdout_str, expected_out);
870 assert_eq!(stderr_str, "");
871 assert!(!src.exists());
872 let contents = fs::read_to_string(&dest).unwrap();
873 assert_eq!(contents, "line2\n");
874 }
875
876 #[test]
879 fn test_multiple_update_chunks_apply_to_single_file() {
880 let dir = tempdir().unwrap();
882 let path = dir.path().join("multi.txt");
883 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
884 let patch = wrap_patch(&format!(
888 r#"*** Update File: {}
889@@
890 foo
891-bar
892+BAR
893@@
894 baz
895-qux
896+QUX"#,
897 path.display()
898 ));
899 let mut stdout = Vec::new();
900 let mut stderr = Vec::new();
901 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
902 let stdout_str = String::from_utf8(stdout).unwrap();
903 let stderr_str = String::from_utf8(stderr).unwrap();
904 let expected_out = format!(
905 "Success. Updated the following files:\nM {}\n",
906 path.display()
907 );
908 assert_eq!(stdout_str, expected_out);
909 assert_eq!(stderr_str, "");
910 let contents = fs::read_to_string(&path).unwrap();
911 assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
912 }
913
914 #[test]
919 fn test_update_file_hunk_interleaved_changes() {
920 let dir = tempdir().unwrap();
921 let path = dir.path().join("interleaved.txt");
922
923 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
925
926 let patch = wrap_patch(&format!(
931 r#"*** Update File: {}
932@@
933 a
934-b
935+B
936@@
937 c
938 d
939-e
940+E
941@@
942 f
943+g
944*** End of File"#,
945 path.display()
946 ));
947
948 let mut stdout = Vec::new();
949 let mut stderr = Vec::new();
950 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
951
952 let stdout_str = String::from_utf8(stdout).unwrap();
953 let stderr_str = String::from_utf8(stderr).unwrap();
954
955 let expected_out = format!(
956 "Success. Updated the following files:\nM {}\n",
957 path.display()
958 );
959 assert_eq!(stdout_str, expected_out);
960 assert_eq!(stderr_str, "");
961
962 let contents = fs::read_to_string(&path).unwrap();
963 assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
964 }
965
966 #[test]
973 fn test_update_line_with_unicode_dash() {
974 let dir = tempdir().unwrap();
975 let path = dir.path().join("unicode.py");
976
977 let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
979 std::fs::write(&path, original).unwrap();
980
981 let patch = wrap_patch(&format!(
983 r#"*** Update File: {}
984@@
985-import asyncio # local import - avoids top-level dep
986+import asyncio # HELLO"#,
987 path.display()
988 ));
989
990 let mut stdout = Vec::new();
991 let mut stderr = Vec::new();
992 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
993
994 let expected = "import asyncio # HELLO\n";
996 let contents = std::fs::read_to_string(&path).unwrap();
997 assert_eq!(contents, expected);
998
999 let stdout_str = String::from_utf8(stdout).unwrap();
1001 let expected_out = format!(
1002 "Success. Updated the following files:\nM {}\n",
1003 path.display()
1004 );
1005 assert_eq!(stdout_str, expected_out);
1006
1007 assert_eq!(String::from_utf8(stderr).unwrap(), "");
1009 }
1010
1011 #[test]
1012 fn test_unified_diff() {
1013 let dir = tempdir().unwrap();
1015 let path = dir.path().join("multi.txt");
1016 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1017 let patch = wrap_patch(&format!(
1018 r#"*** Update File: {}
1019@@
1020 foo
1021-bar
1022+BAR
1023@@
1024 baz
1025-qux
1026+QUX"#,
1027 path.display()
1028 ));
1029 let patch = parse_patch(&patch).unwrap();
1030
1031 let update_file_chunks = match patch.hunks.as_slice() {
1032 [Hunk::UpdateFile { chunks, .. }] => chunks,
1033 _ => panic!("Expected a single UpdateFile hunk"),
1034 };
1035 let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap();
1036 let expected_diff = r#"@@ -1,4 +1,4 @@
1037 foo
1038-bar
1039+BAR
1040 baz
1041-qux
1042+QUX
1043"#;
1044 let expected = ApplyPatchFileUpdate {
1045 unified_diff: expected_diff.to_string(),
1046 content: "foo\nBAR\nbaz\nQUX\n".to_string(),
1047 };
1048 assert_eq!(expected, diff);
1049 }
1050
1051 #[test]
1052 fn test_unified_diff_first_line_replacement() {
1053 let dir = tempdir().unwrap();
1055 let path = dir.path().join("first.txt");
1056 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1057
1058 let patch = wrap_patch(&format!(
1059 r#"*** Update File: {}
1060@@
1061-foo
1062+FOO
1063 bar
1064"#,
1065 path.display()
1066 ));
1067
1068 let patch = parse_patch(&patch).unwrap();
1069 let chunks = match patch.hunks.as_slice() {
1070 [Hunk::UpdateFile { chunks, .. }] => chunks,
1071 _ => panic!("Expected a single UpdateFile hunk"),
1072 };
1073
1074 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1075 let expected_diff = r#"@@ -1,2 +1,2 @@
1076-foo
1077+FOO
1078 bar
1079"#;
1080 let expected = ApplyPatchFileUpdate {
1081 unified_diff: expected_diff.to_string(),
1082 content: "FOO\nbar\nbaz\n".to_string(),
1083 };
1084 assert_eq!(expected, diff);
1085 }
1086
1087 #[test]
1088 fn test_unified_diff_last_line_replacement() {
1089 let dir = tempdir().unwrap();
1091 let path = dir.path().join("last.txt");
1092 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1093
1094 let patch = wrap_patch(&format!(
1095 r#"*** Update File: {}
1096@@
1097 foo
1098 bar
1099-baz
1100+BAZ
1101"#,
1102 path.display()
1103 ));
1104
1105 let patch = parse_patch(&patch).unwrap();
1106 let chunks = match patch.hunks.as_slice() {
1107 [Hunk::UpdateFile { chunks, .. }] => chunks,
1108 _ => panic!("Expected a single UpdateFile hunk"),
1109 };
1110
1111 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1112 let expected_diff = r#"@@ -2,2 +2,2 @@
1113 bar
1114-baz
1115+BAZ
1116"#;
1117 let expected = ApplyPatchFileUpdate {
1118 unified_diff: expected_diff.to_string(),
1119 content: "foo\nbar\nBAZ\n".to_string(),
1120 };
1121 assert_eq!(expected, diff);
1122 }
1123
1124 #[test]
1125 fn test_unified_diff_insert_at_eof() {
1126 let dir = tempdir().unwrap();
1128 let path = dir.path().join("insert.txt");
1129 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1130
1131 let patch = wrap_patch(&format!(
1132 r#"*** Update File: {}
1133@@
1134+quux
1135*** End of File
1136"#,
1137 path.display()
1138 ));
1139
1140 let patch = parse_patch(&patch).unwrap();
1141 let chunks = match patch.hunks.as_slice() {
1142 [Hunk::UpdateFile { chunks, .. }] => chunks,
1143 _ => panic!("Expected a single UpdateFile hunk"),
1144 };
1145
1146 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1147 let expected_diff = r#"@@ -3 +3,2 @@
1148 baz
1149+quux
1150"#;
1151 let expected = ApplyPatchFileUpdate {
1152 unified_diff: expected_diff.to_string(),
1153 content: "foo\nbar\nbaz\nquux\n".to_string(),
1154 };
1155 assert_eq!(expected, diff);
1156 }
1157
1158 #[test]
1159 fn test_unified_diff_interleaved_changes() {
1160 let dir = tempdir().unwrap();
1162 let path = dir.path().join("interleaved.txt");
1163 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1164
1165 let patch_body = format!(
1168 r#"*** Update File: {}
1169@@
1170 a
1171-b
1172+B
1173@@
1174 d
1175-e
1176+E
1177@@
1178 f
1179+g
1180*** End of File"#,
1181 path.display()
1182 );
1183 let patch = wrap_patch(&patch_body);
1184
1185 let parsed = parse_patch(&patch).unwrap();
1187 let chunks = match parsed.hunks.as_slice() {
1188 [Hunk::UpdateFile { chunks, .. }] => chunks,
1189 _ => panic!("Expected a single UpdateFile hunk"),
1190 };
1191
1192 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1193
1194 let expected_diff = r#"@@ -1,6 +1,7 @@
1195 a
1196-b
1197+B
1198 c
1199 d
1200-e
1201+E
1202 f
1203+g
1204"#;
1205
1206 let expected = ApplyPatchFileUpdate {
1207 unified_diff: expected_diff.to_string(),
1208 content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
1209 };
1210
1211 assert_eq!(expected, diff);
1212
1213 let mut stdout = Vec::new();
1214 let mut stderr = Vec::new();
1215 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1216 let contents = fs::read_to_string(path).unwrap();
1217 assert_eq!(
1218 contents,
1219 r#"a
1220B
1221c
1222d
1223E
1224f
1225g
1226"#
1227 );
1228 }
1229
1230 #[test]
1231 fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
1232 let session_dir = tempdir().unwrap();
1233 let relative_path = "source.txt";
1234
1235 let session_file_path = session_dir.path().join(relative_path);
1238 fs::write(&session_file_path, "session directory content\n").unwrap();
1239
1240 let argv = vec![
1241 "apply_patch".to_string(),
1242 r#"*** Begin Patch
1243*** Update File: source.txt
1244@@
1245-session directory content
1246+updated session directory content
1247*** End Patch"#
1248 .to_string(),
1249 ];
1250
1251 let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
1252
1253 assert_eq!(
1256 result,
1257 MaybeApplyPatchVerified::Body(ApplyPatchAction {
1258 changes: HashMap::from([(
1259 session_dir.path().join(relative_path),
1260 ApplyPatchFileChange::Update {
1261 unified_diff: r#"@@ -1 +1 @@
1262-session directory content
1263+updated session directory content
1264"#
1265 .to_string(),
1266 move_path: None,
1267 new_content: "updated session directory content\n".to_string(),
1268 },
1269 )]),
1270 patch: argv[1].clone(),
1271 cwd: session_dir.path().to_path_buf(),
1272 })
1273 );
1274 }
1275
1276 #[test]
1277 fn test_apply_patch_fails_on_write_error() {
1278 let dir = tempdir().unwrap();
1279 let path = dir.path().join("readonly.txt");
1280 fs::write(&path, "before\n").unwrap();
1281 let mut perms = fs::metadata(&path).unwrap().permissions();
1282 perms.set_readonly(true);
1283 fs::set_permissions(&path, perms).unwrap();
1284
1285 let patch = wrap_patch(&format!(
1286 "*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
1287 path.display()
1288 ));
1289
1290 let mut stdout = Vec::new();
1291 let mut stderr = Vec::new();
1292 let result = apply_patch(&patch, &mut stdout, &mut stderr);
1293 assert!(result.is_err());
1294 }
1295}