1mod invocation;
2mod parser;
3mod seek_sequence;
4mod standalone_executable;
5mod streaming_parser;
6
7use std::collections::HashMap;
8use std::io;
9use std::path::PathBuf;
10
11use anyhow::Context;
12use anyhow::Result;
13use codex_exec_server::CreateDirectoryOptions;
14use codex_exec_server::ExecutorFileSystem;
15use codex_exec_server::FileSystemSandboxContext;
16use codex_exec_server::RemoveOptions;
17use codex_utils_path_uri::PathUri;
18use codex_utils_path_uri::PathUriParseError;
19pub use parser::Hunk;
20pub use parser::ParseError;
21use parser::ParseError::*;
22pub use parser::UpdateFileChunk;
23pub use parser::parse_patch;
24use similar::TextDiff;
25pub use streaming_parser::StreamingPatchParser;
26use thiserror::Error;
27
28pub use invocation::maybe_parse_apply_patch_verified;
29pub use invocation::verify_apply_patch_args;
30pub use standalone_executable::main;
31
32use crate::invocation::ExtractHeredocError;
33
34pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
42
43#[derive(Debug, Error, PartialEq)]
44pub enum ApplyPatchError {
45 #[error(transparent)]
46 ParseError(#[from] ParseError),
47 #[error(transparent)]
48 IoError(#[from] IoError),
49 #[error("{0}")]
51 ComputeReplacements(String),
52 #[error(transparent)]
54 PathUri(#[from] PathUriParseError),
55 #[error(
57 "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
58 )]
59 ImplicitInvocation,
60}
61
62impl From<std::io::Error> for ApplyPatchError {
63 fn from(err: std::io::Error) -> Self {
64 ApplyPatchError::IoError(IoError {
65 context: "I/O error".to_string(),
66 source: err,
67 })
68 }
69}
70
71impl From<&std::io::Error> for ApplyPatchError {
72 fn from(err: &std::io::Error) -> Self {
73 ApplyPatchError::IoError(IoError {
74 context: "I/O error".to_string(),
75 source: std::io::Error::new(err.kind(), err.to_string()),
76 })
77 }
78}
79
80#[derive(Debug, Error)]
81#[error("{context}: {source}")]
82pub struct IoError {
83 context: String,
84 #[source]
85 source: std::io::Error,
86}
87
88impl PartialEq for IoError {
89 fn eq(&self, other: &Self) -> bool {
90 self.context == other.context && self.source.to_string() == other.source.to_string()
91 }
92}
93
94#[derive(Debug, PartialEq)]
97pub struct ApplyPatchArgs {
98 pub patch: String,
99 pub hunks: Vec<Hunk>,
100 pub workdir: Option<String>,
101 pub environment_id: Option<String>,
102}
103
104#[derive(Debug, PartialEq)]
105pub enum ApplyPatchFileChange {
106 Add {
107 content: String,
108 },
109 Delete {
110 content: String,
111 },
112 Update {
113 unified_diff: String,
114 move_path: Option<PathUri>,
115 new_content: String,
117 },
118}
119
120#[derive(Debug, PartialEq)]
121pub enum MaybeApplyPatchVerified {
122 Body(ApplyPatchAction),
125 ShellParseError(ExtractHeredocError),
128 CorrectnessError(ApplyPatchError),
131 NotApplyPatch,
133}
134
135#[derive(Debug, PartialEq)]
138pub struct ApplyPatchAction {
139 changes: HashMap<PathUri, ApplyPatchFileChange>,
140
141 pub patch: String,
145
146 pub cwd: PathUri,
148}
149
150impl ApplyPatchAction {
151 pub fn is_empty(&self) -> bool {
152 self.changes.is_empty()
153 }
154
155 pub fn changes(&self) -> &HashMap<PathUri, ApplyPatchFileChange> {
157 &self.changes
158 }
159
160 pub fn new_add_for_test(path: &PathUri, content: String) -> Self {
163 #[expect(clippy::expect_used)]
164 let filename = path.basename().expect("path should not be empty");
165 let patch = format!(
166 r#"*** Begin Patch
167*** Update File: {filename}
168@@
169+ {content}
170*** End Patch"#,
171 );
172 let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]);
173 #[expect(clippy::expect_used)]
174 Self {
175 changes,
176 cwd: path.parent().expect("path should have parent"),
177 patch,
178 }
179 }
180}
181
182#[derive(Clone, Debug, PartialEq)]
184pub struct AppliedPatchDelta {
185 changes: Vec<AppliedPatchChange>,
186 exact: bool,
187}
188
189impl AppliedPatchDelta {
190 fn new(changes: Vec<AppliedPatchChange>, exact: bool) -> Self {
191 Self { changes, exact }
192 }
193
194 fn empty() -> Self {
195 Self::new(Vec::new(), true)
196 }
197
198 pub fn changes(&self) -> &[AppliedPatchChange] {
199 &self.changes
200 }
201
202 pub fn is_empty(&self) -> bool {
203 self.changes.is_empty()
204 }
205
206 pub fn is_exact(&self) -> bool {
207 self.exact
208 }
209
210 pub fn append(&mut self, other: Self) {
212 self.changes.extend(other.changes);
213 self.exact &= other.exact;
214 }
215}
216
217impl Default for AppliedPatchDelta {
218 fn default() -> Self {
219 Self::empty()
220 }
221}
222
223#[derive(Clone, Debug, PartialEq)]
225pub struct AppliedPatchChange {
226 pub path: PathBuf,
227 pub change: AppliedPatchFileChange,
228}
229
230#[derive(Clone, Debug, PartialEq)]
231pub enum AppliedPatchFileChange {
232 Add {
233 content: String,
234 overwritten_content: Option<String>,
235 },
236 Delete {
237 content: String,
238 },
239 Update {
240 move_path: Option<PathBuf>,
241 old_content: String,
242 overwritten_move_content: Option<String>,
243 new_content: String,
244 },
245}
246
247#[derive(Debug, Error)]
250#[error("{error}")]
251pub struct ApplyPatchFailure {
252 #[source]
253 error: ApplyPatchError,
254 delta: AppliedPatchDelta,
255}
256
257impl ApplyPatchFailure {
258 fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self {
259 Self { error, delta }
260 }
261
262 fn without_delta(error: ApplyPatchError) -> Self {
263 Self::new(error, AppliedPatchDelta::empty())
264 }
265
266 pub fn delta(&self) -> &AppliedPatchDelta {
267 &self.delta
268 }
269
270 pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) {
271 (self.error, self.delta)
272 }
273}
274
275pub async fn apply_patch(
277 patch: &str,
278 cwd: &PathUri,
279 stdout: &mut impl std::io::Write,
280 stderr: &mut impl std::io::Write,
281 fs: &dyn ExecutorFileSystem,
282 sandbox: Option<&FileSystemSandboxContext>,
283) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
284 let hunks = match parse_patch(patch) {
285 Ok(source) => source.hunks,
286 Err(e) => {
287 match &e {
288 InvalidPatchError(message) => {
289 writeln!(stderr, "Invalid patch: {message}")
290 .map_err(ApplyPatchError::from)
291 .map_err(ApplyPatchFailure::without_delta)?;
292 }
293 InvalidHunkError {
294 message,
295 line_number,
296 } => {
297 writeln!(
298 stderr,
299 "Invalid patch hunk on line {line_number}: {message}"
300 )
301 .map_err(ApplyPatchError::from)
302 .map_err(ApplyPatchFailure::without_delta)?;
303 }
304 }
305 return Err(ApplyPatchFailure::without_delta(
306 ApplyPatchError::ParseError(e),
307 ));
308 }
309 };
310
311 apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await
312}
313
314pub async fn apply_hunks(
316 hunks: &[Hunk],
317 cwd: &PathUri,
318 stdout: &mut impl std::io::Write,
319 stderr: &mut impl std::io::Write,
320 fs: &dyn ExecutorFileSystem,
321 sandbox: Option<&FileSystemSandboxContext>,
322) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
323 let mut delta = AppliedPatchDelta::empty();
324 match apply_hunks_to_files(hunks, cwd, fs, sandbox, &mut delta).await {
325 Ok(affected_paths) => {
326 print_summary(&affected_paths, stdout).map_err(|error| {
327 ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
328 })?;
329 Ok(delta)
330 }
331 Err(error) => {
332 let msg = error.to_string();
333 writeln!(stderr, "{msg}").map_err(|error| {
334 ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone())
335 })?;
336 let error = if let Some(io) = error.downcast_ref::<std::io::Error>() {
337 ApplyPatchError::from(io)
338 } else {
339 ApplyPatchError::IoError(IoError {
340 context: msg,
341 source: std::io::Error::other(error),
342 })
343 };
344 Err(ApplyPatchFailure::new(error, delta))
345 }
346 }
347}
348
349pub struct AffectedPaths {
354 pub added: Vec<PathBuf>,
355 pub modified: Vec<PathBuf>,
356 pub deleted: Vec<PathBuf>,
357}
358
359async fn apply_hunks_to_files(
362 hunks: &[Hunk],
363 cwd: &PathUri,
364 fs: &dyn ExecutorFileSystem,
365 sandbox: Option<&FileSystemSandboxContext>,
366 delta: &mut AppliedPatchDelta,
367) -> anyhow::Result<AffectedPaths> {
368 if hunks.is_empty() {
369 anyhow::bail!("No files were modified.");
370 }
371
372 let mut added: Vec<PathBuf> = Vec::new();
373 let mut modified: Vec<PathBuf> = Vec::new();
374 let mut deleted: Vec<PathBuf> = Vec::new();
375 macro_rules! try_write {
379 ($result:expr) => {
380 match $result {
381 Ok(value) => value,
382 Err(error) => {
383 delta.exact = false;
384 return Err(anyhow::Error::from(error));
385 }
386 }
387 };
388 }
389
390 for hunk in hunks {
392 let affected_path = hunk.path().to_path_buf();
393 let path_uri = hunk.resolve_path(cwd)?;
394 match hunk {
395 Hunk::AddFile { contents, .. } => {
396 let overwritten_content =
397 read_optional_file_text_for_delta(&path_uri, fs, sandbox, &mut delta.exact)
398 .await;
399 try_write!(
400 write_file_with_missing_parent_retry(
401 fs,
402 &path_uri,
403 contents.clone().into_bytes(),
404 sandbox,
405 )
406 .await
407 );
408 delta.changes.push(AppliedPatchChange {
409 path: path_uri.to_path_buf(),
410 change: AppliedPatchFileChange::Add {
411 content: contents.clone(),
412 overwritten_content,
413 },
414 });
415 added.push(affected_path);
416 }
417 Hunk::DeleteFile { .. } => {
418 note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await;
419 let deleted_content = fs.read_file_text(&path_uri, sandbox).await.ok();
420 if deleted_content.is_none() {
421 delta.exact = false;
422 }
423 ensure_not_directory(&path_uri, fs, sandbox)
424 .await
425 .with_context(|| {
426 format!(
427 "Failed to delete file {}",
428 path_uri.inferred_native_path_string()
429 )
430 })?;
431 if let Err(error) = fs
432 .remove(
433 &path_uri,
434 RemoveOptions {
435 recursive: false,
436 force: false,
437 },
438 sandbox,
439 )
440 .await
441 .with_context(|| {
442 format!(
443 "Failed to delete file {}",
444 path_uri.inferred_native_path_string()
445 )
446 })
447 {
448 delta.exact &= remove_failure_was_side_effect_free(
449 &path_uri,
450 deleted_content.as_deref(),
451 fs,
452 sandbox,
453 )
454 .await;
455 return Err(error);
456 }
457 if let Some(content) = deleted_content {
458 delta.changes.push(AppliedPatchChange {
459 path: path_uri.to_path_buf(),
460 change: AppliedPatchFileChange::Delete { content },
461 });
462 }
463 deleted.push(affected_path);
464 }
465 Hunk::UpdateFile {
466 move_path, chunks, ..
467 } => {
468 note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await;
469 let AppliedPatch {
470 original_contents,
471 new_contents,
472 } = derive_new_contents_from_chunks(&path_uri, chunks, fs, sandbox).await?;
473 if let Some(dest) = move_path {
474 let dest_uri = cwd.join(&dest.to_string_lossy())?;
475 let overwritten_move_content =
476 read_optional_file_text_for_delta(&dest_uri, fs, sandbox, &mut delta.exact)
477 .await;
478 try_write!(
479 write_file_with_missing_parent_retry(
480 fs,
481 &dest_uri,
482 new_contents.clone().into_bytes(),
483 sandbox,
484 )
485 .await
486 );
487 let dest_write_change_index = delta.changes.len();
488 delta.changes.push(AppliedPatchChange {
489 path: dest_uri.to_path_buf(),
490 change: AppliedPatchFileChange::Add {
491 content: new_contents.clone(),
492 overwritten_content: overwritten_move_content.clone(),
493 },
494 });
495 ensure_not_directory(&path_uri, fs, sandbox)
496 .await
497 .with_context(|| {
498 format!(
499 "Failed to remove original {}",
500 path_uri.inferred_native_path_string()
501 )
502 })?;
503 if let Err(error) = fs
504 .remove(
505 &path_uri,
506 RemoveOptions {
507 recursive: false,
508 force: false,
509 },
510 sandbox,
511 )
512 .await
513 .with_context(|| {
514 format!(
515 "Failed to remove original {}",
516 path_uri.inferred_native_path_string()
517 )
518 })
519 {
520 delta.exact &= remove_failure_was_side_effect_free(
521 &path_uri,
522 Some(&original_contents),
523 fs,
524 sandbox,
525 )
526 .await;
527 return Err(error);
528 }
529 delta.changes[dest_write_change_index] = AppliedPatchChange {
530 path: path_uri.to_path_buf(),
531 change: AppliedPatchFileChange::Update {
532 move_path: Some(dest_uri.to_path_buf()),
533 old_content: original_contents,
534 overwritten_move_content,
535 new_content: new_contents,
536 },
537 };
538 modified.push(affected_path);
539 } else {
540 try_write!(
541 fs.write_file(&path_uri, new_contents.clone().into_bytes(), sandbox)
542 .await
543 .with_context(|| format!(
544 "Failed to write file {}",
545 path_uri.inferred_native_path_string()
546 ))
547 );
548 delta.changes.push(AppliedPatchChange {
549 path: path_uri.to_path_buf(),
550 change: AppliedPatchFileChange::Update {
551 move_path: None,
552 old_content: original_contents,
553 overwritten_move_content: None,
554 new_content: new_contents,
555 },
556 });
557 modified.push(affected_path);
558 }
559 }
560 }
561 }
562 Ok(AffectedPaths {
563 added,
564 modified,
565 deleted,
566 })
567}
568
569async fn ensure_not_directory(
570 path: &PathUri,
571 fs: &dyn ExecutorFileSystem,
572 sandbox: Option<&FileSystemSandboxContext>,
573) -> io::Result<()> {
574 let metadata = fs.get_metadata(path, sandbox).await?;
575 if metadata.is_directory {
576 return Err(io::Error::new(
577 io::ErrorKind::InvalidInput,
578 "path is a directory",
579 ));
580 }
581 Ok(())
582}
583
584async fn remove_failure_was_side_effect_free(
585 path: &PathUri,
586 expected_content: Option<&str>,
587 fs: &dyn ExecutorFileSystem,
588 sandbox: Option<&FileSystemSandboxContext>,
589) -> bool {
590 match expected_content {
591 Some(expected_content) => fs
592 .read_file_text(path, sandbox)
593 .await
594 .is_ok_and(|content| content == expected_content),
595 None => false,
596 }
597}
598
599async fn read_optional_file_text_for_delta(
600 path: &PathUri,
601 fs: &dyn ExecutorFileSystem,
602 sandbox: Option<&FileSystemSandboxContext>,
603 exact: &mut bool,
604) -> Option<String> {
605 note_existing_path_delta_support(path, fs, sandbox, exact).await;
606 match fs.read_file_text(path, sandbox).await {
607 Ok(content) => Some(content),
608 Err(source) if source.kind() == io::ErrorKind::NotFound => None,
609 Err(_) => {
610 *exact = false;
611 None
612 }
613 }
614}
615
616async fn note_existing_path_delta_support(
617 path: &PathUri,
618 fs: &dyn ExecutorFileSystem,
619 sandbox: Option<&FileSystemSandboxContext>,
620 exact: &mut bool,
621) {
622 match fs.get_metadata(path, sandbox).await {
623 Ok(metadata) if metadata.is_file && !metadata.is_symlink => {}
624 Ok(_) => *exact = false,
625 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
626 Err(_) => *exact = false,
627 }
628}
629
630async fn write_file_with_missing_parent_retry(
631 fs: &dyn ExecutorFileSystem,
632 path: &PathUri,
633 contents: Vec<u8>,
634 sandbox: Option<&FileSystemSandboxContext>,
635) -> anyhow::Result<()> {
636 match fs.write_file(path, contents.clone(), sandbox).await {
637 Ok(()) => Ok(()),
638 Err(err) if err.kind() == io::ErrorKind::NotFound => {
639 if let Some(parent) = path.parent() {
640 fs.create_directory(&parent, CreateDirectoryOptions { recursive: true }, sandbox)
641 .await
642 .with_context(|| {
643 format!(
644 "Failed to create parent directories for {}",
645 path.inferred_native_path_string()
646 )
647 })?;
648 }
649 fs.write_file(path, contents, sandbox)
650 .await
651 .with_context(|| {
652 format!(
653 "Failed to write file {}",
654 path.inferred_native_path_string()
655 )
656 })?;
657 Ok(())
658 }
659 Err(err) => Err(err).with_context(|| {
660 format!(
661 "Failed to write file {}",
662 path.inferred_native_path_string()
663 )
664 }),
665 }
666}
667
668struct AppliedPatch {
669 original_contents: String,
670 new_contents: String,
671}
672
673async fn derive_new_contents_from_chunks(
676 path: &PathUri,
677 chunks: &[UpdateFileChunk],
678 fs: &dyn ExecutorFileSystem,
679 sandbox: Option<&FileSystemSandboxContext>,
680) -> std::result::Result<AppliedPatch, ApplyPatchError> {
681 let original_contents = fs.read_file_text(path, sandbox).await.map_err(|err| {
682 ApplyPatchError::IoError(IoError {
683 context: format!(
684 "Failed to read file to update {}",
685 path.inferred_native_path_string()
686 ),
687 source: err,
688 })
689 })?;
690
691 let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
692
693 if original_lines.last().is_some_and(String::is_empty) {
696 original_lines.pop();
697 }
698
699 let path_text = path.inferred_native_path_string();
700 let replacements = compute_replacements(&original_lines, &path_text, chunks)?;
701 let new_lines = apply_replacements(original_lines, &replacements);
702 let mut new_lines = new_lines;
703 if !new_lines.last().is_some_and(String::is_empty) {
704 new_lines.push(String::new());
705 }
706 let new_contents = new_lines.join("\n");
707 Ok(AppliedPatch {
708 original_contents,
709 new_contents,
710 })
711}
712
713fn compute_replacements(
717 original_lines: &[String],
718 path: &str,
719 chunks: &[UpdateFileChunk],
720) -> std::result::Result<Vec<(usize, usize, Vec<String>)>, ApplyPatchError> {
721 let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
722 let mut line_index: usize = 0;
723
724 for chunk in chunks {
725 if let Some(ctx_line) = &chunk.change_context {
728 if let Some(idx) = seek_sequence::seek_sequence(
729 original_lines,
730 std::slice::from_ref(ctx_line),
731 line_index,
732 false,
733 ) {
734 line_index = idx + 1;
735 } else {
736 return Err(ApplyPatchError::ComputeReplacements(format!(
737 "Failed to find context '{ctx_line}' in {path}"
738 )));
739 }
740 }
741
742 if chunk.old_lines.is_empty() {
743 let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
746 original_lines.len() - 1
747 } else {
748 original_lines.len()
749 };
750 replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
751 continue;
752 }
753
754 let mut pattern: &[String] = &chunk.old_lines;
766 let mut found =
767 seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
768
769 let mut new_slice: &[String] = &chunk.new_lines;
770
771 if found.is_none() && pattern.last().is_some_and(String::is_empty) {
772 pattern = &pattern[..pattern.len() - 1];
775 if new_slice.last().is_some_and(String::is_empty) {
776 new_slice = &new_slice[..new_slice.len() - 1];
777 }
778
779 found = seek_sequence::seek_sequence(
780 original_lines,
781 pattern,
782 line_index,
783 chunk.is_end_of_file,
784 );
785 }
786
787 if let Some(start_idx) = found {
788 replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
789 line_index = start_idx + pattern.len();
790 } else {
791 return Err(ApplyPatchError::ComputeReplacements(format!(
792 "Failed to find expected lines in {}:\n{}",
793 path,
794 chunk.old_lines.join("\n"),
795 )));
796 }
797 }
798
799 replacements.sort_by_key(|(index, _, _)| *index);
800
801 Ok(replacements)
802}
803
804fn apply_replacements(
807 mut lines: Vec<String>,
808 replacements: &[(usize, usize, Vec<String>)],
809) -> Vec<String> {
810 for (start_idx, old_len, new_segment) in replacements.iter().rev() {
813 let start_idx = *start_idx;
814 let old_len = *old_len;
815
816 for _ in 0..old_len {
818 if start_idx < lines.len() {
819 lines.remove(start_idx);
820 }
821 }
822
823 for (offset, new_line) in new_segment.iter().enumerate() {
825 lines.insert(start_idx + offset, new_line.clone());
826 }
827 }
828
829 lines
830}
831
832#[derive(Debug, Eq, PartialEq)]
834pub struct ApplyPatchFileUpdate {
835 unified_diff: String,
836 original_content: String,
837 content: String,
838}
839
840pub async fn unified_diff_from_chunks(
841 path: &PathUri,
842 chunks: &[UpdateFileChunk],
843 fs: &dyn ExecutorFileSystem,
844 sandbox: Option<&FileSystemSandboxContext>,
845) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
846 unified_diff_from_chunks_with_context(path, chunks, 1, fs, sandbox).await
847}
848
849pub async fn unified_diff_from_chunks_with_context(
850 path: &PathUri,
851 chunks: &[UpdateFileChunk],
852 context: usize,
853 fs: &dyn ExecutorFileSystem,
854 sandbox: Option<&FileSystemSandboxContext>,
855) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
856 let AppliedPatch {
857 original_contents,
858 new_contents,
859 } = derive_new_contents_from_chunks(path, chunks, fs, sandbox).await?;
860 let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
861 let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
862 Ok(ApplyPatchFileUpdate {
863 unified_diff,
864 original_content: original_contents,
865 content: new_contents,
866 })
867}
868
869pub fn print_summary(
872 affected: &AffectedPaths,
873 out: &mut impl std::io::Write,
874) -> std::io::Result<()> {
875 writeln!(out, "Success. Updated the following files:")?;
876 for path in &affected.added {
877 writeln!(out, "A {}", path.display())?;
878 }
879 for path in &affected.modified {
880 writeln!(out, "M {}", path.display())?;
881 }
882 for path in &affected.deleted {
883 writeln!(out, "D {}", path.display())?;
884 }
885 Ok(())
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use codex_exec_server::LOCAL_FS;
892 use pretty_assertions::assert_eq;
893 use std::fs;
894 use std::string::ToString;
895 use tempfile::tempdir;
896
897 fn wrap_patch(body: &str) -> String {
899 format!("*** Begin Patch\n{body}\n*** End Patch")
900 }
901
902 #[tokio::test]
903 async fn test_add_file_hunk_creates_file_with_contents() {
904 let dir = tempdir().unwrap();
905 let path = dir.path().join("add.txt");
906 let patch = wrap_patch(&format!(
907 r#"*** Add File: {}
908+ab
909+cd"#,
910 path.display()
911 ));
912 let mut stdout = Vec::new();
913 let mut stderr = Vec::new();
914 apply_patch(
915 &patch,
916 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
917 &mut stdout,
918 &mut stderr,
919 LOCAL_FS.as_ref(),
920 None,
921 )
922 .await
923 .unwrap();
924 let stdout_str = String::from_utf8(stdout).unwrap();
926 let stderr_str = String::from_utf8(stderr).unwrap();
927 let expected_out = format!(
928 "Success. Updated the following files:\nA {}\n",
929 path.display()
930 );
931 assert_eq!(stdout_str, expected_out);
932 assert_eq!(stderr_str, "");
933 let contents = fs::read_to_string(path).unwrap();
934 assert_eq!(contents, "ab\ncd\n");
935 }
936
937 #[tokio::test]
938 async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() {
939 let dir = tempdir().unwrap();
940 let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
941 let relative_add = dir.path().join("relative-add.txt");
942 let absolute_add = dir.path().join("absolute-add.txt");
943 let relative_delete = dir.path().join("relative-delete.txt");
944 let absolute_delete = dir.path().join("absolute-delete.txt");
945 let relative_update = dir.path().join("relative-update.txt");
946 let absolute_update = dir.path().join("absolute-update.txt");
947 fs::write(&relative_delete, "delete relative\n").unwrap();
948 fs::write(&absolute_delete, "delete absolute\n").unwrap();
949 fs::write(&relative_update, "relative old\n").unwrap();
950 fs::write(&absolute_update, "absolute old\n").unwrap();
951
952 let patch = wrap_patch(&format!(
953 r#"*** Add File: relative-add.txt
954+relative add
955*** Add File: {}
956+absolute add
957*** Delete File: relative-delete.txt
958*** Delete File: {}
959*** Update File: relative-update.txt
960@@
961-relative old
962+relative new
963*** Update File: {}
964@@
965-absolute old
966+absolute new"#,
967 absolute_add.display(),
968 absolute_delete.display(),
969 absolute_update.display(),
970 ));
971 let mut stdout = Vec::new();
972 let mut stderr = Vec::new();
973
974 apply_patch(
975 &patch,
976 &cwd,
977 &mut stdout,
978 &mut stderr,
979 LOCAL_FS.as_ref(),
980 None,
981 )
982 .await
983 .unwrap();
984
985 assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
986 assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
987 assert!(!relative_delete.exists());
988 assert!(!absolute_delete.exists());
989 assert_eq!(
990 fs::read_to_string(&relative_update).unwrap(),
991 "relative new\n"
992 );
993 assert_eq!(
994 fs::read_to_string(&absolute_update).unwrap(),
995 "absolute new\n"
996 );
997 assert_eq!(String::from_utf8(stderr).unwrap(), "");
998 assert_eq!(
999 String::from_utf8(stdout).unwrap(),
1000 format!(
1001 "Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n",
1002 absolute_add.display(),
1003 absolute_update.display(),
1004 absolute_delete.display(),
1005 )
1006 );
1007 }
1008
1009 #[tokio::test]
1010 async fn test_delete_file_hunk_removes_file() {
1011 let dir = tempdir().unwrap();
1012 let path = dir.path().join("del.txt");
1013 fs::write(&path, "x").unwrap();
1014 let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
1015 let mut stdout = Vec::new();
1016 let mut stderr = Vec::new();
1017 apply_patch(
1018 &patch,
1019 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1020 &mut stdout,
1021 &mut stderr,
1022 LOCAL_FS.as_ref(),
1023 None,
1024 )
1025 .await
1026 .unwrap();
1027 let stdout_str = String::from_utf8(stdout).unwrap();
1028 let stderr_str = String::from_utf8(stderr).unwrap();
1029 let expected_out = format!(
1030 "Success. Updated the following files:\nD {}\n",
1031 path.display()
1032 );
1033 assert_eq!(stdout_str, expected_out);
1034 assert_eq!(stderr_str, "");
1035 assert!(!path.exists());
1036 }
1037
1038 #[tokio::test]
1039 async fn test_update_file_hunk_modifies_content() {
1040 let dir = tempdir().unwrap();
1041 let path = dir.path().join("update.txt");
1042 fs::write(&path, "foo\nbar\n").unwrap();
1043 let patch = wrap_patch(&format!(
1044 r#"*** Update File: {}
1045@@
1046 foo
1047-bar
1048+baz"#,
1049 path.display()
1050 ));
1051 let mut stdout = Vec::new();
1052 let mut stderr = Vec::new();
1053 apply_patch(
1054 &patch,
1055 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1056 &mut stdout,
1057 &mut stderr,
1058 LOCAL_FS.as_ref(),
1059 None,
1060 )
1061 .await
1062 .unwrap();
1063 let stdout_str = String::from_utf8(stdout).unwrap();
1065 let stderr_str = String::from_utf8(stderr).unwrap();
1066 let expected_out = format!(
1067 "Success. Updated the following files:\nM {}\n",
1068 path.display()
1069 );
1070 assert_eq!(stdout_str, expected_out);
1071 assert_eq!(stderr_str, "");
1072 let contents = fs::read_to_string(&path).unwrap();
1073 assert_eq!(contents, "foo\nbaz\n");
1074 }
1075
1076 #[tokio::test]
1077 async fn test_update_file_hunk_can_move_file() {
1078 let dir = tempdir().unwrap();
1079 let src = dir.path().join("src.txt");
1080 let dest = dir.path().join("dst.txt");
1081 fs::write(&src, "line\n").unwrap();
1082 let patch = wrap_patch(&format!(
1083 r#"*** Update File: {}
1084*** Move to: {}
1085@@
1086-line
1087+line2"#,
1088 src.display(),
1089 dest.display()
1090 ));
1091 let mut stdout = Vec::new();
1092 let mut stderr = Vec::new();
1093 apply_patch(
1094 &patch,
1095 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1096 &mut stdout,
1097 &mut stderr,
1098 LOCAL_FS.as_ref(),
1099 None,
1100 )
1101 .await
1102 .unwrap();
1103 let stdout_str = String::from_utf8(stdout).unwrap();
1105 let stderr_str = String::from_utf8(stderr).unwrap();
1106 let expected_out = format!(
1107 "Success. Updated the following files:\nM {}\n",
1108 dest.display()
1109 );
1110 assert_eq!(stdout_str, expected_out);
1111 assert_eq!(stderr_str, "");
1112 assert!(!src.exists());
1113 let contents = fs::read_to_string(&dest).unwrap();
1114 assert_eq!(contents, "line2\n");
1115 }
1116
1117 #[cfg(unix)]
1118 #[tokio::test]
1119 async fn test_failed_move_returns_committed_destination_delta() {
1120 use std::os::unix::fs::PermissionsExt;
1121
1122 let dir = tempdir().unwrap();
1123 let source_dir = dir.path().join("locked");
1124 let dest_dir = dir.path().join("out");
1125 fs::create_dir(&source_dir).unwrap();
1126 fs::create_dir(&dest_dir).unwrap();
1127 let src = source_dir.join("src.txt");
1128 let dest = dest_dir.join("dst.txt");
1129 fs::write(&src, "line\n").unwrap();
1130 fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap();
1131
1132 let patch = wrap_patch(
1133 "*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2",
1134 );
1135 let mut stdout = Vec::new();
1136 let mut stderr = Vec::new();
1137 let failure = apply_patch(
1138 &patch,
1139 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1140 &mut stdout,
1141 &mut stderr,
1142 LOCAL_FS.as_ref(),
1143 None,
1144 )
1145 .await
1146 .expect_err("source removal should fail after destination write");
1147
1148 fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap();
1149
1150 assert!(
1151 String::from_utf8(stderr)
1152 .unwrap()
1153 .contains(&format!("Failed to remove original {}", src.display()))
1154 );
1155 assert_eq!(
1156 failure.delta(),
1157 &AppliedPatchDelta::new(
1158 vec![AppliedPatchChange {
1159 path: dest.clone(),
1160 change: AppliedPatchFileChange::Add {
1161 content: "line2\n".to_string(),
1162 overwritten_content: None,
1163 },
1164 }],
1165 true,
1166 )
1167 );
1168 assert_eq!(fs::read_to_string(src).unwrap(), "line\n");
1169 assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n");
1170 }
1171
1172 #[tokio::test]
1175 async fn test_multiple_update_chunks_apply_to_single_file() {
1176 let dir = tempdir().unwrap();
1178 let path = dir.path().join("multi.txt");
1179 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1180 let patch = wrap_patch(&format!(
1184 r#"*** Update File: {}
1185@@
1186 foo
1187-bar
1188+BAR
1189@@
1190 baz
1191-qux
1192+QUX"#,
1193 path.display()
1194 ));
1195 let mut stdout = Vec::new();
1196 let mut stderr = Vec::new();
1197 apply_patch(
1198 &patch,
1199 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1200 &mut stdout,
1201 &mut stderr,
1202 LOCAL_FS.as_ref(),
1203 None,
1204 )
1205 .await
1206 .unwrap();
1207 let stdout_str = String::from_utf8(stdout).unwrap();
1208 let stderr_str = String::from_utf8(stderr).unwrap();
1209 let expected_out = format!(
1210 "Success. Updated the following files:\nM {}\n",
1211 path.display()
1212 );
1213 assert_eq!(stdout_str, expected_out);
1214 assert_eq!(stderr_str, "");
1215 let contents = fs::read_to_string(&path).unwrap();
1216 assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
1217 }
1218
1219 #[tokio::test]
1224 async fn test_update_file_hunk_interleaved_changes() {
1225 let dir = tempdir().unwrap();
1226 let path = dir.path().join("interleaved.txt");
1227
1228 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1230
1231 let patch = wrap_patch(&format!(
1236 r#"*** Update File: {}
1237@@
1238 a
1239-b
1240+B
1241@@
1242 c
1243 d
1244-e
1245+E
1246@@
1247 f
1248+g
1249*** End of File"#,
1250 path.display()
1251 ));
1252
1253 let mut stdout = Vec::new();
1254 let mut stderr = Vec::new();
1255 apply_patch(
1256 &patch,
1257 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1258 &mut stdout,
1259 &mut stderr,
1260 LOCAL_FS.as_ref(),
1261 None,
1262 )
1263 .await
1264 .unwrap();
1265
1266 let stdout_str = String::from_utf8(stdout).unwrap();
1267 let stderr_str = String::from_utf8(stderr).unwrap();
1268
1269 let expected_out = format!(
1270 "Success. Updated the following files:\nM {}\n",
1271 path.display()
1272 );
1273 assert_eq!(stdout_str, expected_out);
1274 assert_eq!(stderr_str, "");
1275
1276 let contents = fs::read_to_string(&path).unwrap();
1277 assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
1278 }
1279
1280 #[tokio::test]
1281 async fn test_pure_addition_chunk_followed_by_removal() {
1282 let dir = tempdir().unwrap();
1283 let path = dir.path().join("panic.txt");
1284 fs::write(&path, "line1\nline2\nline3\n").unwrap();
1285 let patch = wrap_patch(&format!(
1286 r#"*** Update File: {}
1287@@
1288+after-context
1289+second-line
1290@@
1291 line1
1292-line2
1293-line3
1294+line2-replacement"#,
1295 path.display()
1296 ));
1297 let mut stdout = Vec::new();
1298 let mut stderr = Vec::new();
1299 apply_patch(
1300 &patch,
1301 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1302 &mut stdout,
1303 &mut stderr,
1304 LOCAL_FS.as_ref(),
1305 None,
1306 )
1307 .await
1308 .unwrap();
1309 let contents = fs::read_to_string(path).unwrap();
1310 assert_eq!(
1311 contents,
1312 "line1\nline2-replacement\nafter-context\nsecond-line\n"
1313 );
1314 }
1315
1316 #[tokio::test]
1323 async fn test_update_line_with_unicode_dash() {
1324 let dir = tempdir().unwrap();
1325 let path = dir.path().join("unicode.py");
1326
1327 let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
1329 std::fs::write(&path, original).unwrap();
1330
1331 let patch = wrap_patch(&format!(
1333 r#"*** Update File: {}
1334@@
1335-import asyncio # local import - avoids top-level dep
1336+import asyncio # HELLO"#,
1337 path.display()
1338 ));
1339
1340 let mut stdout = Vec::new();
1341 let mut stderr = Vec::new();
1342 apply_patch(
1343 &patch,
1344 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1345 &mut stdout,
1346 &mut stderr,
1347 LOCAL_FS.as_ref(),
1348 None,
1349 )
1350 .await
1351 .unwrap();
1352
1353 let expected = "import asyncio # HELLO\n";
1355 let contents = std::fs::read_to_string(&path).unwrap();
1356 assert_eq!(contents, expected);
1357
1358 let stdout_str = String::from_utf8(stdout).unwrap();
1360 let expected_out = format!(
1361 "Success. Updated the following files:\nM {}\n",
1362 path.display()
1363 );
1364 assert_eq!(stdout_str, expected_out);
1365
1366 assert_eq!(String::from_utf8(stderr).unwrap(), "");
1368 }
1369
1370 #[tokio::test]
1371 async fn test_unified_diff() {
1372 let dir = tempdir().unwrap();
1374 let path = dir.path().join("multi.txt");
1375 fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1376 let patch = wrap_patch(&format!(
1377 r#"*** Update File: {}
1378@@
1379 foo
1380-bar
1381+BAR
1382@@
1383 baz
1384-qux
1385+QUX"#,
1386 path.display()
1387 ));
1388 let patch = parse_patch(&patch).unwrap();
1389
1390 let update_file_chunks = match patch.hunks.as_slice() {
1391 [Hunk::UpdateFile { chunks, .. }] => chunks,
1392 _ => panic!("Expected a single UpdateFile hunk"),
1393 };
1394 let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
1395 let diff = unified_diff_from_chunks(
1396 &path_uri,
1397 update_file_chunks,
1398 LOCAL_FS.as_ref(),
1399 None,
1400 )
1401 .await
1402 .unwrap();
1403 let expected_diff = r#"@@ -1,4 +1,4 @@
1404 foo
1405-bar
1406+BAR
1407 baz
1408-qux
1409+QUX
1410"#;
1411 let expected = ApplyPatchFileUpdate {
1412 unified_diff: expected_diff.to_string(),
1413 original_content: "foo\nbar\nbaz\nqux\n".to_string(),
1414 content: "foo\nBAR\nbaz\nQUX\n".to_string(),
1415 };
1416 assert_eq!(expected, diff);
1417 }
1418
1419 #[tokio::test]
1420 async fn test_unified_diff_first_line_replacement() {
1421 let dir = tempdir().unwrap();
1423 let path = dir.path().join("first.txt");
1424 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1425
1426 let patch = wrap_patch(&format!(
1427 r#"*** Update File: {}
1428@@
1429-foo
1430+FOO
1431 bar
1432"#,
1433 path.display()
1434 ));
1435
1436 let patch = parse_patch(&patch).unwrap();
1437 let chunks = match patch.hunks.as_slice() {
1438 [Hunk::UpdateFile { chunks, .. }] => chunks,
1439 _ => panic!("Expected a single UpdateFile hunk"),
1440 };
1441
1442 let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
1443 let diff = unified_diff_from_chunks(
1444 &resolved_path,
1445 chunks,
1446 LOCAL_FS.as_ref(),
1447 None,
1448 )
1449 .await
1450 .unwrap();
1451 let expected_diff = r#"@@ -1,2 +1,2 @@
1452-foo
1453+FOO
1454 bar
1455"#;
1456 let expected = ApplyPatchFileUpdate {
1457 unified_diff: expected_diff.to_string(),
1458 original_content: "foo\nbar\nbaz\n".to_string(),
1459 content: "FOO\nbar\nbaz\n".to_string(),
1460 };
1461 assert_eq!(expected, diff);
1462 }
1463
1464 #[tokio::test]
1465 async fn test_unified_diff_last_line_replacement() {
1466 let dir = tempdir().unwrap();
1468 let path = dir.path().join("last.txt");
1469 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1470
1471 let patch = wrap_patch(&format!(
1472 r#"*** Update File: {}
1473@@
1474 foo
1475 bar
1476-baz
1477+BAZ
1478"#,
1479 path.display()
1480 ));
1481
1482 let patch = parse_patch(&patch).unwrap();
1483 let chunks = match patch.hunks.as_slice() {
1484 [Hunk::UpdateFile { chunks, .. }] => chunks,
1485 _ => panic!("Expected a single UpdateFile hunk"),
1486 };
1487
1488 let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
1489 let diff = unified_diff_from_chunks(
1490 &resolved_path,
1491 chunks,
1492 LOCAL_FS.as_ref(),
1493 None,
1494 )
1495 .await
1496 .unwrap();
1497 let expected_diff = r#"@@ -2,2 +2,2 @@
1498 bar
1499-baz
1500+BAZ
1501"#;
1502 let expected = ApplyPatchFileUpdate {
1503 unified_diff: expected_diff.to_string(),
1504 original_content: "foo\nbar\nbaz\n".to_string(),
1505 content: "foo\nbar\nBAZ\n".to_string(),
1506 };
1507 assert_eq!(expected, diff);
1508 }
1509
1510 #[tokio::test]
1511 async fn test_unified_diff_insert_at_eof() {
1512 let dir = tempdir().unwrap();
1514 let path = dir.path().join("insert.txt");
1515 fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1516
1517 let patch = wrap_patch(&format!(
1518 r#"*** Update File: {}
1519@@
1520+quux
1521*** End of File
1522"#,
1523 path.display()
1524 ));
1525
1526 let patch = parse_patch(&patch).unwrap();
1527 let chunks = match patch.hunks.as_slice() {
1528 [Hunk::UpdateFile { chunks, .. }] => chunks,
1529 _ => panic!("Expected a single UpdateFile hunk"),
1530 };
1531
1532 let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
1533 let diff =
1534 unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), None)
1535 .await
1536 .unwrap();
1537 let expected_diff = r#"@@ -3 +3,2 @@
1538 baz
1539+quux
1540"#;
1541 let expected = ApplyPatchFileUpdate {
1542 unified_diff: expected_diff.to_string(),
1543 original_content: "foo\nbar\nbaz\n".to_string(),
1544 content: "foo\nbar\nbaz\nquux\n".to_string(),
1545 };
1546 assert_eq!(expected, diff);
1547 }
1548
1549 #[tokio::test]
1550 async fn test_unified_diff_interleaved_changes() {
1551 let dir = tempdir().unwrap();
1553 let path = dir.path().join("interleaved.txt");
1554 fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1555
1556 let patch_body = format!(
1559 r#"*** Update File: {}
1560@@
1561 a
1562-b
1563+B
1564@@
1565 d
1566-e
1567+E
1568@@
1569 f
1570+g
1571*** End of File"#,
1572 path.display()
1573 );
1574 let patch = wrap_patch(&patch_body);
1575
1576 let parsed = parse_patch(&patch).unwrap();
1578 let chunks = match parsed.hunks.as_slice() {
1579 [Hunk::UpdateFile { chunks, .. }] => chunks,
1580 _ => panic!("Expected a single UpdateFile hunk"),
1581 };
1582
1583 let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
1584 let diff =
1585 unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), None)
1586 .await
1587 .unwrap();
1588
1589 let expected_diff = r#"@@ -1,6 +1,7 @@
1590 a
1591-b
1592+B
1593 c
1594 d
1595-e
1596+E
1597 f
1598+g
1599"#;
1600
1601 let expected = ApplyPatchFileUpdate {
1602 unified_diff: expected_diff.to_string(),
1603 original_content: "a\nb\nc\nd\ne\nf\n".to_string(),
1604 content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
1605 };
1606
1607 assert_eq!(expected, diff);
1608
1609 let mut stdout = Vec::new();
1610 let mut stderr = Vec::new();
1611 apply_patch(
1612 &patch,
1613 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1614 &mut stdout,
1615 &mut stderr,
1616 LOCAL_FS.as_ref(),
1617 None,
1618 )
1619 .await
1620 .unwrap();
1621 let contents = fs::read_to_string(path).unwrap();
1622 assert_eq!(
1623 contents,
1624 r#"a
1625B
1626c
1627d
1628E
1629f
1630g
1631"#
1632 );
1633 }
1634
1635 #[cfg(unix)]
1636 #[tokio::test]
1637 async fn test_apply_patch_fails_on_write_error() {
1638 use std::os::unix::fs::PermissionsExt;
1639
1640 let dir = tempdir().unwrap();
1641 let locked_dir = dir.path().join("locked");
1642 fs::create_dir(&locked_dir).unwrap();
1643 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
1644
1645 let patch = wrap_patch("*** Add File: locked/new.txt\n+after");
1646
1647 let mut stdout = Vec::new();
1648 let mut stderr = Vec::new();
1649 let result = apply_patch(
1650 &patch,
1651 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1652 &mut stdout,
1653 &mut stderr,
1654 LOCAL_FS.as_ref(),
1655 None,
1656 )
1657 .await;
1658 let failure = result.expect_err("write should fail");
1659
1660 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
1661
1662 assert!(!failure.delta().is_exact());
1663 }
1664
1665 #[tokio::test]
1666 async fn test_unreadable_destinations_return_inexact_delta() {
1667 let dir = tempdir().unwrap();
1668 let path = dir.path().join("binary.dat");
1669 fs::write(dir.path().join("source.txt"), "before\n").unwrap();
1670 let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
1671
1672 for patch in [
1673 wrap_patch("*** Add File: binary.dat\n+text"),
1674 wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"),
1675 ] {
1676 fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
1677 let mut stdout = Vec::new();
1678 let mut stderr = Vec::new();
1679 let delta = apply_patch(
1680 &patch,
1681 &cwd,
1682 &mut stdout,
1683 &mut stderr,
1684 LOCAL_FS.as_ref(),
1685 None,
1686 )
1687 .await
1688 .unwrap();
1689
1690 assert!(!delta.is_exact());
1691 }
1692 }
1693
1694 #[cfg(unix)]
1695 #[tokio::test]
1696 async fn test_delete_symlink_returns_inexact_delta() {
1697 use std::os::unix::fs::symlink;
1698
1699 let dir = tempdir().unwrap();
1700 fs::write(dir.path().join("target.txt"), "target\n").unwrap();
1701 symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap();
1702 let patch = wrap_patch("*** Delete File: link.txt");
1703
1704 let mut stdout = Vec::new();
1705 let mut stderr = Vec::new();
1706 let delta = apply_patch(
1707 &patch,
1708 &PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
1709 &mut stdout,
1710 &mut stderr,
1711 LOCAL_FS.as_ref(),
1712 None,
1713 )
1714 .await
1715 .unwrap();
1716
1717 assert!(!delta.is_exact());
1718 }
1719}