Skip to main content

codex_apply_patch/
lib.rs

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
34/// Special argv[1] flag used when the Codex executable self-invokes to run the
35/// internal `apply_patch` path.
36///
37/// Although this constant lives in `codex-apply-patch` (to avoid forcing
38/// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core"
39/// process-invocation contract for the standalone `apply_patch` command
40/// surface.
41pub 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 that occurs while computing replacements when applying patch chunks
50    #[error("{0}")]
51    ComputeReplacements(String),
52    /// A patch path could not be resolved as a path URI.
53    #[error(transparent)]
54    PathUri(#[from] PathUriParseError),
55    /// A raw patch body was provided without an explicit `apply_patch` invocation.
56    #[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/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
95/// parsed into hunks.
96#[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 that will result after the unified_diff is applied.
116        new_content: String,
117    },
118}
119
120#[derive(Debug, PartialEq)]
121pub enum MaybeApplyPatchVerified {
122    /// `argv` corresponded to an `apply_patch` invocation, and these are the
123    /// resulting proposed file changes.
124    Body(ApplyPatchAction),
125    /// `argv` could not be parsed to determine whether it corresponds to an
126    /// `apply_patch` invocation.
127    ShellParseError(ExtractHeredocError),
128    /// `argv` corresponded to an `apply_patch` invocation, but it could not
129    /// be fulfilled due to the specified error.
130    CorrectnessError(ApplyPatchError),
131    /// `argv` decidedly did not correspond to an `apply_patch` invocation.
132    NotApplyPatch,
133}
134
135/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
136/// construction, all paths should be absolute paths.
137#[derive(Debug, PartialEq)]
138pub struct ApplyPatchAction {
139    changes: HashMap<PathUri, ApplyPatchFileChange>,
140
141    /// The raw patch argument that can be used to apply the patch. i.e., if the
142    /// original arg was parsed in "lenient" mode with a
143    /// heredoc, this should be the value without the heredoc wrapper.
144    pub patch: String,
145
146    /// The working directory that was used to resolve relative paths in the patch.
147    pub cwd: PathUri,
148}
149
150impl ApplyPatchAction {
151    pub fn is_empty(&self) -> bool {
152        self.changes.is_empty()
153    }
154
155    /// Returns the changes that would be made by applying the patch.
156    pub fn changes(&self) -> &HashMap<PathUri, ApplyPatchFileChange> {
157        &self.changes
158    }
159
160    /// Should be used exclusively for testing. (Not worth the overhead of
161    /// creating a feature flag for this.)
162    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/// Textual file changes that were actually committed while applying a patch.
183#[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(), /*exact*/ 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    /// Appends a later committed prefix while preserving the aggregate exactness.
211    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/// A committed file change, preserved in the order it was applied.
224#[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/// A failed patch application together with the textual mutations that were
248/// definitely committed before the failure was observed.
249#[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
275/// Applies the patch and prints the result to stdout/stderr.
276pub 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
314/// Applies hunks and continues to update stdout/stderr
315pub 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
349/// Applies each parsed patch hunk to the filesystem.
350/// Returns an error if any of the changes could not be applied.
351/// Tracks file paths affected by applying a patch, preserving the path spelling
352/// from the patch for user-facing summaries.
353pub struct AffectedPaths {
354    pub added: Vec<PathBuf>,
355    pub modified: Vec<PathBuf>,
356    pub deleted: Vec<PathBuf>,
357}
358
359/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
360/// Returns an error if the patch could not be applied.
361async 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    // A failed write can still have modified the target before surfacing an
376    // error (for example by truncating before ENOSPC), so the accumulated
377    // delta is no longer exact when a write fails.
378    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    // TODO(anp): Carry PathUri through committed patch deltas and the turn diff tracker.
391    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
673/// Return *only* the new file contents (joined into a single `String`) after
674/// applying the chunks to the file at `path`.
675async 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    // Drop the trailing empty element that results from the final newline so
694    // that line counts match the behaviour of standard `diff`.
695    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
713/// Compute a list of replacements needed to transform `original_lines` into the
714/// new lines, given the patch `chunks`. Each replacement is returned as
715/// `(start_index, old_len, new_lines)`.
716fn 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 a chunk has a `change_context`, we use seek_sequence to find it, then
726        // adjust our `line_index` to continue from there.
727        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                /*eof*/ 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            // Pure addition (no old lines). We'll add them at the end or just
744            // before the final empty line if one exists.
745            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        // Otherwise, try to match the existing lines in the file with the old lines
755        // from the chunk. If found, schedule that region for replacement.
756        // Attempt to locate the `old_lines` verbatim within the file.  In many
757        // real‑world diffs the last element of `old_lines` is an *empty* string
758        // representing the terminating newline of the region being replaced.
759        // This sentinel is not present in `original_lines` because we strip the
760        // trailing empty slice emitted by `split('\n')`.  If a direct search
761        // fails and the pattern ends with an empty string, retry without that
762        // final element so that modifications touching the end‑of‑file can be
763        // located reliably.
764
765        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            // Retry without the trailing empty line which represents the final
773            // newline in the file.
774            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
804/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
805/// returning the modified file contents as a vector of lines.
806fn apply_replacements(
807    mut lines: Vec<String>,
808    replacements: &[(usize, usize, Vec<String>)],
809) -> Vec<String> {
810    // We must apply replacements in descending order so that earlier replacements
811    // don't shift the positions of later ones.
812    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        // Remove old lines.
817        for _ in 0..old_len {
818            if start_idx < lines.len() {
819                lines.remove(start_idx);
820            }
821        }
822
823        // Insert new lines.
824        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/// Intended result of a file update for apply_patch.
833#[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, /*context*/ 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
869/// Print the summary of changes in git-style format.
870/// Write a summary of changes to the given writer.
871pub 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    /// Helper to construct a patch with the given body.
898    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            /*sandbox*/ None,
921        )
922        .await
923        .unwrap();
924        // Verify expected stdout and stderr outputs.
925        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            /*sandbox*/ 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            /*sandbox*/ 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            /*sandbox*/ None,
1060        )
1061        .await
1062        .unwrap();
1063        // Validate modified file contents and expected stdout/stderr.
1064        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            /*sandbox*/ None,
1100        )
1101        .await
1102        .unwrap();
1103        // Validate move semantics and expected stdout/stderr.
1104        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            /*sandbox*/ 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                /*exact*/ 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    /// Verify that a single `Update File` hunk with multiple change chunks can update different
1173    /// parts of a file and that the file is listed only once in the summary.
1174    #[tokio::test]
1175    async fn test_multiple_update_chunks_apply_to_single_file() {
1176        // Start with a file containing four lines.
1177        let dir = tempdir().unwrap();
1178        let path = dir.path().join("multi.txt");
1179        fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1180        // Construct an update patch with two separate change chunks.
1181        // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
1182        // The second chunk uses `baz` as context and transforms `qux` into `QUX`.
1183        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            /*sandbox*/ 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    /// A more involved `Update File` hunk that exercises additions, deletions and
1220    /// replacements in separate chunks that appear in non‑adjacent parts of the
1221    /// file.  Verifies that all edits are applied and that the summary lists the
1222    /// file only once.
1223    #[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        // Original file: six numbered lines.
1229        fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1230
1231        // Patch performs:
1232        //  • Replace `b` → `B`
1233        //  • Replace `e` → `E` (using surrounding context)
1234        //  • Append new line `g` at the end‑of‑file
1235        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            /*sandbox*/ 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            /*sandbox*/ 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    /// Ensure that patches authored with ASCII characters can update lines that
1317    /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
1318    /// HYPHEN). Historically `git apply` succeeds in such scenarios but our
1319    /// internal matcher failed requiring an exact byte-for-byte match.  The
1320    /// fuzzy-matching pass that normalises common punctuation should now bridge
1321    /// the gap.
1322    #[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        // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
1328        let original = "import asyncio  # local import \u{2013} avoids top\u{2011}level dep\n";
1329        std::fs::write(&path, original).unwrap();
1330
1331        // Patch uses plain ASCII dash / hyphen.
1332        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            /*sandbox*/ None,
1349        )
1350        .await
1351        .unwrap();
1352
1353        // File should now contain the replaced comment.
1354        let expected = "import asyncio  # HELLO\n";
1355        let contents = std::fs::read_to_string(&path).unwrap();
1356        assert_eq!(contents, expected);
1357
1358        // Ensure success summary lists the file as modified.
1359        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        // No stderr expected.
1367        assert_eq!(String::from_utf8(stderr).unwrap(), "");
1368    }
1369
1370    #[tokio::test]
1371    async fn test_unified_diff() {
1372        // Start with a file containing four lines.
1373        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            /*sandbox*/ 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        // Replace the very first line of the file.
1422        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            /*sandbox*/ 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        // Replace the very last line of the file.
1467        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            /*sandbox*/ 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        // Insert a new line at end‑of‑file.
1513        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(), /*sandbox*/ 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        // Original file with six lines.
1552        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        // Patch replaces two separate lines and appends a new one at EOF using
1557        // three distinct chunks.
1558        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        // Extract chunks then build the unified diff.
1577        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(), /*sandbox*/ 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            /*sandbox*/ 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            /*sandbox*/ 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                /*sandbox*/ 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            /*sandbox*/ None,
1713        )
1714        .await
1715        .unwrap();
1716
1717        assert!(!delta.is_exact());
1718    }
1719}