agcodex_apply_patch/
lib.rs

1mod error;
2mod parser;
3mod seek_sequence;
4
5use std::collections::HashMap;
6use std::path::Path;
7use std::path::PathBuf;
8use std::str::Utf8Error;
9
10use error::PatchError;
11use error::Result;
12pub use parser::Hunk;
13pub use parser::ParseError;
14use parser::ParseError::*;
15use parser::UpdateFileChunk;
16pub use parser::parse_patch;
17use similar::TextDiff;
18use tree_sitter::LanguageError;
19use tree_sitter::Parser;
20use tree_sitter_bash::LANGUAGE as BASH;
21
22/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
23pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
24
25// ApplyPatchError is now aliased to PatchError in error.rs
26use error::ApplyPatchError;
27
28impl From<std::io::Error> for PatchError {
29    fn from(err: std::io::Error) -> Self {
30        PatchError::IoError {
31            context: "I/O error".to_string(),
32            source: err,
33        }
34    }
35}
36
37impl From<&std::io::Error> for ApplyPatchError {
38    fn from(err: &std::io::Error) -> Self {
39        ApplyPatchError::IoError {
40            context: "I/O error".to_string(),
41            source: std::io::Error::new(err.kind(), err.to_string()),
42        }
43    }
44}
45
46#[derive(Debug, PartialEq)]
47pub enum MaybeApplyPatch {
48    Body(ApplyPatchArgs),
49    ShellParseError(ExtractHeredocError),
50    PatchParseError(ParseError),
51    NotApplyPatch,
52}
53
54/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
55/// parsed into hunks.
56#[derive(Debug, PartialEq)]
57pub struct ApplyPatchArgs {
58    pub patch: String,
59    pub hunks: Vec<Hunk>,
60}
61
62pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
63    const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
64    match argv {
65        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
66            Ok(source) => MaybeApplyPatch::Body(source),
67            Err(e) => MaybeApplyPatch::PatchParseError(e),
68        },
69        [bash, flag, script]
70            if bash == "bash"
71                && flag == "-lc"
72                && script.trim_start().starts_with("apply_patch") =>
73        {
74            match extract_heredoc_body_from_apply_patch_command(script) {
75                Ok(body) => match parse_patch(&body) {
76                    Ok(source) => MaybeApplyPatch::Body(source),
77                    Err(e) => MaybeApplyPatch::PatchParseError(e),
78                },
79                Err(e) => MaybeApplyPatch::ShellParseError(e),
80            }
81        }
82        _ => MaybeApplyPatch::NotApplyPatch,
83    }
84}
85
86#[derive(Debug, PartialEq)]
87pub enum ApplyPatchFileChange {
88    Add {
89        content: String,
90    },
91    Delete,
92    Update {
93        unified_diff: String,
94        move_path: Option<PathBuf>,
95        /// new_content that will result after the unified_diff is applied.
96        new_content: String,
97    },
98}
99
100#[derive(Debug, PartialEq)]
101pub enum MaybeApplyPatchVerified {
102    /// `argv` corresponded to an `apply_patch` invocation, and these are the
103    /// resulting proposed file changes.
104    Body(ApplyPatchAction),
105    /// `argv` could not be parsed to determine whether it corresponds to an
106    /// `apply_patch` invocation.
107    ShellParseError(ExtractHeredocError),
108    /// `argv` corresponded to an `apply_patch` invocation, but it could not
109    /// be fulfilled due to the specified error.
110    CorrectnessError(ApplyPatchError),
111    /// `argv` decidedly did not correspond to an `apply_patch` invocation.
112    NotApplyPatch,
113}
114
115/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
116/// construction, all paths should be absolute paths.
117#[derive(Debug, PartialEq)]
118pub struct ApplyPatchAction {
119    changes: HashMap<PathBuf, ApplyPatchFileChange>,
120
121    /// The raw patch argument that can be used with `apply_patch` as an exec
122    /// call. i.e., if the original arg was parsed in "lenient" mode with a
123    /// heredoc, this should be the value without the heredoc wrapper.
124    pub patch: String,
125
126    /// The working directory that was used to resolve relative paths in the patch.
127    pub cwd: PathBuf,
128}
129
130impl ApplyPatchAction {
131    pub fn is_empty(&self) -> bool {
132        self.changes.is_empty()
133    }
134
135    /// Returns the changes that would be made by applying the patch.
136    pub const fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
137        &self.changes
138    }
139
140    /// Should be used exclusively for testing. (Not worth the overhead of
141    /// creating a feature flag for this.)
142    pub fn new_add_for_test(path: &Path, content: String) -> Self {
143        if !path.is_absolute() {
144            panic!("path must be absolute");
145        }
146
147        #[expect(clippy::expect_used)]
148        let filename = path
149            .file_name()
150            .expect("path should not be empty")
151            .to_string_lossy();
152        let patch = format!(
153            r#"*** Begin Patch
154*** Update File: {filename}
155@@
156+ {content}
157*** End Patch"#,
158        );
159        let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
160        #[expect(clippy::expect_used)]
161        Self {
162            changes,
163            cwd: path
164                .parent()
165                .expect("path should have parent")
166                .to_path_buf(),
167            patch,
168        }
169    }
170}
171
172/// cwd must be an absolute path so that we can resolve relative paths in the
173/// patch.
174pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
175    match maybe_parse_apply_patch(argv) {
176        MaybeApplyPatch::Body(ApplyPatchArgs { patch, hunks }) => {
177            let mut changes = HashMap::new();
178            for hunk in hunks {
179                let path = hunk.resolve_path(cwd);
180                match hunk {
181                    Hunk::AddFile { contents, .. } => {
182                        changes.insert(path, ApplyPatchFileChange::Add { content: contents });
183                    }
184                    Hunk::DeleteFile { .. } => {
185                        changes.insert(path, ApplyPatchFileChange::Delete);
186                    }
187                    Hunk::UpdateFile {
188                        move_path, chunks, ..
189                    } => {
190                        let ApplyPatchFileUpdate {
191                            unified_diff,
192                            content: contents,
193                        } = match unified_diff_from_chunks(&path, &chunks) {
194                            Ok(diff) => diff,
195                            Err(e) => {
196                                return MaybeApplyPatchVerified::CorrectnessError(e);
197                            }
198                        };
199                        changes.insert(
200                            path,
201                            ApplyPatchFileChange::Update {
202                                unified_diff,
203                                move_path: move_path.map(|p| cwd.join(p)),
204                                new_content: contents,
205                            },
206                        );
207                    }
208                }
209            }
210            MaybeApplyPatchVerified::Body(ApplyPatchAction {
211                changes,
212                patch,
213                cwd: cwd.to_path_buf(),
214            })
215        }
216        MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
217        MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
218        MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
219    }
220}
221
222/// Attempts to extract a heredoc_body object from a string bash command like:
223/// Optimistically
224///
225/// ```bash
226/// bash -lc 'apply_patch <<EOF\n***Begin Patch\n...EOF'
227/// ```
228///
229/// # Arguments
230///
231/// * `src` - A string slice that holds the full command
232///
233/// # Returns
234///
235/// This function returns a `Result` which is:
236///
237/// * `Ok(String)` - The heredoc body if the extraction is successful.
238/// * `Err(PatchError)` - An error if the extraction fails.
239///
240fn extract_heredoc_body_from_apply_patch_command(
241    src: &str,
242) -> std::result::Result<String, ExtractHeredocError> {
243    if !src.trim_start().starts_with("apply_patch") {
244        return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch);
245    }
246
247    let lang = BASH.into();
248    let mut parser = Parser::new();
249    parser
250        .set_language(&lang)
251        .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
252    let tree = parser
253        .parse(src, None)
254        .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
255
256    let bytes = src.as_bytes();
257    let mut c = tree.root_node().walk();
258
259    loop {
260        let node = c.node();
261        if node.kind() == "heredoc_body" {
262            let text = node
263                .utf8_text(bytes)
264                .map_err(ExtractHeredocError::HeredocNotUtf8)?;
265            return Ok(text.trim_end_matches('\n').to_owned());
266        }
267
268        if c.goto_first_child() {
269            continue;
270        }
271        while !c.goto_next_sibling() {
272            if !c.goto_parent() {
273                return Err(ExtractHeredocError::FailedToFindHeredocBody);
274            }
275        }
276    }
277}
278
279#[derive(Debug, PartialEq)]
280pub enum ExtractHeredocError {
281    CommandDidNotStartWithApplyPatch,
282    FailedToLoadBashGrammar(LanguageError),
283    HeredocNotUtf8(Utf8Error),
284    FailedToParsePatchIntoAst,
285    FailedToFindHeredocBody,
286}
287
288/// Applies the patch and prints the result to stdout/stderr.
289pub fn apply_patch(
290    patch: &str,
291    stdout: &mut impl std::io::Write,
292    stderr: &mut impl std::io::Write,
293) -> Result<()> {
294    let hunks = match parse_patch(patch) {
295        Ok(source) => source.hunks,
296        Err(e) => {
297            match &e {
298                InvalidPatchError(message) => {
299                    writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?;
300                }
301                InvalidHunkError {
302                    message,
303                    line_number,
304                } => {
305                    writeln!(
306                        stderr,
307                        "Invalid patch hunk on line {line_number}: {message}"
308                    )
309                    .map_err(ApplyPatchError::from)?;
310                }
311            }
312            return Err(ApplyPatchError::ParseError(e));
313        }
314    };
315
316    apply_hunks(&hunks, stdout, stderr)?;
317
318    Ok(())
319}
320
321/// Applies hunks and continues to update stdout/stderr
322pub fn apply_hunks(
323    hunks: &[Hunk],
324    stdout: &mut impl std::io::Write,
325    stderr: &mut impl std::io::Write,
326) -> Result<()> {
327    let _existing_paths: Vec<&Path> = hunks
328        .iter()
329        .filter_map(|hunk| match hunk {
330            Hunk::AddFile { .. } => {
331                // The file is being added, so it doesn't exist yet.
332                None
333            }
334            Hunk::DeleteFile { path } => Some(path.as_path()),
335            Hunk::UpdateFile {
336                path, move_path, ..
337            } => match move_path {
338                Some(move_path) => {
339                    if std::fs::metadata(move_path)
340                        .map(|m| m.is_file())
341                        .unwrap_or(false)
342                    {
343                        Some(move_path.as_path())
344                    } else {
345                        None
346                    }
347                }
348                None => Some(path.as_path()),
349            },
350        })
351        .collect::<Vec<&Path>>();
352
353    // Delegate to a helper that applies each hunk to the filesystem.
354    match apply_hunks_to_files(hunks) {
355        Ok(affected) => {
356            print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
357            Ok(())
358        }
359        Err(err) => {
360            let msg = err.to_string();
361            writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
362            // Convert all errors to IoError variant
363            Err(ApplyPatchError::IoError {
364                context: msg.clone(),
365                source: std::io::Error::other(msg),
366            })
367        }
368    }
369}
370
371/// Applies each parsed patch hunk to the filesystem.
372/// Returns an error if any of the changes could not be applied.
373/// Tracks file paths affected by applying a patch.
374pub struct AffectedPaths {
375    pub added: Vec<PathBuf>,
376    pub modified: Vec<PathBuf>,
377    pub deleted: Vec<PathBuf>,
378}
379
380/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
381/// Returns an error if the patch could not be applied.
382fn apply_hunks_to_files(hunks: &[Hunk]) -> Result<AffectedPaths> {
383    if hunks.is_empty() {
384        return Err(PatchError::NoFilesModified);
385    }
386
387    let mut added: Vec<PathBuf> = Vec::new();
388    let mut modified: Vec<PathBuf> = Vec::new();
389    let mut deleted: Vec<PathBuf> = Vec::new();
390    for hunk in hunks {
391        match hunk {
392            Hunk::AddFile { path, contents } => {
393                if let Some(parent) = path.parent()
394                    && !parent.as_os_str().is_empty()
395                {
396                    std::fs::create_dir_all(parent).map_err(|e| PatchError::IoError {
397                        context: format!(
398                            "Failed to create parent directories for {}",
399                            path.display()
400                        ),
401                        source: e,
402                    })?;
403                }
404                std::fs::write(path, contents).map_err(|e| PatchError::IoError {
405                    context: format!("Failed to write file {}", path.display()),
406                    source: e,
407                })?;
408                added.push(path.clone());
409            }
410            Hunk::DeleteFile { path } => {
411                std::fs::remove_file(path).map_err(|e| PatchError::IoError {
412                    context: format!("Failed to delete file {}", path.display()),
413                    source: e,
414                })?;
415                deleted.push(path.clone());
416            }
417            Hunk::UpdateFile {
418                path,
419                move_path,
420                chunks,
421            } => {
422                let AppliedPatch { new_contents, .. } =
423                    derive_new_contents_from_chunks(path, chunks)?;
424                if let Some(dest) = move_path {
425                    if let Some(parent) = dest.parent()
426                        && !parent.as_os_str().is_empty()
427                    {
428                        std::fs::create_dir_all(parent).map_err(|e| PatchError::IoError {
429                            context: format!(
430                                "Failed to create parent directories for {}",
431                                dest.display()
432                            ),
433                            source: e,
434                        })?;
435                    }
436                    std::fs::write(dest, new_contents).map_err(|e| PatchError::IoError {
437                        context: format!("Failed to write file {}", dest.display()),
438                        source: e,
439                    })?;
440                    std::fs::remove_file(path).map_err(|e| PatchError::IoError {
441                        context: format!("Failed to remove original {}", path.display()),
442                        source: e,
443                    })?;
444                    modified.push(dest.clone());
445                } else {
446                    std::fs::write(path, new_contents).map_err(|e| PatchError::IoError {
447                        context: format!("Failed to write file {}", path.display()),
448                        source: e,
449                    })?;
450                    modified.push(path.clone());
451                }
452            }
453        }
454    }
455    Ok(AffectedPaths {
456        added,
457        modified,
458        deleted,
459    })
460}
461
462struct AppliedPatch {
463    original_contents: String,
464    new_contents: String,
465}
466
467/// Return *only* the new file contents (joined into a single `String`) after
468/// applying the chunks to the file at `path`.
469fn derive_new_contents_from_chunks(
470    path: &Path,
471    chunks: &[UpdateFileChunk],
472) -> Result<AppliedPatch> {
473    let original_contents = match std::fs::read_to_string(path) {
474        Ok(contents) => contents,
475        Err(err) => {
476            return Err(ApplyPatchError::IoError {
477                context: format!("Failed to read file to update {}", path.display()),
478                source: err,
479            });
480        }
481    };
482
483    let mut original_lines: Vec<String> = original_contents
484        .split('\n')
485        .map(|s| s.to_string())
486        .collect();
487
488    // Drop the trailing empty element that results from the final newline so
489    // that line counts match the behaviour of standard `diff`.
490    if original_lines.last().is_some_and(|s| s.is_empty()) {
491        original_lines.pop();
492    }
493
494    let replacements = compute_replacements(&original_lines, path, chunks)?;
495    let new_lines = apply_replacements(original_lines, &replacements);
496    let mut new_lines = new_lines;
497    if !new_lines.last().is_some_and(|s| s.is_empty()) {
498        new_lines.push(String::new());
499    }
500    let new_contents = new_lines.join("\n");
501    Ok(AppliedPatch {
502        original_contents,
503        new_contents,
504    })
505}
506
507/// Compute a list of replacements needed to transform `original_lines` into the
508/// new lines, given the patch `chunks`. Each replacement is returned as
509/// `(start_index, old_len, new_lines)`.
510fn compute_replacements(
511    original_lines: &[String],
512    path: &Path,
513    chunks: &[UpdateFileChunk],
514) -> Result<Vec<(usize, usize, Vec<String>)>> {
515    let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
516    let mut line_index: usize = 0;
517
518    for chunk in chunks {
519        // If a chunk has a `change_context`, we use seek_sequence to find it, then
520        // adjust our `line_index` to continue from there.
521        if let Some(ctx_line) = &chunk.change_context {
522            if let Some(idx) = seek_sequence::seek_sequence(
523                original_lines,
524                std::slice::from_ref(ctx_line),
525                line_index,
526                false,
527            ) {
528                line_index = idx + 1;
529            } else {
530                return Err(ApplyPatchError::ComputeReplacements(format!(
531                    "Failed to find context '{}' in {}",
532                    ctx_line,
533                    path.display()
534                )));
535            }
536        }
537
538        if chunk.old_lines.is_empty() {
539            // Pure addition (no old lines). We'll add them at the end or just
540            // before the final empty line if one exists.
541            let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
542                original_lines.len() - 1
543            } else {
544                original_lines.len()
545            };
546            replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
547            continue;
548        }
549
550        // Otherwise, try to match the existing lines in the file with the old lines
551        // from the chunk. If found, schedule that region for replacement.
552        // Attempt to locate the `old_lines` verbatim within the file.  In many
553        // real‑world diffs the last element of `old_lines` is an *empty* string
554        // representing the terminating newline of the region being replaced.
555        // This sentinel is not present in `original_lines` because we strip the
556        // trailing empty slice emitted by `split('\n')`.  If a direct search
557        // fails and the pattern ends with an empty string, retry without that
558        // final element so that modifications touching the end‑of‑file can be
559        // located reliably.
560
561        let mut pattern: &[String] = &chunk.old_lines;
562        let mut found =
563            seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
564
565        let mut new_slice: &[String] = &chunk.new_lines;
566
567        if found.is_none() && pattern.last().is_some_and(|s| s.is_empty()) {
568            // Retry without the trailing empty line which represents the final
569            // newline in the file.
570            pattern = &pattern[..pattern.len() - 1];
571            if new_slice.last().is_some_and(|s| s.is_empty()) {
572                new_slice = &new_slice[..new_slice.len() - 1];
573            }
574
575            found = seek_sequence::seek_sequence(
576                original_lines,
577                pattern,
578                line_index,
579                chunk.is_end_of_file,
580            );
581        }
582
583        if let Some(start_idx) = found {
584            replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
585            line_index = start_idx + pattern.len();
586        } else {
587            return Err(ApplyPatchError::ComputeReplacements(format!(
588                "Failed to find expected lines {:?} in {}",
589                chunk.old_lines,
590                path.display()
591            )));
592        }
593    }
594
595    Ok(replacements)
596}
597
598/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
599/// returning the modified file contents as a vector of lines.
600fn apply_replacements(
601    mut lines: Vec<String>,
602    replacements: &[(usize, usize, Vec<String>)],
603) -> Vec<String> {
604    // We must apply replacements in descending order so that earlier replacements
605    // don't shift the positions of later ones.
606    for (start_idx, old_len, new_segment) in replacements.iter().rev() {
607        let start_idx = *start_idx;
608        let old_len = *old_len;
609
610        // Remove old lines.
611        for _ in 0..old_len {
612            if start_idx < lines.len() {
613                lines.remove(start_idx);
614            }
615        }
616
617        // Insert new lines.
618        for (offset, new_line) in new_segment.iter().enumerate() {
619            lines.insert(start_idx + offset, new_line.clone());
620        }
621    }
622
623    lines
624}
625
626/// Intended result of a file update for apply_patch.
627#[derive(Debug, Eq, PartialEq)]
628pub struct ApplyPatchFileUpdate {
629    unified_diff: String,
630    content: String,
631}
632
633pub fn unified_diff_from_chunks(
634    path: &Path,
635    chunks: &[UpdateFileChunk],
636) -> Result<ApplyPatchFileUpdate> {
637    unified_diff_from_chunks_with_context(path, chunks, 1)
638}
639
640pub fn unified_diff_from_chunks_with_context(
641    path: &Path,
642    chunks: &[UpdateFileChunk],
643    context: usize,
644) -> Result<ApplyPatchFileUpdate> {
645    let AppliedPatch {
646        original_contents,
647        new_contents,
648    } = derive_new_contents_from_chunks(path, chunks)?;
649    let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
650    let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
651    Ok(ApplyPatchFileUpdate {
652        unified_diff,
653        content: new_contents,
654    })
655}
656
657/// Print the summary of changes in git-style format.
658/// Write a summary of changes to the given writer.
659pub fn print_summary(
660    affected: &AffectedPaths,
661    out: &mut impl std::io::Write,
662) -> std::io::Result<()> {
663    writeln!(out, "Success. Updated the following files:")?;
664    for path in &affected.added {
665        writeln!(out, "A {}", path.display())?;
666    }
667    for path in &affected.modified {
668        writeln!(out, "M {}", path.display())?;
669    }
670    for path in &affected.deleted {
671        writeln!(out, "D {}", path.display())?;
672    }
673    Ok(())
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use pretty_assertions::assert_eq;
680    use std::fs;
681    use tempfile::tempdir;
682
683    /// Helper to construct a patch with the given body.
684    fn wrap_patch(body: &str) -> String {
685        format!("*** Begin Patch\n{body}\n*** End Patch")
686    }
687
688    fn strs_to_strings(strs: &[&str]) -> Vec<String> {
689        strs.iter().map(|s| (*s).to_string()).collect()
690    }
691
692    #[test]
693    fn test_literal() {
694        let args = strs_to_strings(&[
695            "apply_patch",
696            r#"*** Begin Patch
697*** Add File: foo
698+hi
699*** End Patch
700"#,
701        ]);
702
703        match maybe_parse_apply_patch(&args) {
704            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
705                assert_eq!(
706                    hunks,
707                    vec![Hunk::AddFile {
708                        path: PathBuf::from("foo"),
709                        contents: "hi\n".to_string()
710                    }]
711                );
712            }
713            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
714        }
715    }
716
717    #[test]
718    fn test_literal_applypatch() {
719        let args = strs_to_strings(&[
720            "applypatch",
721            r#"*** Begin Patch
722*** Add File: foo
723+hi
724*** End Patch
725"#,
726        ]);
727
728        match maybe_parse_apply_patch(&args) {
729            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
730                assert_eq!(
731                    hunks,
732                    vec![Hunk::AddFile {
733                        path: PathBuf::from("foo"),
734                        contents: "hi\n".to_string()
735                    }]
736                );
737            }
738            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
739        }
740    }
741
742    #[test]
743    fn test_heredoc() {
744        let args = strs_to_strings(&[
745            "bash",
746            "-lc",
747            r#"apply_patch <<'PATCH'
748*** Begin Patch
749*** Add File: foo
750+hi
751*** End Patch
752PATCH"#,
753        ]);
754
755        match maybe_parse_apply_patch(&args) {
756            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
757                assert_eq!(
758                    hunks,
759                    vec![Hunk::AddFile {
760                        path: PathBuf::from("foo"),
761                        contents: "hi\n".to_string()
762                    }]
763                );
764            }
765            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
766        }
767    }
768
769    #[test]
770    fn test_add_file_hunk_creates_file_with_contents() {
771        let dir = tempdir().unwrap();
772        let path = dir.path().join("add.txt");
773        let patch = wrap_patch(&format!(
774            r#"*** Add File: {}
775+ab
776+cd"#,
777            path.display()
778        ));
779        let mut stdout = Vec::new();
780        let mut stderr = Vec::new();
781        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
782        // Verify expected stdout and stderr outputs.
783        let stdout_str = String::from_utf8(stdout).unwrap();
784        let stderr_str = String::from_utf8(stderr).unwrap();
785        let expected_out = format!(
786            "Success. Updated the following files:\nA {}\n",
787            path.display()
788        );
789        assert_eq!(stdout_str, expected_out);
790        assert_eq!(stderr_str, "");
791        let contents = fs::read_to_string(path).unwrap();
792        assert_eq!(contents, "ab\ncd\n");
793    }
794
795    #[test]
796    fn test_delete_file_hunk_removes_file() {
797        let dir = tempdir().unwrap();
798        let path = dir.path().join("del.txt");
799        fs::write(&path, "x").unwrap();
800        let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
801        let mut stdout = Vec::new();
802        let mut stderr = Vec::new();
803        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
804        let stdout_str = String::from_utf8(stdout).unwrap();
805        let stderr_str = String::from_utf8(stderr).unwrap();
806        let expected_out = format!(
807            "Success. Updated the following files:\nD {}\n",
808            path.display()
809        );
810        assert_eq!(stdout_str, expected_out);
811        assert_eq!(stderr_str, "");
812        assert!(!path.exists());
813    }
814
815    #[test]
816    fn test_update_file_hunk_modifies_content() {
817        let dir = tempdir().unwrap();
818        let path = dir.path().join("update.txt");
819        fs::write(&path, "foo\nbar\n").unwrap();
820        let patch = wrap_patch(&format!(
821            r#"*** Update File: {}
822@@
823 foo
824-bar
825+baz"#,
826            path.display()
827        ));
828        let mut stdout = Vec::new();
829        let mut stderr = Vec::new();
830        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
831        // Validate modified file contents and expected stdout/stderr.
832        let stdout_str = String::from_utf8(stdout).unwrap();
833        let stderr_str = String::from_utf8(stderr).unwrap();
834        let expected_out = format!(
835            "Success. Updated the following files:\nM {}\n",
836            path.display()
837        );
838        assert_eq!(stdout_str, expected_out);
839        assert_eq!(stderr_str, "");
840        let contents = fs::read_to_string(&path).unwrap();
841        assert_eq!(contents, "foo\nbaz\n");
842    }
843
844    #[test]
845    fn test_update_file_hunk_can_move_file() {
846        let dir = tempdir().unwrap();
847        let src = dir.path().join("src.txt");
848        let dest = dir.path().join("dst.txt");
849        fs::write(&src, "line\n").unwrap();
850        let patch = wrap_patch(&format!(
851            r#"*** Update File: {}
852*** Move to: {}
853@@
854-line
855+line2"#,
856            src.display(),
857            dest.display()
858        ));
859        let mut stdout = Vec::new();
860        let mut stderr = Vec::new();
861        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
862        // Validate move semantics and expected stdout/stderr.
863        let stdout_str = String::from_utf8(stdout).unwrap();
864        let stderr_str = String::from_utf8(stderr).unwrap();
865        let expected_out = format!(
866            "Success. Updated the following files:\nM {}\n",
867            dest.display()
868        );
869        assert_eq!(stdout_str, expected_out);
870        assert_eq!(stderr_str, "");
871        assert!(!src.exists());
872        let contents = fs::read_to_string(&dest).unwrap();
873        assert_eq!(contents, "line2\n");
874    }
875
876    /// Verify that a single `Update File` hunk with multiple change chunks can update different
877    /// parts of a file and that the file is listed only once in the summary.
878    #[test]
879    fn test_multiple_update_chunks_apply_to_single_file() {
880        // Start with a file containing four lines.
881        let dir = tempdir().unwrap();
882        let path = dir.path().join("multi.txt");
883        fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
884        // Construct an update patch with two separate change chunks.
885        // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
886        // The second chunk uses `baz` as context and transforms `qux` into `QUX`.
887        let patch = wrap_patch(&format!(
888            r#"*** Update File: {}
889@@
890 foo
891-bar
892+BAR
893@@
894 baz
895-qux
896+QUX"#,
897            path.display()
898        ));
899        let mut stdout = Vec::new();
900        let mut stderr = Vec::new();
901        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
902        let stdout_str = String::from_utf8(stdout).unwrap();
903        let stderr_str = String::from_utf8(stderr).unwrap();
904        let expected_out = format!(
905            "Success. Updated the following files:\nM {}\n",
906            path.display()
907        );
908        assert_eq!(stdout_str, expected_out);
909        assert_eq!(stderr_str, "");
910        let contents = fs::read_to_string(&path).unwrap();
911        assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
912    }
913
914    /// A more involved `Update File` hunk that exercises additions, deletions and
915    /// replacements in separate chunks that appear in non‑adjacent parts of the
916    /// file.  Verifies that all edits are applied and that the summary lists the
917    /// file only once.
918    #[test]
919    fn test_update_file_hunk_interleaved_changes() {
920        let dir = tempdir().unwrap();
921        let path = dir.path().join("interleaved.txt");
922
923        // Original file: six numbered lines.
924        fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
925
926        // Patch performs:
927        //  • Replace `b` → `B`
928        //  • Replace `e` → `E` (using surrounding context)
929        //  • Append new line `g` at the end‑of‑file
930        let patch = wrap_patch(&format!(
931            r#"*** Update File: {}
932@@
933 a
934-b
935+B
936@@
937 c
938 d
939-e
940+E
941@@
942 f
943+g
944*** End of File"#,
945            path.display()
946        ));
947
948        let mut stdout = Vec::new();
949        let mut stderr = Vec::new();
950        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
951
952        let stdout_str = String::from_utf8(stdout).unwrap();
953        let stderr_str = String::from_utf8(stderr).unwrap();
954
955        let expected_out = format!(
956            "Success. Updated the following files:\nM {}\n",
957            path.display()
958        );
959        assert_eq!(stdout_str, expected_out);
960        assert_eq!(stderr_str, "");
961
962        let contents = fs::read_to_string(&path).unwrap();
963        assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
964    }
965
966    /// Ensure that patches authored with ASCII characters can update lines that
967    /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
968    /// HYPHEN). Historically `git apply` succeeds in such scenarios but our
969    /// internal matcher failed requiring an exact byte-for-byte match.  The
970    /// fuzzy-matching pass that normalises common punctuation should now bridge
971    /// the gap.
972    #[test]
973    fn test_update_line_with_unicode_dash() {
974        let dir = tempdir().unwrap();
975        let path = dir.path().join("unicode.py");
976
977        // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
978        let original = "import asyncio  # local import \u{2013} avoids top\u{2011}level dep\n";
979        std::fs::write(&path, original).unwrap();
980
981        // Patch uses plain ASCII dash / hyphen.
982        let patch = wrap_patch(&format!(
983            r#"*** Update File: {}
984@@
985-import asyncio  # local import - avoids top-level dep
986+import asyncio  # HELLO"#,
987            path.display()
988        ));
989
990        let mut stdout = Vec::new();
991        let mut stderr = Vec::new();
992        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
993
994        // File should now contain the replaced comment.
995        let expected = "import asyncio  # HELLO\n";
996        let contents = std::fs::read_to_string(&path).unwrap();
997        assert_eq!(contents, expected);
998
999        // Ensure success summary lists the file as modified.
1000        let stdout_str = String::from_utf8(stdout).unwrap();
1001        let expected_out = format!(
1002            "Success. Updated the following files:\nM {}\n",
1003            path.display()
1004        );
1005        assert_eq!(stdout_str, expected_out);
1006
1007        // No stderr expected.
1008        assert_eq!(String::from_utf8(stderr).unwrap(), "");
1009    }
1010
1011    #[test]
1012    fn test_unified_diff() {
1013        // Start with a file containing four lines.
1014        let dir = tempdir().unwrap();
1015        let path = dir.path().join("multi.txt");
1016        fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1017        let patch = wrap_patch(&format!(
1018            r#"*** Update File: {}
1019@@
1020 foo
1021-bar
1022+BAR
1023@@
1024 baz
1025-qux
1026+QUX"#,
1027            path.display()
1028        ));
1029        let patch = parse_patch(&patch).unwrap();
1030
1031        let update_file_chunks = match patch.hunks.as_slice() {
1032            [Hunk::UpdateFile { chunks, .. }] => chunks,
1033            _ => panic!("Expected a single UpdateFile hunk"),
1034        };
1035        let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap();
1036        let expected_diff = r#"@@ -1,4 +1,4 @@
1037 foo
1038-bar
1039+BAR
1040 baz
1041-qux
1042+QUX
1043"#;
1044        let expected = ApplyPatchFileUpdate {
1045            unified_diff: expected_diff.to_string(),
1046            content: "foo\nBAR\nbaz\nQUX\n".to_string(),
1047        };
1048        assert_eq!(expected, diff);
1049    }
1050
1051    #[test]
1052    fn test_unified_diff_first_line_replacement() {
1053        // Replace the very first line of the file.
1054        let dir = tempdir().unwrap();
1055        let path = dir.path().join("first.txt");
1056        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1057
1058        let patch = wrap_patch(&format!(
1059            r#"*** Update File: {}
1060@@
1061-foo
1062+FOO
1063 bar
1064"#,
1065            path.display()
1066        ));
1067
1068        let patch = parse_patch(&patch).unwrap();
1069        let chunks = match patch.hunks.as_slice() {
1070            [Hunk::UpdateFile { chunks, .. }] => chunks,
1071            _ => panic!("Expected a single UpdateFile hunk"),
1072        };
1073
1074        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1075        let expected_diff = r#"@@ -1,2 +1,2 @@
1076-foo
1077+FOO
1078 bar
1079"#;
1080        let expected = ApplyPatchFileUpdate {
1081            unified_diff: expected_diff.to_string(),
1082            content: "FOO\nbar\nbaz\n".to_string(),
1083        };
1084        assert_eq!(expected, diff);
1085    }
1086
1087    #[test]
1088    fn test_unified_diff_last_line_replacement() {
1089        // Replace the very last line of the file.
1090        let dir = tempdir().unwrap();
1091        let path = dir.path().join("last.txt");
1092        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1093
1094        let patch = wrap_patch(&format!(
1095            r#"*** Update File: {}
1096@@
1097 foo
1098 bar
1099-baz
1100+BAZ
1101"#,
1102            path.display()
1103        ));
1104
1105        let patch = parse_patch(&patch).unwrap();
1106        let chunks = match patch.hunks.as_slice() {
1107            [Hunk::UpdateFile { chunks, .. }] => chunks,
1108            _ => panic!("Expected a single UpdateFile hunk"),
1109        };
1110
1111        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1112        let expected_diff = r#"@@ -2,2 +2,2 @@
1113 bar
1114-baz
1115+BAZ
1116"#;
1117        let expected = ApplyPatchFileUpdate {
1118            unified_diff: expected_diff.to_string(),
1119            content: "foo\nbar\nBAZ\n".to_string(),
1120        };
1121        assert_eq!(expected, diff);
1122    }
1123
1124    #[test]
1125    fn test_unified_diff_insert_at_eof() {
1126        // Insert a new line at end‑of‑file.
1127        let dir = tempdir().unwrap();
1128        let path = dir.path().join("insert.txt");
1129        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1130
1131        let patch = wrap_patch(&format!(
1132            r#"*** Update File: {}
1133@@
1134+quux
1135*** End of File
1136"#,
1137            path.display()
1138        ));
1139
1140        let patch = parse_patch(&patch).unwrap();
1141        let chunks = match patch.hunks.as_slice() {
1142            [Hunk::UpdateFile { chunks, .. }] => chunks,
1143            _ => panic!("Expected a single UpdateFile hunk"),
1144        };
1145
1146        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1147        let expected_diff = r#"@@ -3 +3,2 @@
1148 baz
1149+quux
1150"#;
1151        let expected = ApplyPatchFileUpdate {
1152            unified_diff: expected_diff.to_string(),
1153            content: "foo\nbar\nbaz\nquux\n".to_string(),
1154        };
1155        assert_eq!(expected, diff);
1156    }
1157
1158    #[test]
1159    fn test_unified_diff_interleaved_changes() {
1160        // Original file with six lines.
1161        let dir = tempdir().unwrap();
1162        let path = dir.path().join("interleaved.txt");
1163        fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1164
1165        // Patch replaces two separate lines and appends a new one at EOF using
1166        // three distinct chunks.
1167        let patch_body = format!(
1168            r#"*** Update File: {}
1169@@
1170 a
1171-b
1172+B
1173@@
1174 d
1175-e
1176+E
1177@@
1178 f
1179+g
1180*** End of File"#,
1181            path.display()
1182        );
1183        let patch = wrap_patch(&patch_body);
1184
1185        // Extract chunks then build the unified diff.
1186        let parsed = parse_patch(&patch).unwrap();
1187        let chunks = match parsed.hunks.as_slice() {
1188            [Hunk::UpdateFile { chunks, .. }] => chunks,
1189            _ => panic!("Expected a single UpdateFile hunk"),
1190        };
1191
1192        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1193
1194        let expected_diff = r#"@@ -1,6 +1,7 @@
1195 a
1196-b
1197+B
1198 c
1199 d
1200-e
1201+E
1202 f
1203+g
1204"#;
1205
1206        let expected = ApplyPatchFileUpdate {
1207            unified_diff: expected_diff.to_string(),
1208            content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
1209        };
1210
1211        assert_eq!(expected, diff);
1212
1213        let mut stdout = Vec::new();
1214        let mut stderr = Vec::new();
1215        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1216        let contents = fs::read_to_string(path).unwrap();
1217        assert_eq!(
1218            contents,
1219            r#"a
1220B
1221c
1222d
1223E
1224f
1225g
1226"#
1227        );
1228    }
1229
1230    #[test]
1231    fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
1232        let session_dir = tempdir().unwrap();
1233        let relative_path = "source.txt";
1234
1235        // Note that we need this file to exist for the patch to be "verified"
1236        // and parsed correctly.
1237        let session_file_path = session_dir.path().join(relative_path);
1238        fs::write(&session_file_path, "session directory content\n").unwrap();
1239
1240        let argv = vec![
1241            "apply_patch".to_string(),
1242            r#"*** Begin Patch
1243*** Update File: source.txt
1244@@
1245-session directory content
1246+updated session directory content
1247*** End Patch"#
1248                .to_string(),
1249        ];
1250
1251        let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
1252
1253        // Verify the patch contents - as otherwise we may have pulled contents
1254        // from the wrong file (as we're using relative paths)
1255        assert_eq!(
1256            result,
1257            MaybeApplyPatchVerified::Body(ApplyPatchAction {
1258                changes: HashMap::from([(
1259                    session_dir.path().join(relative_path),
1260                    ApplyPatchFileChange::Update {
1261                        unified_diff: r#"@@ -1 +1 @@
1262-session directory content
1263+updated session directory content
1264"#
1265                        .to_string(),
1266                        move_path: None,
1267                        new_content: "updated session directory content\n".to_string(),
1268                    },
1269                )]),
1270                patch: argv[1].clone(),
1271                cwd: session_dir.path().to_path_buf(),
1272            })
1273        );
1274    }
1275
1276    #[test]
1277    fn test_apply_patch_fails_on_write_error() {
1278        let dir = tempdir().unwrap();
1279        let path = dir.path().join("readonly.txt");
1280        fs::write(&path, "before\n").unwrap();
1281        let mut perms = fs::metadata(&path).unwrap().permissions();
1282        perms.set_readonly(true);
1283        fs::set_permissions(&path, perms).unwrap();
1284
1285        let patch = wrap_patch(&format!(
1286            "*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
1287            path.display()
1288        ));
1289
1290        let mut stdout = Vec::new();
1291        let mut stderr = Vec::new();
1292        let result = apply_patch(&patch, &mut stdout, &mut stderr);
1293        assert!(result.is_err());
1294    }
1295}