1mod parser;
2mod seek_sequence;
3mod standalone_executable;
4
5use std::collections::HashMap;
6use std::path::Path;
7use std::path::PathBuf;
8use std::str::Utf8Error;
9use std::sync::LazyLock;
10
11use anyhow::Context;
12use anyhow::Result;
13pub use parser::Hunk;
14pub use parser::ParseError;
15use parser::ParseError::*;
16use parser::UpdateFileChunk;
17pub use parser::parse_patch;
18use similar::TextDiff;
19use thiserror::Error;
20use tree_sitter::LanguageError;
21use tree_sitter::Parser;
22use tree_sitter::Query;
23use tree_sitter::QueryCursor;
24use tree_sitter::StreamingIterator;
25use tree_sitter_bash::LANGUAGE as BASH;
26
27pub use standalone_executable::main;
28
29#[allow(async_fn_in_trait)]
36pub trait FileSystem {
37 async fn read_text_file(&self, path: &Path) -> std::io::Result<String>;
38 async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()>;
39}
40
41pub struct StdFileSystem;
42
43impl FileSystem for StdFileSystem {
44 async fn read_text_file(&self, path: &Path) -> std::io::Result<String> {
45 std::fs::read_to_string(path)
46 }
47
48 async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> {
49 std::fs::write(path, contents)
50 }
51}
52
53pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
55
56const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum ApplyPatchShell {
60 Unix,
61 PowerShell,
62 Cmd,
63}
64
65fn classify_shell_name(shell: &str) -> Option<String> {
66 Path::new(shell)
67 .file_stem()
68 .and_then(|name| name.to_str())
69 .map(str::to_ascii_lowercase)
70}
71
72fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
73 classify_shell_name(shell).and_then(|name| match name.as_str() {
74 "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
75 "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
76 Some(ApplyPatchShell::PowerShell)
77 }
78 "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
79 _ => None,
80 })
81}
82
83fn can_skip_flag(shell: &str, flag: &str) -> bool {
84 classify_shell_name(shell).is_some_and(|name| {
85 matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
86 })
87}
88
89fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
90 match argv {
91 [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
92 let script = script.as_str();
93 (shell_type, script)
94 }),
95 [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
96 classify_shell(shell, flag).map(|shell_type| {
97 let script = script.as_str();
98 (shell_type, script)
99 })
100 }
101 _ => None,
102 }
103}
104
105fn extract_apply_patch_from_shell(
106 shell: ApplyPatchShell,
107 script: &str,
108) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
109 match shell {
110 ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
111 extract_apply_patch_from_bash(script)
112 }
113 }
114}
115
116#[derive(Debug, Error, PartialEq)]
117pub enum ApplyPatchError {
118 #[error(transparent)]
119 ParseError(#[from] ParseError),
120 #[error(transparent)]
121 IoError(#[from] IoError),
122 #[error("{0}")]
124 ComputeReplacements(String),
125 #[error(
127 "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
128 )]
129 ImplicitInvocation,
130}
131
132impl From<std::io::Error> for ApplyPatchError {
133 fn from(err: std::io::Error) -> Self {
134 ApplyPatchError::IoError(IoError {
135 context: "I/O error".to_string(),
136 source: err,
137 })
138 }
139}
140
141impl From<&std::io::Error> for ApplyPatchError {
142 fn from(err: &std::io::Error) -> Self {
143 ApplyPatchError::IoError(IoError {
144 context: "I/O error".to_string(),
145 source: std::io::Error::new(err.kind(), err.to_string()),
146 })
147 }
148}
149
150#[derive(Debug, Error)]
151#[error("{context}: {source}")]
152pub struct IoError {
153 context: String,
154 #[source]
155 source: std::io::Error,
156}
157
158impl PartialEq for IoError {
159 fn eq(&self, other: &Self) -> bool {
160 self.context == other.context && self.source.to_string() == other.source.to_string()
161 }
162}
163
164#[derive(Debug, PartialEq)]
165pub enum MaybeApplyPatch {
166 Body(ApplyPatchArgs),
167 ShellParseError(ExtractHeredocError),
168 PatchParseError(ParseError),
169 NotApplyPatch,
170}
171
172#[derive(Debug, PartialEq)]
175pub struct ApplyPatchArgs {
176 pub patch: String,
177 pub hunks: Vec<Hunk>,
178 pub workdir: Option<String>,
179}
180
181pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
182 match argv {
183 [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
185 Ok(source) => MaybeApplyPatch::Body(source),
186 Err(e) => MaybeApplyPatch::PatchParseError(e),
187 },
188 _ => match parse_shell_script(argv) {
190 Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
191 Ok((body, workdir)) => match parse_patch(&body) {
192 Ok(mut source) => {
193 source.workdir = workdir;
194 MaybeApplyPatch::Body(source)
195 }
196 Err(e) => MaybeApplyPatch::PatchParseError(e),
197 },
198 Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
199 MaybeApplyPatch::NotApplyPatch
200 }
201 Err(e) => MaybeApplyPatch::ShellParseError(e),
202 },
203 None => MaybeApplyPatch::NotApplyPatch,
204 },
205 }
206}
207
208#[derive(Debug, PartialEq)]
209pub enum ApplyPatchFileChange {
210 Add {
211 content: String,
212 },
213 Delete {
214 content: String,
215 },
216 Update {
217 unified_diff: String,
218 move_path: Option<PathBuf>,
219 new_content: String,
221 },
222}
223
224#[derive(Debug, PartialEq)]
225pub enum MaybeApplyPatchVerified {
226 Body(ApplyPatchAction),
229 ShellParseError(ExtractHeredocError),
232 CorrectnessError(ApplyPatchError),
235 NotApplyPatch,
237}
238
239#[derive(Debug, PartialEq)]
242pub struct ApplyPatchAction {
243 changes: HashMap<PathBuf, ApplyPatchFileChange>,
244
245 pub patch: String,
249
250 pub cwd: PathBuf,
252}
253
254impl ApplyPatchAction {
255 pub fn is_empty(&self) -> bool {
256 self.changes.is_empty()
257 }
258
259 pub fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
261 &self.changes
262 }
263
264 pub fn new_add_for_test(path: &Path, content: String) -> Self {
267 if !path.is_absolute() {
268 panic!("path must be absolute");
269 }
270
271 #[expect(clippy::expect_used)]
272 let filename = path
273 .file_name()
274 .expect("path should not be empty")
275 .to_string_lossy();
276 let patch = format!(
277 r#"*** Begin Patch
278*** Update File: {filename}
279@@
280+ {content}
281*** End Patch"#,
282 );
283 let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
284 #[expect(clippy::expect_used)]
285 Self {
286 changes,
287 cwd: path
288 .parent()
289 .expect("path should have parent")
290 .to_path_buf(),
291 patch,
292 }
293 }
294}
295
296pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
299 if argv.len() == 1 {
302 let body = &argv[0];
303 if parse_patch(body).is_ok() {
304 return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
305 }
306 }
307
308 if let Some((_, script)) = parse_shell_script(argv)
309 && parse_patch(script).is_ok()
310 {
311 return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
312 }
313
314 match maybe_parse_apply_patch(argv) {
315 MaybeApplyPatch::Body(ApplyPatchArgs {
316 patch,
317 hunks,
318 workdir,
319 }) => {
320 let effective_cwd = workdir
321 .as_ref()
322 .map(|dir| {
323 let path = Path::new(dir);
324 if path.is_absolute() {
325 path.to_path_buf()
326 } else {
327 cwd.join(path)
328 }
329 })
330 .unwrap_or_else(|| cwd.to_path_buf());
331 let mut changes = HashMap::new();
332 for hunk in hunks {
333 let path = hunk.resolve_path(&effective_cwd);
334 match hunk {
335 Hunk::AddFile { contents, .. } => {
336 changes.insert(path, ApplyPatchFileChange::Add { content: contents });
337 }
338 Hunk::DeleteFile { .. } => {
339 let content = match std::fs::read_to_string(&path) {
340 Ok(content) => content,
341 Err(e) => {
342 return MaybeApplyPatchVerified::CorrectnessError(
343 ApplyPatchError::IoError(IoError {
344 context: format!("Failed to read {}", path.display()),
345 source: e,
346 }),
347 );
348 }
349 };
350 changes.insert(path, ApplyPatchFileChange::Delete { content });
351 }
352 Hunk::UpdateFile {
353 move_path, chunks, ..
354 } => {
355 let ApplyPatchFileUpdate {
356 unified_diff,
357 content: contents,
358 } = match unified_diff_from_chunks(&path, &chunks) {
359 Ok(diff) => diff,
360 Err(e) => {
361 return MaybeApplyPatchVerified::CorrectnessError(e);
362 }
363 };
364 changes.insert(
365 path,
366 ApplyPatchFileChange::Update {
367 unified_diff,
368 move_path: move_path.map(|p| effective_cwd.join(p)),
369 new_content: contents,
370 },
371 );
372 }
373 }
374 }
375 MaybeApplyPatchVerified::Body(ApplyPatchAction {
376 changes,
377 patch,
378 cwd: effective_cwd,
379 })
380 }
381 MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
382 MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
383 MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
384 }
385}
386
387fn extract_apply_patch_from_bash(
408 src: &str,
409) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
410 static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
432 let language = BASH.into();
433 #[expect(clippy::expect_used)]
434 Query::new(
435 &language,
436 r#"
437 (
438 program
439 . (redirected_statement
440 body: (command
441 name: (command_name (word) @apply_name) .)
442 (#any-of? @apply_name "apply_patch" "applypatch")
443 redirect: (heredoc_redirect
444 . (heredoc_start)
445 . (heredoc_body) @heredoc
446 . (heredoc_end)
447 .))
448 .)
449
450 (
451 program
452 . (redirected_statement
453 body: (list
454 . (command
455 name: (command_name (word) @cd_name) .
456 argument: [
457 (word) @cd_path
458 (string (string_content) @cd_path)
459 (raw_string) @cd_raw_string
460 ] .)
461 "&&"
462 . (command
463 name: (command_name (word) @apply_name))
464 .)
465 (#eq? @cd_name "cd")
466 (#any-of? @apply_name "apply_patch" "applypatch")
467 redirect: (heredoc_redirect
468 . (heredoc_start)
469 . (heredoc_body) @heredoc
470 . (heredoc_end)
471 .))
472 .)
473 "#,
474 )
475 .expect("valid bash query")
476 });
477
478 let lang = BASH.into();
479 let mut parser = Parser::new();
480 parser
481 .set_language(&lang)
482 .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
483 let tree = parser
484 .parse(src, None)
485 .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
486
487 let bytes = src.as_bytes();
488 let root = tree.root_node();
489
490 let mut cursor = QueryCursor::new();
491 let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
492 while let Some(m) = matches.next() {
493 let mut heredoc_text: Option<String> = None;
494 let mut cd_path: Option<String> = None;
495
496 for capture in m.captures.iter() {
497 let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
498 match name {
499 "heredoc" => {
500 let text = capture
501 .node
502 .utf8_text(bytes)
503 .map_err(ExtractHeredocError::HeredocNotUtf8)?
504 .trim_end_matches('\n')
505 .to_string();
506 heredoc_text = Some(text);
507 }
508 "cd_path" => {
509 let text = capture
510 .node
511 .utf8_text(bytes)
512 .map_err(ExtractHeredocError::HeredocNotUtf8)?
513 .to_string();
514 cd_path = Some(text);
515 }
516 "cd_raw_string" => {
517 let raw = capture
518 .node
519 .utf8_text(bytes)
520 .map_err(ExtractHeredocError::HeredocNotUtf8)?;
521 let trimmed = raw
522 .strip_prefix('\'')
523 .and_then(|s| s.strip_suffix('\''))
524 .unwrap_or(raw);
525 cd_path = Some(trimmed.to_string());
526 }
527 _ => {}
528 }
529 }
530
531 if let Some(heredoc) = heredoc_text {
532 return Ok((heredoc, cd_path));
533 }
534 }
535
536 Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
537}
538
539#[derive(Debug, PartialEq)]
540pub enum ExtractHeredocError {
541 CommandDidNotStartWithApplyPatch,
542 FailedToLoadBashGrammar(LanguageError),
543 HeredocNotUtf8(Utf8Error),
544 FailedToParsePatchIntoAst,
545 FailedToFindHeredocBody,
546}
547
548pub fn apply_patch(
550 patch: &str,
551 stdout: &mut impl std::io::Write,
552 stderr: &mut impl std::io::Write,
553) -> Result<(), ApplyPatchError> {
554 let hunks = match parse_patch(patch) {
555 Ok(source) => source.hunks,
556 Err(e) => {
557 match &e {
558 InvalidPatchError(message) => {
559 writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?;
560 }
561 InvalidHunkError {
562 message,
563 line_number,
564 } => {
565 writeln!(
566 stderr,
567 "Invalid patch hunk on line {line_number}: {message}"
568 )
569 .map_err(ApplyPatchError::from)?;
570 }
571 }
572 return Err(ApplyPatchError::ParseError(e));
573 }
574 };
575
576 apply_hunks(&hunks, stdout, stderr)?;
577
578 Ok(())
579}
580
581pub fn apply_hunks(
583 hunks: &[Hunk],
584 stdout: &mut impl std::io::Write,
585 stderr: &mut impl std::io::Write,
586) -> Result<(), ApplyPatchError> {
587 let _existing_paths: Vec<&Path> = hunks
588 .iter()
589 .filter_map(|hunk| match hunk {
590 Hunk::AddFile { .. } => {
591 None
593 }
594 Hunk::DeleteFile { path } => Some(path.as_path()),
595 Hunk::UpdateFile {
596 path, move_path, ..
597 } => match move_path {
598 Some(move_path) => {
599 if std::fs::metadata(move_path)
600 .map(|m| m.is_file())
601 .unwrap_or(false)
602 {
603 Some(move_path.as_path())
604 } else {
605 None
606 }
607 }
608 None => Some(path.as_path()),
609 },
610 })
611 .collect::<Vec<&Path>>();
612
613 match apply_hunks_to_files(hunks) {
615 Ok(affected) => {
616 print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
617 Ok(())
618 }
619 Err(err) => {
620 let msg = err.to_string();
621 writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
622 if let Some(io) = err.downcast_ref::<std::io::Error>() {
623 Err(ApplyPatchError::from(io))
624 } else {
625 Err(ApplyPatchError::IoError(IoError {
626 context: msg,
627 source: std::io::Error::other(err),
628 }))
629 }
630 }
631 }
632}
633
634pub struct AffectedPaths {
638 pub added: Vec<PathBuf>,
639 pub modified: Vec<PathBuf>,
640 pub deleted: Vec<PathBuf>,
641}
642
643fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
646 if hunks.is_empty() {
647 anyhow::bail!("No files were modified.");
648 }
649
650 let mut added: Vec<PathBuf> = Vec::new();
651 let mut modified: Vec<PathBuf> = Vec::new();
652 let mut deleted: Vec<PathBuf> = Vec::new();
653 for hunk in hunks {
654 match hunk {
655 Hunk::AddFile { path, contents } => {
656 if let Some(parent) = path.parent()
657 && !parent.as_os_str().is_empty()
658 {
659 std::fs::create_dir_all(parent).with_context(|| {
660 format!("Failed to create parent directories for {}", path.display())
661 })?;
662 }
663 std::fs::write(path, contents)
664 .with_context(|| format!("Failed to write file {}", path.display()))?;
665 added.push(path.clone());
666 }
667 Hunk::DeleteFile { path } => {
668 std::fs::remove_file(path)
669 .with_context(|| format!("Failed to delete file {}", path.display()))?;
670 deleted.push(path.clone());
671 }
672 Hunk::UpdateFile {
673 path,
674 move_path,
675 chunks,
676 } => {
677 let AppliedPatch { new_contents, .. } =
678 derive_new_contents_from_chunks(path, chunks)?;
679 if let Some(dest) = move_path {
680 if let Some(parent) = dest.parent()
681 && !parent.as_os_str().is_empty()
682 {
683 std::fs::create_dir_all(parent).with_context(|| {
684 format!("Failed to create parent directories for {}", dest.display())
685 })?;
686 }
687 std::fs::write(dest, new_contents)
688 .with_context(|| format!("Failed to write file {}", dest.display()))?;
689 std::fs::remove_file(path)
690 .with_context(|| format!("Failed to remove original {}", path.display()))?;
691 modified.push(dest.clone());
692 } else {
693 std::fs::write(path, new_contents)
694 .with_context(|| format!("Failed to write file {}", path.display()))?;
695 modified.push(path.clone());
696 }
697 }
698 }
699 }
700 Ok(AffectedPaths {
701 added,
702 modified,
703 deleted,
704 })
705}
706
707struct AppliedPatch {
708 original_contents: String,
709 new_contents: String,
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713enum LineEnding {
714 Lf,
715 Crlf,
716}
717
718impl LineEnding {
719 fn detect(contents: &str) -> Self {
720 if contents.contains("\r\n") {
721 Self::Crlf
722 } else {
723 Self::Lf
724 }
725 }
726
727 fn joiner(self) -> &'static str {
728 match self {
729 Self::Lf => "\n",
730 Self::Crlf => "\r\n",
731 }
732 }
733}
734
735fn normalize_line_ending(line: &str) -> &str {
736 line.strip_suffix('\r').unwrap_or(line)
737}
738
739fn derive_new_contents_from_chunks(
742 path: &Path,
743 chunks: &[UpdateFileChunk],
744) -> std::result::Result<AppliedPatch, ApplyPatchError> {
745 let original_contents = match std::fs::read_to_string(path) {
746 Ok(contents) => contents,
747 Err(err) => {
748 return Err(ApplyPatchError::IoError(IoError {
749 context: format!("Failed to read file to update {}", path.display()),
750 source: err,
751 }));
752 }
753 };
754
755 let line_ending = LineEnding::detect(&original_contents);
756 let mut original_lines: Vec<String> = original_contents
757 .split('\n')
758 .map(|line| normalize_line_ending(line).to_string())
759 .collect();
760
761 if original_lines.last().is_some_and(String::is_empty) {
764 original_lines.pop();
765 }
766
767 let replacements = compute_replacements(&original_lines, path, chunks)?;
768 let new_lines = apply_replacements(original_lines, &replacements);
769 let mut new_lines = new_lines;
770 if !new_lines.last().is_some_and(String::is_empty) {
771 new_lines.push(String::new());
772 }
773 let new_contents = new_lines.join(line_ending.joiner());
774 Ok(AppliedPatch {
775 original_contents,
776 new_contents,
777 })
778}
779
780fn compute_replacements(
784 original_lines: &[String],
785 path: &Path,
786 chunks: &[UpdateFileChunk],
787) -> std::result::Result<Vec<(usize, usize, Vec<String>)>, ApplyPatchError> {
788 let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
789 let mut line_index: usize = 0;
790
791 for chunk in chunks {
792 if let Some(ctx_line) = &chunk.change_context {
795 if let Some(idx) = seek_sequence::seek_sequence(
796 original_lines,
797 std::slice::from_ref(ctx_line),
798 line_index,
799 false,
800 ) {
801 line_index = idx + 1;
802 } else {
803 return Err(ApplyPatchError::ComputeReplacements(format!(
804 "Failed to find context '{}' in {}",
805 ctx_line,
806 path.display()
807 )));
808 }
809 }
810
811 if chunk.old_lines.is_empty() {
812 let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
815 original_lines.len() - 1
816 } else {
817 original_lines.len()
818 };
819 let normalized_new_lines: Vec<String> = chunk
820 .new_lines
821 .iter()
822 .map(|line| normalize_line_ending(line).to_string())
823 .collect();
824 replacements.push((insertion_idx, 0, normalized_new_lines));
825 continue;
826 }
827
828 let normalized_old_lines: Vec<String> = chunk
840 .old_lines
841 .iter()
842 .map(|line| normalize_line_ending(line).to_string())
843 .collect();
844 let normalized_new_lines: Vec<String> = chunk
845 .new_lines
846 .iter()
847 .map(|line| normalize_line_ending(line).to_string())
848 .collect();
849
850 let mut pattern: &[String] = &normalized_old_lines;
851 let mut found =
852 seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
853
854 let mut new_slice: &[String] = &normalized_new_lines;
855
856 if found.is_none() && pattern.last().is_some_and(String::is_empty) {
857 pattern = &pattern[..pattern.len() - 1];
860 if new_slice.last().is_some_and(String::is_empty) {
861 new_slice = &new_slice[..new_slice.len() - 1];
862 }
863
864 found = seek_sequence::seek_sequence(
865 original_lines,
866 pattern,
867 line_index,
868 chunk.is_end_of_file,
869 );
870 }
871
872 if let Some(start_idx) = found {
873 replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
874 line_index = start_idx + pattern.len();
875 } else {
876 return Err(ApplyPatchError::ComputeReplacements(format!(
877 "Failed to find expected lines in {}:\n{}",
878 path.display(),
879 normalized_old_lines.join("\n"),
880 )));
881 }
882 }
883
884 replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx));
885
886 Ok(replacements)
887}
888
889fn apply_replacements(
892 mut lines: Vec<String>,
893 replacements: &[(usize, usize, Vec<String>)],
894) -> Vec<String> {
895 for (start_idx, old_len, new_segment) in replacements.iter().rev() {
898 let start_idx = *start_idx;
899 let old_len = *old_len;
900
901 for _ in 0..old_len {
903 if start_idx < lines.len() {
904 lines.remove(start_idx);
905 }
906 }
907
908 for (offset, new_line) in new_segment.iter().enumerate() {
910 lines.insert(start_idx + offset, new_line.clone());
911 }
912 }
913
914 lines
915}
916
917#[derive(Debug, Eq, PartialEq)]
919pub struct ApplyPatchFileUpdate {
920 unified_diff: String,
921 content: String,
922}
923
924pub fn unified_diff_from_chunks(
925 path: &Path,
926 chunks: &[UpdateFileChunk],
927) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
928 unified_diff_from_chunks_with_context(path, chunks, 1)
929}
930
931pub fn unified_diff_from_chunks_with_context(
932 path: &Path,
933 chunks: &[UpdateFileChunk],
934 context: usize,
935) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
936 let AppliedPatch {
937 original_contents,
938 new_contents,
939 } = derive_new_contents_from_chunks(path, chunks)?;
940 let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
941 let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
942 Ok(ApplyPatchFileUpdate {
943 unified_diff,
944 content: new_contents,
945 })
946}
947
948pub fn print_summary(
951 affected: &AffectedPaths,
952 out: &mut impl std::io::Write,
953) -> std::io::Result<()> {
954 writeln!(out, "Success. Updated the following files:")?;
955 for path in &affected.added {
956 writeln!(out, "A {}", path.display())?;
957 }
958 for path in &affected.modified {
959 writeln!(out, "M {}", path.display())?;
960 }
961 for path in &affected.deleted {
962 writeln!(out, "D {}", path.display())?;
963 }
964 Ok(())
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use pretty_assertions::assert_eq;
971 use std::fs;
972 use std::string::ToString;
973 use tempfile::tempdir;
974
975 fn wrap_patch(body: &str) -> String {
977 format!("*** Begin Patch\n{body}\n*** End Patch")
978 }
979
980 fn strs_to_strings(strs: &[&str]) -> Vec<String> {
981 strs.iter().map(ToString::to_string).collect()
982 }
983
984 fn args_bash(script: &str) -> Vec<String> {
986 strs_to_strings(&["bash", "-lc", script])
987 }
988
989 fn args_abs_bash(script: &str) -> Vec<String> {
990 strs_to_strings(&["/bin/bash", "-lc", script])
991 }
992
993 fn heredoc_script(prefix: &str) -> String {
994 format!(
995 "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
996 )
997 }
998
999 fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
1000 format!(
1001 "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
1002 )
1003 }
1004
1005 fn expected_single_add() -> Vec<Hunk> {
1006 vec![Hunk::AddFile {
1007 path: PathBuf::from("foo"),
1008 contents: "hi\n".to_string(),
1009 }]
1010 }
1011
1012 fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
1013 match maybe_parse_apply_patch(&args) {
1014 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
1015 assert_eq!(workdir.as_deref(), expected_workdir);
1016 assert_eq!(hunks, expected_single_add());
1017 }
1018 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1019 }
1020 }
1021
1022 fn assert_match(script: &str, expected_workdir: Option<&str>) {
1023 assert_match_args(args_bash(script), expected_workdir);
1024 assert_match_args(args_abs_bash(script), expected_workdir);
1025 }
1026
1027 fn assert_not_match_args(args: Vec<String>) {
1028 assert!(matches!(
1029 maybe_parse_apply_patch(&args),
1030 MaybeApplyPatch::NotApplyPatch
1031 ));
1032 }
1033
1034 fn assert_not_match(script: &str) {
1035 assert_not_match_args(args_bash(script));
1036 assert_not_match_args(args_abs_bash(script));
1037 }
1038
1039 #[test]
1040 fn test_implicit_patch_single_arg_is_error() {
1041 let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
1042 let args = vec![patch];
1043 let dir = tempdir().unwrap();
1044 assert!(matches!(
1045 maybe_parse_apply_patch_verified(&args, dir.path()),
1046 MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
1047 ));
1048 }
1049
1050 #[test]
1051 fn test_implicit_patch_bash_script_is_error() {
1052 let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
1053 let dir = tempdir().unwrap();
1054 for args in [args_bash(script), args_abs_bash(script)] {
1055 assert!(matches!(
1056 maybe_parse_apply_patch_verified(&args, dir.path()),
1057 MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
1058 ));
1059 }
1060 }
1061
1062 #[test]
1063 fn test_literal() {
1064 let args = strs_to_strings(&[
1065 "apply_patch",
1066 r#"*** Begin Patch
1067*** Add File: foo
1068+hi
1069*** End Patch
1070"#,
1071 ]);
1072
1073 match maybe_parse_apply_patch(&args) {
1074 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
1075 assert_eq!(
1076 hunks,
1077 vec![Hunk::AddFile {
1078 path: PathBuf::from("foo"),
1079 contents: "hi\n".to_string()
1080 }]
1081 );
1082 }
1083 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1084 }
1085 }
1086
1087 #[test]
1088 fn test_literal_applypatch() {
1089 let args = strs_to_strings(&[
1090 "applypatch",
1091 r#"*** Begin Patch
1092*** Add File: foo
1093+hi
1094*** End Patch
1095"#,
1096 ]);
1097
1098 match maybe_parse_apply_patch(&args) {
1099 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
1100 assert_eq!(
1101 hunks,
1102 vec![Hunk::AddFile {
1103 path: PathBuf::from("foo"),
1104 contents: "hi\n".to_string()
1105 }]
1106 );
1107 }
1108 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1109 }
1110 }
1111
1112 #[test]
1113 fn test_heredoc() {
1114 assert_match(&heredoc_script(""), None);
1115 }
1116
1117 #[test]
1118 fn test_heredoc_applypatch() {
1119 for args in [
1120 strs_to_strings(&[
1121 "bash",
1122 "-lc",
1123 r#"applypatch <<'PATCH'
1124*** Begin Patch
1125*** Add File: foo
1126+hi
1127*** End Patch
1128PATCH"#,
1129 ]),
1130 strs_to_strings(&[
1131 "/bin/bash",
1132 "-lc",
1133 r#"applypatch <<'PATCH'
1134*** Begin Patch
1135*** Add File: foo
1136+hi
1137*** End Patch
1138PATCH"#,
1139 ]),
1140 ] {
1141 match maybe_parse_apply_patch(&args) {
1142 MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
1143 assert_eq!(workdir, None);
1144 assert_eq!(
1145 hunks,
1146 vec![Hunk::AddFile {
1147 path: PathBuf::from("foo"),
1148 contents: "hi\n".to_string()
1149 }]
1150 );
1151 }
1152 result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1153 }
1154 }
1155 }
1156
1157 #[test]
1158 fn test_heredoc_with_leading_cd() {
1159 assert_match(&heredoc_script("cd foo && "), Some("foo"));
1160 }
1161
1162 #[test]
1163 fn test_cd_with_semicolon_is_ignored() {
1164 assert_not_match(&heredoc_script("cd foo; "));
1165 }
1166
1167 #[test]
1168 fn test_cd_or_apply_patch_is_ignored() {
1169 assert_not_match(&heredoc_script("cd bar || "));
1170 }
1171
1172 #[test]
1173 fn test_cd_pipe_apply_patch_is_ignored() {
1174 assert_not_match(&heredoc_script("cd bar | "));
1175 }
1176
1177 #[test]
1178 fn test_cd_single_quoted_path_with_spaces() {
1179 assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
1180 }
1181
1182 #[test]
1183 fn test_cd_double_quoted_path_with_spaces() {
1184 assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
1185 }
1186
1187 #[test]
1188 fn test_echo_and_apply_patch_is_ignored() {
1189 assert_not_match(&heredoc_script("echo foo && "));
1190 }
1191
1192 #[test]
1193 fn test_apply_patch_with_arg_is_ignored() {
1194 let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
1195 assert_not_match(script);
1196 }
1197
1198 #[test]
1199 fn test_double_cd_then_apply_patch_is_ignored() {
1200 assert_not_match(&heredoc_script("cd foo && cd bar && "));
1201 }
1202
1203 #[test]
1204 fn test_cd_two_args_is_ignored() {
1205 assert_not_match(&heredoc_script("cd foo bar && "));
1206 }
1207
1208 #[test]
1209 fn test_cd_then_apply_patch_then_extra_is_ignored() {
1210 let script = heredoc_script_ps("cd bar && ", " && echo done");
1211 assert_not_match(&script);
1212 }
1213
1214 #[test]
1215 fn test_echo_then_cd_and_apply_patch_is_ignored() {
1216 assert_not_match(&heredoc_script("echo foo; cd bar && "));
1218 }
1219
1220 #[test]
1221 fn test_add_file_hunk_creates_file_with_contents() {
1222 let dir = tempdir().unwrap();
1223 let path = dir.path().join("add.txt");
1224 let patch = wrap_patch(&format!(
1225 r#"*** Add File: {}
1226+ab
1227+cd"#,
1228 path.display()
1229 ));
1230 let mut stdout = Vec::new();
1231 let mut stderr = Vec::new();
1232 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1233 let stdout_str = String::from_utf8(stdout).unwrap();
1235 let stderr_str = String::from_utf8(stderr).unwrap();
1236 let expected_out = format!(
1237 "Success. Updated the following files:\nA {}\n",
1238 path.display()
1239 );
1240 assert_eq!(stdout_str, expected_out);
1241 assert_eq!(stderr_str, "");
1242 let contents = fs::read_to_string(path).unwrap();
1243 assert_eq!(contents, "ab\ncd\n");
1244 }
1245
1246 #[test]
1247 fn test_delete_file_hunk_removes_file() {
1248 let dir = tempdir().unwrap();
1249 let path = dir.path().join("del.txt");
1250 fs::write(&path, "x").unwrap();
1251 let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
1252 let mut stdout = Vec::new();
1253 let mut stderr = Vec::new();
1254 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1255 let stdout_str = String::from_utf8(stdout).unwrap();
1256 let stderr_str = String::from_utf8(stderr).unwrap();
1257 let expected_out = format!(
1258 "Success. Updated the following files:\nD {}\n",
1259 path.display()
1260 );
1261 assert_eq!(stdout_str, expected_out);
1262 assert_eq!(stderr_str, "");
1263 assert!(!path.exists());
1264 }
1265
1266 #[test]
1267 fn test_update_file_hunk_modifies_content() {
1268 let dir = tempdir().unwrap();
1269 let path = dir.path().join("update.txt");
1270 fs::write(&path, "foo\nbar\n").unwrap();
1271 let patch = wrap_patch(&format!(
1272 r#"*** Update File: {}
1273@@
1274 foo
1275-bar
1276+baz"#,
1277 path.display()
1278 ));
1279 let mut stdout = Vec::new();
1280 let mut stderr = Vec::new();
1281 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1282 let stdout_str = String::from_utf8(stdout).unwrap();
1284 let stderr_str = String::from_utf8(stderr).unwrap();
1285 let expected_out = format!(
1286 "Success. Updated the following files:\nM {}\n",
1287 path.display()
1288 );
1289 assert_eq!(stdout_str, expected_out);
1290 assert_eq!(stderr_str, "");
1291 let contents = fs::read_to_string(&path).unwrap();
1292 assert_eq!(contents, "foo\nbaz\n");
1293 }
1294
1295 #[test]
1296 fn test_update_file_hunk_preserves_crlf_line_endings() {
1297 let dir = tempdir().unwrap();
1298 let path = dir.path().join("update_crlf.txt");
1299 fs::write(&path, "foo\r\nbar\r\n").unwrap();
1300
1301 let patch = wrap_patch(&format!(
1302 r#"*** Update File: {}
1303@@
1304 foo
1305-bar
1306+baz
1307"#,
1308 path.display()
1309 ));
1310
1311 let mut stdout = Vec::new();
1312 let mut stderr = Vec::new();
1313 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1314
1315 let bytes = fs::read(&path).unwrap();
1316 assert!(bytes.ends_with(b"\r\n"), "file should end with CRLF");
1317 for (idx, byte) in bytes.iter().enumerate() {
1318 if *byte == b'\n' {
1319 assert!(
1320 idx > 0 && bytes[idx - 1] == b'\r',
1321 "found bare LF at index {idx}"
1322 );
1323 }
1324 }
1325 assert_eq!(String::from_utf8_lossy(&bytes), "foo\r\nbaz\r\n");
1326 }
1327
1328 #[test]
1329 fn test_update_file_hunk_can_move_file() {
1330 let dir = tempdir().unwrap();
1331 let src = dir.path().join("src.txt");
1332 let dest = dir.path().join("dst.txt");
1333 fs::write(&src, "line\n").unwrap();
1334 let patch = wrap_patch(&format!(
1335 r#"*** Update File: {}
1336*** Move to: {}
1337@@
1338-line
1339+line2"#,
1340 src.display(),
1341 dest.display()
1342 ));
1343 let mut stdout = Vec::new();
1344 let mut stderr = Vec::new();
1345 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1346 let stdout_str = String::from_utf8(stdout).unwrap();
1348 let stderr_str = String::from_utf8(stderr).unwrap();
1349 let expected_out = format!(
1350 "Success. Updated the following files:\nM {}\n",
1351 dest.display()
1352 );
1353 assert_eq!(stdout_str, expected_out);
1354 assert_eq!(stderr_str, "");
1355 assert!(!src.exists());
1356 let contents = fs::read_to_string(&dest).unwrap();
1357 assert_eq!(contents, "line2\n");
1358 }
1359
1360 #[test]
1363 fn test_multiple_update_chunks_apply_to_single_file() {
1364 let dir = tempdir().unwrap();
1366 let path = dir.path().join("multi.txt");
1367 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1368 let patch = wrap_patch(&format!(
1372 r#"*** Update File: {}
1373@@
1374 foo
1375-bar
1376+BAR
1377@@
1378 baz
1379-qux
1380+QUX"#,
1381 path.display()
1382 ));
1383 let mut stdout = Vec::new();
1384 let mut stderr = Vec::new();
1385 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1386 let stdout_str = String::from_utf8(stdout).unwrap();
1387 let stderr_str = String::from_utf8(stderr).unwrap();
1388 let expected_out = format!(
1389 "Success. Updated the following files:\nM {}\n",
1390 path.display()
1391 );
1392 assert_eq!(stdout_str, expected_out);
1393 assert_eq!(stderr_str, "");
1394 let contents = fs::read_to_string(&path).unwrap();
1395 assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
1396 }
1397
1398 #[test]
1403 fn test_update_file_hunk_interleaved_changes() {
1404 let dir = tempdir().unwrap();
1405 let path = dir.path().join("interleaved.txt");
1406
1407 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1409
1410 let patch = wrap_patch(&format!(
1415 r#"*** Update File: {}
1416@@
1417 a
1418-b
1419+B
1420@@
1421 c
1422 d
1423-e
1424+E
1425@@
1426 f
1427+g
1428*** End of File"#,
1429 path.display()
1430 ));
1431
1432 let mut stdout = Vec::new();
1433 let mut stderr = Vec::new();
1434 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1435
1436 let stdout_str = String::from_utf8(stdout).unwrap();
1437 let stderr_str = String::from_utf8(stderr).unwrap();
1438
1439 let expected_out = format!(
1440 "Success. Updated the following files:\nM {}\n",
1441 path.display()
1442 );
1443 assert_eq!(stdout_str, expected_out);
1444 assert_eq!(stderr_str, "");
1445
1446 let contents = fs::read_to_string(&path).unwrap();
1447 assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
1448 }
1449
1450 #[test]
1451 fn test_pure_addition_chunk_followed_by_removal() {
1452 let dir = tempdir().unwrap();
1453 let path = dir.path().join("panic.txt");
1454 fs::write(&path, "line1\nline2\nline3\n").unwrap();
1455 let patch = wrap_patch(&format!(
1456 r#"*** Update File: {}
1457@@
1458+after-context
1459+second-line
1460@@
1461 line1
1462-line2
1463-line3
1464+line2-replacement"#,
1465 path.display()
1466 ));
1467 let mut stdout = Vec::new();
1468 let mut stderr = Vec::new();
1469 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1470 let contents = fs::read_to_string(path).unwrap();
1471 assert_eq!(
1472 contents,
1473 "line1\nline2-replacement\nafter-context\nsecond-line\n"
1474 );
1475 }
1476
1477 #[test]
1484 fn test_update_line_with_unicode_dash() {
1485 let dir = tempdir().unwrap();
1486 let path = dir.path().join("unicode.py");
1487
1488 let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
1490 std::fs::write(&path, original).unwrap();
1491
1492 let patch = wrap_patch(&format!(
1494 r#"*** Update File: {}
1495@@
1496-import asyncio # local import - avoids top-level dep
1497+import asyncio # HELLO"#,
1498 path.display()
1499 ));
1500
1501 let mut stdout = Vec::new();
1502 let mut stderr = Vec::new();
1503 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1504
1505 let expected = "import asyncio # HELLO\n";
1507 let contents = std::fs::read_to_string(&path).unwrap();
1508 assert_eq!(contents, expected);
1509
1510 let stdout_str = String::from_utf8(stdout).unwrap();
1512 let expected_out = format!(
1513 "Success. Updated the following files:\nM {}\n",
1514 path.display()
1515 );
1516 assert_eq!(stdout_str, expected_out);
1517
1518 assert_eq!(String::from_utf8(stderr).unwrap(), "");
1520 }
1521
1522 #[test]
1523 fn test_unified_diff() {
1524 let dir = tempdir().unwrap();
1526 let path = dir.path().join("multi.txt");
1527 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1528 let patch = wrap_patch(&format!(
1529 r#"*** Update File: {}
1530@@
1531 foo
1532-bar
1533+BAR
1534@@
1535 baz
1536-qux
1537+QUX"#,
1538 path.display()
1539 ));
1540 let patch = parse_patch(&patch).unwrap();
1541
1542 let update_file_chunks = match patch.hunks.as_slice() {
1543 [Hunk::UpdateFile { chunks, .. }] => chunks,
1544 _ => panic!("Expected a single UpdateFile hunk"),
1545 };
1546 let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap();
1547 let expected_diff = r#"@@ -1,4 +1,4 @@
1548 foo
1549-bar
1550+BAR
1551 baz
1552-qux
1553+QUX
1554"#;
1555 let expected = ApplyPatchFileUpdate {
1556 unified_diff: expected_diff.to_string(),
1557 content: "foo\nBAR\nbaz\nQUX\n".to_string(),
1558 };
1559 assert_eq!(expected, diff);
1560 }
1561
1562 #[test]
1563 fn test_unified_diff_first_line_replacement() {
1564 let dir = tempdir().unwrap();
1566 let path = dir.path().join("first.txt");
1567 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1568
1569 let patch = wrap_patch(&format!(
1570 r#"*** Update File: {}
1571@@
1572-foo
1573+FOO
1574 bar
1575"#,
1576 path.display()
1577 ));
1578
1579 let patch = parse_patch(&patch).unwrap();
1580 let chunks = match patch.hunks.as_slice() {
1581 [Hunk::UpdateFile { chunks, .. }] => chunks,
1582 _ => panic!("Expected a single UpdateFile hunk"),
1583 };
1584
1585 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1586 let expected_diff = r#"@@ -1,2 +1,2 @@
1587-foo
1588+FOO
1589 bar
1590"#;
1591 let expected = ApplyPatchFileUpdate {
1592 unified_diff: expected_diff.to_string(),
1593 content: "FOO\nbar\nbaz\n".to_string(),
1594 };
1595 assert_eq!(expected, diff);
1596 }
1597
1598 #[test]
1599 fn test_unified_diff_last_line_replacement() {
1600 let dir = tempdir().unwrap();
1602 let path = dir.path().join("last.txt");
1603 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1604
1605 let patch = wrap_patch(&format!(
1606 r#"*** Update File: {}
1607@@
1608 foo
1609 bar
1610-baz
1611+BAZ
1612"#,
1613 path.display()
1614 ));
1615
1616 let patch = parse_patch(&patch).unwrap();
1617 let chunks = match patch.hunks.as_slice() {
1618 [Hunk::UpdateFile { chunks, .. }] => chunks,
1619 _ => panic!("Expected a single UpdateFile hunk"),
1620 };
1621
1622 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1623 let expected_diff = r#"@@ -2,2 +2,2 @@
1624 bar
1625-baz
1626+BAZ
1627"#;
1628 let expected = ApplyPatchFileUpdate {
1629 unified_diff: expected_diff.to_string(),
1630 content: "foo\nbar\nBAZ\n".to_string(),
1631 };
1632 assert_eq!(expected, diff);
1633 }
1634
1635 #[test]
1636 fn test_unified_diff_insert_at_eof() {
1637 let dir = tempdir().unwrap();
1639 let path = dir.path().join("insert.txt");
1640 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1641
1642 let patch = wrap_patch(&format!(
1643 r#"*** Update File: {}
1644@@
1645+quux
1646*** End of File
1647"#,
1648 path.display()
1649 ));
1650
1651 let patch = parse_patch(&patch).unwrap();
1652 let chunks = match patch.hunks.as_slice() {
1653 [Hunk::UpdateFile { chunks, .. }] => chunks,
1654 _ => panic!("Expected a single UpdateFile hunk"),
1655 };
1656
1657 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1658 let expected_diff = r#"@@ -3 +3,2 @@
1659 baz
1660+quux
1661"#;
1662 let expected = ApplyPatchFileUpdate {
1663 unified_diff: expected_diff.to_string(),
1664 content: "foo\nbar\nbaz\nquux\n".to_string(),
1665 };
1666 assert_eq!(expected, diff);
1667 }
1668
1669 #[test]
1670 fn test_unified_diff_interleaved_changes() {
1671 let dir = tempdir().unwrap();
1673 let path = dir.path().join("interleaved.txt");
1674 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1675
1676 let patch_body = format!(
1679 r#"*** Update File: {}
1680@@
1681 a
1682-b
1683+B
1684@@
1685 d
1686-e
1687+E
1688@@
1689 f
1690+g
1691*** End of File"#,
1692 path.display()
1693 );
1694 let patch = wrap_patch(&patch_body);
1695
1696 let parsed = parse_patch(&patch).unwrap();
1698 let chunks = match parsed.hunks.as_slice() {
1699 [Hunk::UpdateFile { chunks, .. }] => chunks,
1700 _ => panic!("Expected a single UpdateFile hunk"),
1701 };
1702
1703 let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1704
1705 let expected_diff = r#"@@ -1,6 +1,7 @@
1706 a
1707-b
1708+B
1709 c
1710 d
1711-e
1712+E
1713 f
1714+g
1715"#;
1716
1717 let expected = ApplyPatchFileUpdate {
1718 unified_diff: expected_diff.to_string(),
1719 content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
1720 };
1721
1722 assert_eq!(expected, diff);
1723
1724 let mut stdout = Vec::new();
1725 let mut stderr = Vec::new();
1726 apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1727 let contents = fs::read_to_string(path).unwrap();
1728 assert_eq!(
1729 contents,
1730 r#"a
1731B
1732c
1733d
1734E
1735f
1736g
1737"#
1738 );
1739 }
1740
1741 #[test]
1742 fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
1743 let session_dir = tempdir().unwrap();
1744 let relative_path = "source.txt";
1745
1746 let session_file_path = session_dir.path().join(relative_path);
1749 fs::write(&session_file_path, "session directory content\n").unwrap();
1750
1751 let argv = vec![
1752 "apply_patch".to_string(),
1753 r#"*** Begin Patch
1754*** Update File: source.txt
1755@@
1756-session directory content
1757+updated session directory content
1758*** End Patch"#
1759 .to_string(),
1760 ];
1761
1762 let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
1763
1764 assert_eq!(
1767 result,
1768 MaybeApplyPatchVerified::Body(ApplyPatchAction {
1769 changes: HashMap::from([(
1770 session_dir.path().join(relative_path),
1771 ApplyPatchFileChange::Update {
1772 unified_diff: r#"@@ -1 +1 @@
1773-session directory content
1774+updated session directory content
1775"#
1776 .to_string(),
1777 move_path: None,
1778 new_content: "updated session directory content\n".to_string(),
1779 },
1780 )]),
1781 patch: argv[1].clone(),
1782 cwd: session_dir.path().to_path_buf(),
1783 })
1784 );
1785 }
1786
1787 #[test]
1788 fn test_apply_patch_fails_on_write_error() {
1789 let dir = tempdir().unwrap();
1790 let path = dir.path().join("readonly.txt");
1791 fs::write(&path, "before\n").unwrap();
1792 let mut perms = fs::metadata(&path).unwrap().permissions();
1793 perms.set_readonly(true);
1794 fs::set_permissions(&path, perms).unwrap();
1795
1796 let patch = wrap_patch(&format!(
1797 "*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
1798 path.display()
1799 ));
1800
1801 let mut stdout = Vec::new();
1802 let mut stderr = Vec::new();
1803 let result = apply_patch(&patch, &mut stdout, &mut stderr);
1804 assert!(result.is_err());
1805 }
1806}