Skip to main content

hanzo_apply_patch/
lib.rs

1mod parser;
2mod seek_sequence;
3mod standalone_executable;
4
5use std::collections::HashMap;
6use std::path::Path;
7use std::path::PathBuf;
8use std::str::Utf8Error;
9use std::sync::LazyLock;
10
11use anyhow::Context;
12use anyhow::Result;
13pub use parser::Hunk;
14pub use parser::ParseError;
15use parser::ParseError::*;
16use parser::UpdateFileChunk;
17pub use parser::parse_patch;
18use similar::TextDiff;
19use thiserror::Error;
20use tree_sitter::LanguageError;
21use tree_sitter::Parser;
22use tree_sitter::Query;
23use tree_sitter::QueryCursor;
24use tree_sitter::StreamingIterator;
25use tree_sitter_bash::LANGUAGE as BASH;
26
27pub use standalone_executable::main;
28
29// Back-compat shim for codex-core callers
30// The core crate expects a simple async FileSystem abstraction and a default
31// StdFileSystem implementation. Upstream refactored apply-patch to operate
32// directly on std::fs; we preserve these minimal exports here so downstream
33// code (core/acp.rs, core/apply_patch.rs, core/codex.rs) continues to compile
34// without changes.
35#[allow(async_fn_in_trait)]
36pub trait FileSystem {
37    async fn read_text_file(&self, path: &Path) -> std::io::Result<String>;
38    async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()>;
39}
40
41pub struct StdFileSystem;
42
43impl FileSystem for StdFileSystem {
44    async fn read_text_file(&self, path: &Path) -> std::io::Result<String> {
45        std::fs::read_to_string(path)
46    }
47
48    async fn write_text_file(&self, path: &Path, contents: String) -> std::io::Result<()> {
49        std::fs::write(path, contents)
50    }
51}
52
53/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
54pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
55
56const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum ApplyPatchShell {
60    Unix,
61    PowerShell,
62    Cmd,
63}
64
65fn classify_shell_name(shell: &str) -> Option<String> {
66    Path::new(shell)
67        .file_stem()
68        .and_then(|name| name.to_str())
69        .map(str::to_ascii_lowercase)
70}
71
72fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
73    classify_shell_name(shell).and_then(|name| match name.as_str() {
74        "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
75        "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
76            Some(ApplyPatchShell::PowerShell)
77        }
78        "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
79        _ => None,
80    })
81}
82
83fn can_skip_flag(shell: &str, flag: &str) -> bool {
84    classify_shell_name(shell).is_some_and(|name| {
85        matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
86    })
87}
88
89fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
90    match argv {
91        [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
92            let script = script.as_str();
93            (shell_type, script)
94        }),
95        [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
96            classify_shell(shell, flag).map(|shell_type| {
97                let script = script.as_str();
98                (shell_type, script)
99            })
100        }
101        _ => None,
102    }
103}
104
105fn extract_apply_patch_from_shell(
106    shell: ApplyPatchShell,
107    script: &str,
108) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
109    match shell {
110        ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
111            extract_apply_patch_from_bash(script)
112        }
113    }
114}
115
116#[derive(Debug, Error, PartialEq)]
117pub enum ApplyPatchError {
118    #[error(transparent)]
119    ParseError(#[from] ParseError),
120    #[error(transparent)]
121    IoError(#[from] IoError),
122    /// Error that occurs while computing replacements when applying patch chunks
123    #[error("{0}")]
124    ComputeReplacements(String),
125    /// A raw patch body was provided without an explicit `apply_patch` invocation.
126    #[error(
127        "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
128    )]
129    ImplicitInvocation,
130}
131
132impl From<std::io::Error> for ApplyPatchError {
133    fn from(err: std::io::Error) -> Self {
134        ApplyPatchError::IoError(IoError {
135            context: "I/O error".to_string(),
136            source: err,
137        })
138    }
139}
140
141impl From<&std::io::Error> for ApplyPatchError {
142    fn from(err: &std::io::Error) -> Self {
143        ApplyPatchError::IoError(IoError {
144            context: "I/O error".to_string(),
145            source: std::io::Error::new(err.kind(), err.to_string()),
146        })
147    }
148}
149
150#[derive(Debug, Error)]
151#[error("{context}: {source}")]
152pub struct IoError {
153    context: String,
154    #[source]
155    source: std::io::Error,
156}
157
158impl PartialEq for IoError {
159    fn eq(&self, other: &Self) -> bool {
160        self.context == other.context && self.source.to_string() == other.source.to_string()
161    }
162}
163
164#[derive(Debug, PartialEq)]
165pub enum MaybeApplyPatch {
166    Body(ApplyPatchArgs),
167    ShellParseError(ExtractHeredocError),
168    PatchParseError(ParseError),
169    NotApplyPatch,
170}
171
172/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
173/// parsed into hunks.
174#[derive(Debug, PartialEq)]
175pub struct ApplyPatchArgs {
176    pub patch: String,
177    pub hunks: Vec<Hunk>,
178    pub workdir: Option<String>,
179}
180
181pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
182    match argv {
183        // Direct invocation: apply_patch <patch>
184        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
185            Ok(source) => MaybeApplyPatch::Body(source),
186            Err(e) => MaybeApplyPatch::PatchParseError(e),
187        },
188        // Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
189        _ => match parse_shell_script(argv) {
190            Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
191                Ok((body, workdir)) => match parse_patch(&body) {
192                    Ok(mut source) => {
193                        source.workdir = workdir;
194                        MaybeApplyPatch::Body(source)
195                    }
196                    Err(e) => MaybeApplyPatch::PatchParseError(e),
197                },
198                Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
199                    MaybeApplyPatch::NotApplyPatch
200                }
201                Err(e) => MaybeApplyPatch::ShellParseError(e),
202            },
203            None => MaybeApplyPatch::NotApplyPatch,
204        },
205    }
206}
207
208#[derive(Debug, PartialEq)]
209pub enum ApplyPatchFileChange {
210    Add {
211        content: String,
212    },
213    Delete {
214        content: String,
215    },
216    Update {
217        unified_diff: String,
218        move_path: Option<PathBuf>,
219        /// new_content that will result after the unified_diff is applied.
220        new_content: String,
221    },
222}
223
224#[derive(Debug, PartialEq)]
225pub enum MaybeApplyPatchVerified {
226    /// `argv` corresponded to an `apply_patch` invocation, and these are the
227    /// resulting proposed file changes.
228    Body(ApplyPatchAction),
229    /// `argv` could not be parsed to determine whether it corresponds to an
230    /// `apply_patch` invocation.
231    ShellParseError(ExtractHeredocError),
232    /// `argv` corresponded to an `apply_patch` invocation, but it could not
233    /// be fulfilled due to the specified error.
234    CorrectnessError(ApplyPatchError),
235    /// `argv` decidedly did not correspond to an `apply_patch` invocation.
236    NotApplyPatch,
237}
238
239/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
240/// construction, all paths should be absolute paths.
241#[derive(Debug, PartialEq)]
242pub struct ApplyPatchAction {
243    changes: HashMap<PathBuf, ApplyPatchFileChange>,
244
245    /// The raw patch argument that can be used with `apply_patch` as an exec
246    /// call. i.e., if the original arg was parsed in "lenient" mode with a
247    /// heredoc, this should be the value without the heredoc wrapper.
248    pub patch: String,
249
250    /// The working directory that was used to resolve relative paths in the patch.
251    pub cwd: PathBuf,
252}
253
254impl ApplyPatchAction {
255    pub fn is_empty(&self) -> bool {
256        self.changes.is_empty()
257    }
258
259    /// Returns the changes that would be made by applying the patch.
260    pub fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
261        &self.changes
262    }
263
264    /// Should be used exclusively for testing. (Not worth the overhead of
265    /// creating a feature flag for this.)
266    pub fn new_add_for_test(path: &Path, content: String) -> Self {
267        if !path.is_absolute() {
268            panic!("path must be absolute");
269        }
270
271        #[expect(clippy::expect_used)]
272        let filename = path
273            .file_name()
274            .expect("path should not be empty")
275            .to_string_lossy();
276        let patch = format!(
277            r#"*** Begin Patch
278*** Update File: {filename}
279@@
280+ {content}
281*** End Patch"#,
282        );
283        let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
284        #[expect(clippy::expect_used)]
285        Self {
286            changes,
287            cwd: path
288                .parent()
289                .expect("path should have parent")
290                .to_path_buf(),
291            patch,
292        }
293    }
294}
295
296/// cwd must be an absolute path so that we can resolve relative paths in the
297/// patch.
298pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
299    // Detect a raw patch body passed directly as the command or as the body of a shell
300    // script. In these cases, report an explicit error rather than applying the patch.
301    if argv.len() == 1 {
302        let body = &argv[0];
303        if parse_patch(body).is_ok() {
304            return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
305        }
306    }
307
308    if let Some((_, script)) = parse_shell_script(argv)
309        && parse_patch(script).is_ok()
310    {
311        return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
312    }
313
314    match maybe_parse_apply_patch(argv) {
315        MaybeApplyPatch::Body(ApplyPatchArgs {
316            patch,
317            hunks,
318            workdir,
319        }) => {
320            let effective_cwd = workdir
321                .as_ref()
322                .map(|dir| {
323                    let path = Path::new(dir);
324                    if path.is_absolute() {
325                        path.to_path_buf()
326                    } else {
327                        cwd.join(path)
328                    }
329                })
330                .unwrap_or_else(|| cwd.to_path_buf());
331            let mut changes = HashMap::new();
332            for hunk in hunks {
333                let path = hunk.resolve_path(&effective_cwd);
334                match hunk {
335                    Hunk::AddFile { contents, .. } => {
336                        changes.insert(path, ApplyPatchFileChange::Add { content: contents });
337                    }
338                    Hunk::DeleteFile { .. } => {
339                        let content = match std::fs::read_to_string(&path) {
340                            Ok(content) => content,
341                            Err(e) => {
342                                return MaybeApplyPatchVerified::CorrectnessError(
343                                    ApplyPatchError::IoError(IoError {
344                                        context: format!("Failed to read {}", path.display()),
345                                        source: e,
346                                    }),
347                                );
348                            }
349                        };
350                        changes.insert(path, ApplyPatchFileChange::Delete { content });
351                    }
352                    Hunk::UpdateFile {
353                        move_path, chunks, ..
354                    } => {
355                        let ApplyPatchFileUpdate {
356                            unified_diff,
357                            content: contents,
358                        } = match unified_diff_from_chunks(&path, &chunks) {
359                            Ok(diff) => diff,
360                            Err(e) => {
361                                return MaybeApplyPatchVerified::CorrectnessError(e);
362                            }
363                        };
364                        changes.insert(
365                            path,
366                            ApplyPatchFileChange::Update {
367                                unified_diff,
368                                move_path: move_path.map(|p| effective_cwd.join(p)),
369                                new_content: contents,
370                            },
371                        );
372                    }
373                }
374            }
375            MaybeApplyPatchVerified::Body(ApplyPatchAction {
376                changes,
377                patch,
378                cwd: effective_cwd,
379            })
380        }
381        MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
382        MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
383        MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
384    }
385}
386
387/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
388/// that invokes the apply_patch tool using a heredoc.
389///
390/// Supported top‑level forms (must be the only top‑level statement):
391/// - `apply_patch <<'EOF'\n...\nEOF`
392/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
393///
394/// Notes about matching:
395/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the
396///   heredoc‑redirected statement is the only top‑level statement.
397/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
398/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
399///   strings, no second argument).
400/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch`
401///   or `applypatch`.
402/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
403///
404/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
405/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
406/// cannot be parsed or does not match the allowed patterns.
407fn extract_apply_patch_from_bash(
408    src: &str,
409) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
410    // This function uses a Tree-sitter query to recognize one of two
411    // whole-script forms, each expressed as a single top-level statement:
412    //
413    // 1. apply_patch <<'EOF'\n...\nEOF
414    // 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
415    //
416    // Key ideas when reading the query:
417    // - dots (`.`) between named nodes enforces adjacency among named children and
418    //   anchor to the start/end of the expression.
419    // - we match a single redirected_statement directly under program with leading
420    //   and trailing anchors (`.`). This ensures it is the only top-level statement
421    //   (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
422    //
423    // Overall, we want to be conservative and only match the intended forms, as other
424    // forms are likely to be model errors, or incorrectly interpreted by later code.
425    //
426    // If you're editing this query, it's helpful to start by creating a debugging binary
427    // which will let you see the AST of an arbitrary bash script passed in, and optionally
428    // also run an arbitrary query against the AST. This is useful for understanding
429    // how tree-sitter parses the script and whether the query syntax is correct. Be sure
430    // to test both positive and negative cases.
431    static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
432        let language = BASH.into();
433        #[expect(clippy::expect_used)]
434        Query::new(
435            &language,
436            r#"
437            (
438              program
439                . (redirected_statement
440                    body: (command
441                            name: (command_name (word) @apply_name) .)
442                    (#any-of? @apply_name "apply_patch" "applypatch")
443                    redirect: (heredoc_redirect
444                                . (heredoc_start)
445                                . (heredoc_body) @heredoc
446                                . (heredoc_end)
447                                .))
448                .)
449
450            (
451              program
452                . (redirected_statement
453                    body: (list
454                            . (command
455                                name: (command_name (word) @cd_name) .
456                                argument: [
457                                  (word) @cd_path
458                                  (string (string_content) @cd_path)
459                                  (raw_string) @cd_raw_string
460                                ] .)
461                            "&&"
462                            . (command
463                                name: (command_name (word) @apply_name))
464                            .)
465                    (#eq? @cd_name "cd")
466                    (#any-of? @apply_name "apply_patch" "applypatch")
467                    redirect: (heredoc_redirect
468                                . (heredoc_start)
469                                . (heredoc_body) @heredoc
470                                . (heredoc_end)
471                                .))
472                .)
473            "#,
474        )
475        .expect("valid bash query")
476    });
477
478    let lang = BASH.into();
479    let mut parser = Parser::new();
480    parser
481        .set_language(&lang)
482        .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
483    let tree = parser
484        .parse(src, None)
485        .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
486
487    let bytes = src.as_bytes();
488    let root = tree.root_node();
489
490    let mut cursor = QueryCursor::new();
491    let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
492    while let Some(m) = matches.next() {
493        let mut heredoc_text: Option<String> = None;
494        let mut cd_path: Option<String> = None;
495
496        for capture in m.captures.iter() {
497            let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
498            match name {
499                "heredoc" => {
500                    let text = capture
501                        .node
502                        .utf8_text(bytes)
503                        .map_err(ExtractHeredocError::HeredocNotUtf8)?
504                        .trim_end_matches('\n')
505                        .to_string();
506                    heredoc_text = Some(text);
507                }
508                "cd_path" => {
509                    let text = capture
510                        .node
511                        .utf8_text(bytes)
512                        .map_err(ExtractHeredocError::HeredocNotUtf8)?
513                        .to_string();
514                    cd_path = Some(text);
515                }
516                "cd_raw_string" => {
517                    let raw = capture
518                        .node
519                        .utf8_text(bytes)
520                        .map_err(ExtractHeredocError::HeredocNotUtf8)?;
521                    let trimmed = raw
522                        .strip_prefix('\'')
523                        .and_then(|s| s.strip_suffix('\''))
524                        .unwrap_or(raw);
525                    cd_path = Some(trimmed.to_string());
526                }
527                _ => {}
528            }
529        }
530
531        if let Some(heredoc) = heredoc_text {
532            return Ok((heredoc, cd_path));
533        }
534    }
535
536    Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
537}
538
539#[derive(Debug, PartialEq)]
540pub enum ExtractHeredocError {
541    CommandDidNotStartWithApplyPatch,
542    FailedToLoadBashGrammar(LanguageError),
543    HeredocNotUtf8(Utf8Error),
544    FailedToParsePatchIntoAst,
545    FailedToFindHeredocBody,
546}
547
548/// Applies the patch and prints the result to stdout/stderr.
549pub fn apply_patch(
550    patch: &str,
551    stdout: &mut impl std::io::Write,
552    stderr: &mut impl std::io::Write,
553) -> Result<(), ApplyPatchError> {
554    let hunks = match parse_patch(patch) {
555        Ok(source) => source.hunks,
556        Err(e) => {
557            match &e {
558                InvalidPatchError(message) => {
559                    writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?;
560                }
561                InvalidHunkError {
562                    message,
563                    line_number,
564                } => {
565                    writeln!(
566                        stderr,
567                        "Invalid patch hunk on line {line_number}: {message}"
568                    )
569                    .map_err(ApplyPatchError::from)?;
570                }
571            }
572            return Err(ApplyPatchError::ParseError(e));
573        }
574    };
575
576    apply_hunks(&hunks, stdout, stderr)?;
577
578    Ok(())
579}
580
581/// Applies hunks and continues to update stdout/stderr
582pub fn apply_hunks(
583    hunks: &[Hunk],
584    stdout: &mut impl std::io::Write,
585    stderr: &mut impl std::io::Write,
586) -> Result<(), ApplyPatchError> {
587    let _existing_paths: Vec<&Path> = hunks
588        .iter()
589        .filter_map(|hunk| match hunk {
590            Hunk::AddFile { .. } => {
591                // The file is being added, so it doesn't exist yet.
592                None
593            }
594            Hunk::DeleteFile { path } => Some(path.as_path()),
595            Hunk::UpdateFile {
596                path, move_path, ..
597            } => match move_path {
598                Some(move_path) => {
599                    if std::fs::metadata(move_path)
600                        .map(|m| m.is_file())
601                        .unwrap_or(false)
602                    {
603                        Some(move_path.as_path())
604                    } else {
605                        None
606                    }
607                }
608                None => Some(path.as_path()),
609            },
610        })
611        .collect::<Vec<&Path>>();
612
613    // Delegate to a helper that applies each hunk to the filesystem.
614    match apply_hunks_to_files(hunks) {
615        Ok(affected) => {
616            print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
617            Ok(())
618        }
619        Err(err) => {
620            let msg = err.to_string();
621            writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
622            if let Some(io) = err.downcast_ref::<std::io::Error>() {
623                Err(ApplyPatchError::from(io))
624            } else {
625                Err(ApplyPatchError::IoError(IoError {
626                    context: msg,
627                    source: std::io::Error::other(err),
628                }))
629            }
630        }
631    }
632}
633
634/// Applies each parsed patch hunk to the filesystem.
635/// Returns an error if any of the changes could not be applied.
636/// Tracks file paths affected by applying a patch.
637pub struct AffectedPaths {
638    pub added: Vec<PathBuf>,
639    pub modified: Vec<PathBuf>,
640    pub deleted: Vec<PathBuf>,
641}
642
643/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted.
644/// Returns an error if the patch could not be applied.
645fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result<AffectedPaths> {
646    if hunks.is_empty() {
647        anyhow::bail!("No files were modified.");
648    }
649
650    let mut added: Vec<PathBuf> = Vec::new();
651    let mut modified: Vec<PathBuf> = Vec::new();
652    let mut deleted: Vec<PathBuf> = Vec::new();
653    for hunk in hunks {
654        match hunk {
655            Hunk::AddFile { path, contents } => {
656                if let Some(parent) = path.parent()
657                    && !parent.as_os_str().is_empty()
658                {
659                    std::fs::create_dir_all(parent).with_context(|| {
660                        format!("Failed to create parent directories for {}", path.display())
661                    })?;
662                }
663                std::fs::write(path, contents)
664                    .with_context(|| format!("Failed to write file {}", path.display()))?;
665                added.push(path.clone());
666            }
667            Hunk::DeleteFile { path } => {
668                std::fs::remove_file(path)
669                    .with_context(|| format!("Failed to delete file {}", path.display()))?;
670                deleted.push(path.clone());
671            }
672            Hunk::UpdateFile {
673                path,
674                move_path,
675                chunks,
676            } => {
677                let AppliedPatch { new_contents, .. } =
678                    derive_new_contents_from_chunks(path, chunks)?;
679                if let Some(dest) = move_path {
680                    if let Some(parent) = dest.parent()
681                        && !parent.as_os_str().is_empty()
682                    {
683                        std::fs::create_dir_all(parent).with_context(|| {
684                            format!("Failed to create parent directories for {}", dest.display())
685                        })?;
686                    }
687                    std::fs::write(dest, new_contents)
688                        .with_context(|| format!("Failed to write file {}", dest.display()))?;
689                    std::fs::remove_file(path)
690                        .with_context(|| format!("Failed to remove original {}", path.display()))?;
691                    modified.push(dest.clone());
692                } else {
693                    std::fs::write(path, new_contents)
694                        .with_context(|| format!("Failed to write file {}", path.display()))?;
695                    modified.push(path.clone());
696                }
697            }
698        }
699    }
700    Ok(AffectedPaths {
701        added,
702        modified,
703        deleted,
704    })
705}
706
707struct AppliedPatch {
708    original_contents: String,
709    new_contents: String,
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713enum LineEnding {
714    Lf,
715    Crlf,
716}
717
718impl LineEnding {
719    fn detect(contents: &str) -> Self {
720        if contents.contains("\r\n") {
721            Self::Crlf
722        } else {
723            Self::Lf
724        }
725    }
726
727    fn joiner(self) -> &'static str {
728        match self {
729            Self::Lf => "\n",
730            Self::Crlf => "\r\n",
731        }
732    }
733}
734
735fn normalize_line_ending(line: &str) -> &str {
736    line.strip_suffix('\r').unwrap_or(line)
737}
738
739/// Return *only* the new file contents (joined into a single `String`) after
740/// applying the chunks to the file at `path`.
741fn derive_new_contents_from_chunks(
742    path: &Path,
743    chunks: &[UpdateFileChunk],
744) -> std::result::Result<AppliedPatch, ApplyPatchError> {
745    let original_contents = match std::fs::read_to_string(path) {
746        Ok(contents) => contents,
747        Err(err) => {
748            return Err(ApplyPatchError::IoError(IoError {
749                context: format!("Failed to read file to update {}", path.display()),
750                source: err,
751            }));
752        }
753    };
754
755    let line_ending = LineEnding::detect(&original_contents);
756    let mut original_lines: Vec<String> = original_contents
757        .split('\n')
758        .map(|line| normalize_line_ending(line).to_string())
759        .collect();
760
761    // Drop the trailing empty element that results from the final newline so
762    // that line counts match the behaviour of standard `diff`.
763    if original_lines.last().is_some_and(String::is_empty) {
764        original_lines.pop();
765    }
766
767    let replacements = compute_replacements(&original_lines, path, chunks)?;
768    let new_lines = apply_replacements(original_lines, &replacements);
769    let mut new_lines = new_lines;
770    if !new_lines.last().is_some_and(String::is_empty) {
771        new_lines.push(String::new());
772    }
773    let new_contents = new_lines.join(line_ending.joiner());
774    Ok(AppliedPatch {
775        original_contents,
776        new_contents,
777    })
778}
779
780/// Compute a list of replacements needed to transform `original_lines` into the
781/// new lines, given the patch `chunks`. Each replacement is returned as
782/// `(start_index, old_len, new_lines)`.
783fn compute_replacements(
784    original_lines: &[String],
785    path: &Path,
786    chunks: &[UpdateFileChunk],
787) -> std::result::Result<Vec<(usize, usize, Vec<String>)>, ApplyPatchError> {
788    let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
789    let mut line_index: usize = 0;
790
791    for chunk in chunks {
792        // If a chunk has a `change_context`, we use seek_sequence to find it, then
793        // adjust our `line_index` to continue from there.
794        if let Some(ctx_line) = &chunk.change_context {
795            if let Some(idx) = seek_sequence::seek_sequence(
796                original_lines,
797                std::slice::from_ref(ctx_line),
798                line_index,
799                false,
800            ) {
801                line_index = idx + 1;
802            } else {
803                return Err(ApplyPatchError::ComputeReplacements(format!(
804                    "Failed to find context '{}' in {}",
805                    ctx_line,
806                    path.display()
807                )));
808            }
809        }
810
811        if chunk.old_lines.is_empty() {
812            // Pure addition (no old lines). We'll add them at the end or just
813            // before the final empty line if one exists.
814            let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
815                original_lines.len() - 1
816            } else {
817                original_lines.len()
818            };
819            let normalized_new_lines: Vec<String> = chunk
820                .new_lines
821                .iter()
822                .map(|line| normalize_line_ending(line).to_string())
823                .collect();
824            replacements.push((insertion_idx, 0, normalized_new_lines));
825            continue;
826        }
827
828        // Otherwise, try to match the existing lines in the file with the old lines
829        // from the chunk. If found, schedule that region for replacement.
830        // Attempt to locate the `old_lines` verbatim within the file.  In many
831        // real‑world diffs the last element of `old_lines` is an *empty* string
832        // representing the terminating newline of the region being replaced.
833        // This sentinel is not present in `original_lines` because we strip the
834        // trailing empty slice emitted by `split('\n')`.  If a direct search
835        // fails and the pattern ends with an empty string, retry without that
836        // final element so that modifications touching the end‑of‑file can be
837        // located reliably.
838
839        let normalized_old_lines: Vec<String> = chunk
840            .old_lines
841            .iter()
842            .map(|line| normalize_line_ending(line).to_string())
843            .collect();
844        let normalized_new_lines: Vec<String> = chunk
845            .new_lines
846            .iter()
847            .map(|line| normalize_line_ending(line).to_string())
848            .collect();
849
850        let mut pattern: &[String] = &normalized_old_lines;
851        let mut found =
852            seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
853
854        let mut new_slice: &[String] = &normalized_new_lines;
855
856        if found.is_none() && pattern.last().is_some_and(String::is_empty) {
857            // Retry without the trailing empty line which represents the final
858            // newline in the file.
859            pattern = &pattern[..pattern.len() - 1];
860            if new_slice.last().is_some_and(String::is_empty) {
861                new_slice = &new_slice[..new_slice.len() - 1];
862            }
863
864            found = seek_sequence::seek_sequence(
865                original_lines,
866                pattern,
867                line_index,
868                chunk.is_end_of_file,
869            );
870        }
871
872        if let Some(start_idx) = found {
873            replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
874            line_index = start_idx + pattern.len();
875        } else {
876            return Err(ApplyPatchError::ComputeReplacements(format!(
877                "Failed to find expected lines in {}:\n{}",
878                path.display(),
879                normalized_old_lines.join("\n"),
880            )));
881        }
882    }
883
884    replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx));
885
886    Ok(replacements)
887}
888
889/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`,
890/// returning the modified file contents as a vector of lines.
891fn apply_replacements(
892    mut lines: Vec<String>,
893    replacements: &[(usize, usize, Vec<String>)],
894) -> Vec<String> {
895    // We must apply replacements in descending order so that earlier replacements
896    // don't shift the positions of later ones.
897    for (start_idx, old_len, new_segment) in replacements.iter().rev() {
898        let start_idx = *start_idx;
899        let old_len = *old_len;
900
901        // Remove old lines.
902        for _ in 0..old_len {
903            if start_idx < lines.len() {
904                lines.remove(start_idx);
905            }
906        }
907
908        // Insert new lines.
909        for (offset, new_line) in new_segment.iter().enumerate() {
910            lines.insert(start_idx + offset, new_line.clone());
911        }
912    }
913
914    lines
915}
916
917/// Intended result of a file update for apply_patch.
918#[derive(Debug, Eq, PartialEq)]
919pub struct ApplyPatchFileUpdate {
920    unified_diff: String,
921    content: String,
922}
923
924pub fn unified_diff_from_chunks(
925    path: &Path,
926    chunks: &[UpdateFileChunk],
927) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
928    unified_diff_from_chunks_with_context(path, chunks, 1)
929}
930
931pub fn unified_diff_from_chunks_with_context(
932    path: &Path,
933    chunks: &[UpdateFileChunk],
934    context: usize,
935) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
936    let AppliedPatch {
937        original_contents,
938        new_contents,
939    } = derive_new_contents_from_chunks(path, chunks)?;
940    let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
941    let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
942    Ok(ApplyPatchFileUpdate {
943        unified_diff,
944        content: new_contents,
945    })
946}
947
948/// Print the summary of changes in git-style format.
949/// Write a summary of changes to the given writer.
950pub fn print_summary(
951    affected: &AffectedPaths,
952    out: &mut impl std::io::Write,
953) -> std::io::Result<()> {
954    writeln!(out, "Success. Updated the following files:")?;
955    for path in &affected.added {
956        writeln!(out, "A {}", path.display())?;
957    }
958    for path in &affected.modified {
959        writeln!(out, "M {}", path.display())?;
960    }
961    for path in &affected.deleted {
962        writeln!(out, "D {}", path.display())?;
963    }
964    Ok(())
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use pretty_assertions::assert_eq;
971    use std::fs;
972    use std::string::ToString;
973    use tempfile::tempdir;
974
975    /// Helper to construct a patch with the given body.
976    fn wrap_patch(body: &str) -> String {
977        format!("*** Begin Patch\n{body}\n*** End Patch")
978    }
979
980    fn strs_to_strings(strs: &[&str]) -> Vec<String> {
981        strs.iter().map(ToString::to_string).collect()
982    }
983
984    // Test helpers to reduce repetition when building bash -lc heredoc scripts
985    fn args_bash(script: &str) -> Vec<String> {
986        strs_to_strings(&["bash", "-lc", script])
987    }
988
989    fn args_abs_bash(script: &str) -> Vec<String> {
990        strs_to_strings(&["/bin/bash", "-lc", script])
991    }
992
993    fn heredoc_script(prefix: &str) -> String {
994        format!(
995            "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
996        )
997    }
998
999    fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
1000        format!(
1001            "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
1002        )
1003    }
1004
1005    fn expected_single_add() -> Vec<Hunk> {
1006        vec![Hunk::AddFile {
1007            path: PathBuf::from("foo"),
1008            contents: "hi\n".to_string(),
1009        }]
1010    }
1011
1012    fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
1013        match maybe_parse_apply_patch(&args) {
1014            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
1015                assert_eq!(workdir.as_deref(), expected_workdir);
1016                assert_eq!(hunks, expected_single_add());
1017            }
1018            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1019        }
1020    }
1021
1022    fn assert_match(script: &str, expected_workdir: Option<&str>) {
1023        assert_match_args(args_bash(script), expected_workdir);
1024        assert_match_args(args_abs_bash(script), expected_workdir);
1025    }
1026
1027    fn assert_not_match_args(args: Vec<String>) {
1028        assert!(matches!(
1029            maybe_parse_apply_patch(&args),
1030            MaybeApplyPatch::NotApplyPatch
1031        ));
1032    }
1033
1034    fn assert_not_match(script: &str) {
1035        assert_not_match_args(args_bash(script));
1036        assert_not_match_args(args_abs_bash(script));
1037    }
1038
1039    #[test]
1040    fn test_implicit_patch_single_arg_is_error() {
1041        let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
1042        let args = vec![patch];
1043        let dir = tempdir().unwrap();
1044        assert!(matches!(
1045            maybe_parse_apply_patch_verified(&args, dir.path()),
1046            MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
1047        ));
1048    }
1049
1050    #[test]
1051    fn test_implicit_patch_bash_script_is_error() {
1052        let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
1053        let dir = tempdir().unwrap();
1054        for args in [args_bash(script), args_abs_bash(script)] {
1055            assert!(matches!(
1056                maybe_parse_apply_patch_verified(&args, dir.path()),
1057                MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
1058            ));
1059        }
1060    }
1061
1062    #[test]
1063    fn test_literal() {
1064        let args = strs_to_strings(&[
1065            "apply_patch",
1066            r#"*** Begin Patch
1067*** Add File: foo
1068+hi
1069*** End Patch
1070"#,
1071        ]);
1072
1073        match maybe_parse_apply_patch(&args) {
1074            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
1075                assert_eq!(
1076                    hunks,
1077                    vec![Hunk::AddFile {
1078                        path: PathBuf::from("foo"),
1079                        contents: "hi\n".to_string()
1080                    }]
1081                );
1082            }
1083            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1084        }
1085    }
1086
1087    #[test]
1088    fn test_literal_applypatch() {
1089        let args = strs_to_strings(&[
1090            "applypatch",
1091            r#"*** Begin Patch
1092*** Add File: foo
1093+hi
1094*** End Patch
1095"#,
1096        ]);
1097
1098        match maybe_parse_apply_patch(&args) {
1099            MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
1100                assert_eq!(
1101                    hunks,
1102                    vec![Hunk::AddFile {
1103                        path: PathBuf::from("foo"),
1104                        contents: "hi\n".to_string()
1105                    }]
1106                );
1107            }
1108            result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1109        }
1110    }
1111
1112    #[test]
1113    fn test_heredoc() {
1114        assert_match(&heredoc_script(""), None);
1115    }
1116
1117    #[test]
1118    fn test_heredoc_applypatch() {
1119        for args in [
1120            strs_to_strings(&[
1121                "bash",
1122                "-lc",
1123                r#"applypatch <<'PATCH'
1124*** Begin Patch
1125*** Add File: foo
1126+hi
1127*** End Patch
1128PATCH"#,
1129            ]),
1130            strs_to_strings(&[
1131                "/bin/bash",
1132                "-lc",
1133                r#"applypatch <<'PATCH'
1134*** Begin Patch
1135*** Add File: foo
1136+hi
1137*** End Patch
1138PATCH"#,
1139            ]),
1140        ] {
1141            match maybe_parse_apply_patch(&args) {
1142                MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
1143                    assert_eq!(workdir, None);
1144                    assert_eq!(
1145                        hunks,
1146                        vec![Hunk::AddFile {
1147                            path: PathBuf::from("foo"),
1148                            contents: "hi\n".to_string()
1149                        }]
1150                    );
1151                }
1152                result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
1153            }
1154        }
1155    }
1156
1157    #[test]
1158    fn test_heredoc_with_leading_cd() {
1159        assert_match(&heredoc_script("cd foo && "), Some("foo"));
1160    }
1161
1162    #[test]
1163    fn test_cd_with_semicolon_is_ignored() {
1164        assert_not_match(&heredoc_script("cd foo; "));
1165    }
1166
1167    #[test]
1168    fn test_cd_or_apply_patch_is_ignored() {
1169        assert_not_match(&heredoc_script("cd bar || "));
1170    }
1171
1172    #[test]
1173    fn test_cd_pipe_apply_patch_is_ignored() {
1174        assert_not_match(&heredoc_script("cd bar | "));
1175    }
1176
1177    #[test]
1178    fn test_cd_single_quoted_path_with_spaces() {
1179        assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
1180    }
1181
1182    #[test]
1183    fn test_cd_double_quoted_path_with_spaces() {
1184        assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
1185    }
1186
1187    #[test]
1188    fn test_echo_and_apply_patch_is_ignored() {
1189        assert_not_match(&heredoc_script("echo foo && "));
1190    }
1191
1192    #[test]
1193    fn test_apply_patch_with_arg_is_ignored() {
1194        let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
1195        assert_not_match(script);
1196    }
1197
1198    #[test]
1199    fn test_double_cd_then_apply_patch_is_ignored() {
1200        assert_not_match(&heredoc_script("cd foo && cd bar && "));
1201    }
1202
1203    #[test]
1204    fn test_cd_two_args_is_ignored() {
1205        assert_not_match(&heredoc_script("cd foo bar && "));
1206    }
1207
1208    #[test]
1209    fn test_cd_then_apply_patch_then_extra_is_ignored() {
1210        let script = heredoc_script_ps("cd bar && ", " && echo done");
1211        assert_not_match(&script);
1212    }
1213
1214    #[test]
1215    fn test_echo_then_cd_and_apply_patch_is_ignored() {
1216        // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
1217        assert_not_match(&heredoc_script("echo foo; cd bar && "));
1218    }
1219
1220    #[test]
1221    fn test_add_file_hunk_creates_file_with_contents() {
1222        let dir = tempdir().unwrap();
1223        let path = dir.path().join("add.txt");
1224        let patch = wrap_patch(&format!(
1225            r#"*** Add File: {}
1226+ab
1227+cd"#,
1228            path.display()
1229        ));
1230        let mut stdout = Vec::new();
1231        let mut stderr = Vec::new();
1232        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1233        // Verify expected stdout and stderr outputs.
1234        let stdout_str = String::from_utf8(stdout).unwrap();
1235        let stderr_str = String::from_utf8(stderr).unwrap();
1236        let expected_out = format!(
1237            "Success. Updated the following files:\nA {}\n",
1238            path.display()
1239        );
1240        assert_eq!(stdout_str, expected_out);
1241        assert_eq!(stderr_str, "");
1242        let contents = fs::read_to_string(path).unwrap();
1243        assert_eq!(contents, "ab\ncd\n");
1244    }
1245
1246    #[test]
1247    fn test_delete_file_hunk_removes_file() {
1248        let dir = tempdir().unwrap();
1249        let path = dir.path().join("del.txt");
1250        fs::write(&path, "x").unwrap();
1251        let patch = wrap_patch(&format!("*** Delete File: {}", path.display()));
1252        let mut stdout = Vec::new();
1253        let mut stderr = Vec::new();
1254        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1255        let stdout_str = String::from_utf8(stdout).unwrap();
1256        let stderr_str = String::from_utf8(stderr).unwrap();
1257        let expected_out = format!(
1258            "Success. Updated the following files:\nD {}\n",
1259            path.display()
1260        );
1261        assert_eq!(stdout_str, expected_out);
1262        assert_eq!(stderr_str, "");
1263        assert!(!path.exists());
1264    }
1265
1266    #[test]
1267    fn test_update_file_hunk_modifies_content() {
1268        let dir = tempdir().unwrap();
1269        let path = dir.path().join("update.txt");
1270        fs::write(&path, "foo\nbar\n").unwrap();
1271        let patch = wrap_patch(&format!(
1272            r#"*** Update File: {}
1273@@
1274 foo
1275-bar
1276+baz"#,
1277            path.display()
1278        ));
1279        let mut stdout = Vec::new();
1280        let mut stderr = Vec::new();
1281        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1282        // Validate modified file contents and expected stdout/stderr.
1283        let stdout_str = String::from_utf8(stdout).unwrap();
1284        let stderr_str = String::from_utf8(stderr).unwrap();
1285        let expected_out = format!(
1286            "Success. Updated the following files:\nM {}\n",
1287            path.display()
1288        );
1289        assert_eq!(stdout_str, expected_out);
1290        assert_eq!(stderr_str, "");
1291        let contents = fs::read_to_string(&path).unwrap();
1292        assert_eq!(contents, "foo\nbaz\n");
1293    }
1294
1295    #[test]
1296    fn test_update_file_hunk_preserves_crlf_line_endings() {
1297        let dir = tempdir().unwrap();
1298        let path = dir.path().join("update_crlf.txt");
1299        fs::write(&path, "foo\r\nbar\r\n").unwrap();
1300
1301        let patch = wrap_patch(&format!(
1302            r#"*** Update File: {}
1303@@
1304 foo
1305-bar
1306+baz
1307"#,
1308            path.display()
1309        ));
1310
1311        let mut stdout = Vec::new();
1312        let mut stderr = Vec::new();
1313        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1314
1315        let bytes = fs::read(&path).unwrap();
1316        assert!(bytes.ends_with(b"\r\n"), "file should end with CRLF");
1317        for (idx, byte) in bytes.iter().enumerate() {
1318            if *byte == b'\n' {
1319                assert!(
1320                    idx > 0 && bytes[idx - 1] == b'\r',
1321                    "found bare LF at index {idx}"
1322                );
1323            }
1324        }
1325        assert_eq!(String::from_utf8_lossy(&bytes), "foo\r\nbaz\r\n");
1326    }
1327
1328    #[test]
1329    fn test_update_file_hunk_can_move_file() {
1330        let dir = tempdir().unwrap();
1331        let src = dir.path().join("src.txt");
1332        let dest = dir.path().join("dst.txt");
1333        fs::write(&src, "line\n").unwrap();
1334        let patch = wrap_patch(&format!(
1335            r#"*** Update File: {}
1336*** Move to: {}
1337@@
1338-line
1339+line2"#,
1340            src.display(),
1341            dest.display()
1342        ));
1343        let mut stdout = Vec::new();
1344        let mut stderr = Vec::new();
1345        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1346        // Validate move semantics and expected stdout/stderr.
1347        let stdout_str = String::from_utf8(stdout).unwrap();
1348        let stderr_str = String::from_utf8(stderr).unwrap();
1349        let expected_out = format!(
1350            "Success. Updated the following files:\nM {}\n",
1351            dest.display()
1352        );
1353        assert_eq!(stdout_str, expected_out);
1354        assert_eq!(stderr_str, "");
1355        assert!(!src.exists());
1356        let contents = fs::read_to_string(&dest).unwrap();
1357        assert_eq!(contents, "line2\n");
1358    }
1359
1360    /// Verify that a single `Update File` hunk with multiple change chunks can update different
1361    /// parts of a file and that the file is listed only once in the summary.
1362    #[test]
1363    fn test_multiple_update_chunks_apply_to_single_file() {
1364        // Start with a file containing four lines.
1365        let dir = tempdir().unwrap();
1366        let path = dir.path().join("multi.txt");
1367        fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1368        // Construct an update patch with two separate change chunks.
1369        // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`.
1370        // The second chunk uses `baz` as context and transforms `qux` into `QUX`.
1371        let patch = wrap_patch(&format!(
1372            r#"*** Update File: {}
1373@@
1374 foo
1375-bar
1376+BAR
1377@@
1378 baz
1379-qux
1380+QUX"#,
1381            path.display()
1382        ));
1383        let mut stdout = Vec::new();
1384        let mut stderr = Vec::new();
1385        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1386        let stdout_str = String::from_utf8(stdout).unwrap();
1387        let stderr_str = String::from_utf8(stderr).unwrap();
1388        let expected_out = format!(
1389            "Success. Updated the following files:\nM {}\n",
1390            path.display()
1391        );
1392        assert_eq!(stdout_str, expected_out);
1393        assert_eq!(stderr_str, "");
1394        let contents = fs::read_to_string(&path).unwrap();
1395        assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n");
1396    }
1397
1398    /// A more involved `Update File` hunk that exercises additions, deletions and
1399    /// replacements in separate chunks that appear in non‑adjacent parts of the
1400    /// file.  Verifies that all edits are applied and that the summary lists the
1401    /// file only once.
1402    #[test]
1403    fn test_update_file_hunk_interleaved_changes() {
1404        let dir = tempdir().unwrap();
1405        let path = dir.path().join("interleaved.txt");
1406
1407        // Original file: six numbered lines.
1408        fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1409
1410        // Patch performs:
1411        //  • Replace `b` → `B`
1412        //  • Replace `e` → `E` (using surrounding context)
1413        //  • Append new line `g` at the end‑of‑file
1414        let patch = wrap_patch(&format!(
1415            r#"*** Update File: {}
1416@@
1417 a
1418-b
1419+B
1420@@
1421 c
1422 d
1423-e
1424+E
1425@@
1426 f
1427+g
1428*** End of File"#,
1429            path.display()
1430        ));
1431
1432        let mut stdout = Vec::new();
1433        let mut stderr = Vec::new();
1434        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1435
1436        let stdout_str = String::from_utf8(stdout).unwrap();
1437        let stderr_str = String::from_utf8(stderr).unwrap();
1438
1439        let expected_out = format!(
1440            "Success. Updated the following files:\nM {}\n",
1441            path.display()
1442        );
1443        assert_eq!(stdout_str, expected_out);
1444        assert_eq!(stderr_str, "");
1445
1446        let contents = fs::read_to_string(&path).unwrap();
1447        assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
1448    }
1449
1450    #[test]
1451    fn test_pure_addition_chunk_followed_by_removal() {
1452        let dir = tempdir().unwrap();
1453        let path = dir.path().join("panic.txt");
1454        fs::write(&path, "line1\nline2\nline3\n").unwrap();
1455        let patch = wrap_patch(&format!(
1456            r#"*** Update File: {}
1457@@
1458+after-context
1459+second-line
1460@@
1461 line1
1462-line2
1463-line3
1464+line2-replacement"#,
1465            path.display()
1466        ));
1467        let mut stdout = Vec::new();
1468        let mut stderr = Vec::new();
1469        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1470        let contents = fs::read_to_string(path).unwrap();
1471        assert_eq!(
1472            contents,
1473            "line1\nline2-replacement\nafter-context\nsecond-line\n"
1474        );
1475    }
1476
1477    /// Ensure that patches authored with ASCII characters can update lines that
1478    /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
1479    /// HYPHEN). Historically `git apply` succeeds in such scenarios but our
1480    /// internal matcher failed requiring an exact byte-for-byte match.  The
1481    /// fuzzy-matching pass that normalises common punctuation should now bridge
1482    /// the gap.
1483    #[test]
1484    fn test_update_line_with_unicode_dash() {
1485        let dir = tempdir().unwrap();
1486        let path = dir.path().join("unicode.py");
1487
1488        // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}).
1489        let original = "import asyncio  # local import \u{2013} avoids top\u{2011}level dep\n";
1490        std::fs::write(&path, original).unwrap();
1491
1492        // Patch uses plain ASCII dash / hyphen.
1493        let patch = wrap_patch(&format!(
1494            r#"*** Update File: {}
1495@@
1496-import asyncio  # local import - avoids top-level dep
1497+import asyncio  # HELLO"#,
1498            path.display()
1499        ));
1500
1501        let mut stdout = Vec::new();
1502        let mut stderr = Vec::new();
1503        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1504
1505        // File should now contain the replaced comment.
1506        let expected = "import asyncio  # HELLO\n";
1507        let contents = std::fs::read_to_string(&path).unwrap();
1508        assert_eq!(contents, expected);
1509
1510        // Ensure success summary lists the file as modified.
1511        let stdout_str = String::from_utf8(stdout).unwrap();
1512        let expected_out = format!(
1513            "Success. Updated the following files:\nM {}\n",
1514            path.display()
1515        );
1516        assert_eq!(stdout_str, expected_out);
1517
1518        // No stderr expected.
1519        assert_eq!(String::from_utf8(stderr).unwrap(), "");
1520    }
1521
1522    #[test]
1523    fn test_unified_diff() {
1524        // Start with a file containing four lines.
1525        let dir = tempdir().unwrap();
1526        let path = dir.path().join("multi.txt");
1527        fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap();
1528        let patch = wrap_patch(&format!(
1529            r#"*** Update File: {}
1530@@
1531 foo
1532-bar
1533+BAR
1534@@
1535 baz
1536-qux
1537+QUX"#,
1538            path.display()
1539        ));
1540        let patch = parse_patch(&patch).unwrap();
1541
1542        let update_file_chunks = match patch.hunks.as_slice() {
1543            [Hunk::UpdateFile { chunks, .. }] => chunks,
1544            _ => panic!("Expected a single UpdateFile hunk"),
1545        };
1546        let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap();
1547        let expected_diff = r#"@@ -1,4 +1,4 @@
1548 foo
1549-bar
1550+BAR
1551 baz
1552-qux
1553+QUX
1554"#;
1555        let expected = ApplyPatchFileUpdate {
1556            unified_diff: expected_diff.to_string(),
1557            content: "foo\nBAR\nbaz\nQUX\n".to_string(),
1558        };
1559        assert_eq!(expected, diff);
1560    }
1561
1562    #[test]
1563    fn test_unified_diff_first_line_replacement() {
1564        // Replace the very first line of the file.
1565        let dir = tempdir().unwrap();
1566        let path = dir.path().join("first.txt");
1567        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1568
1569        let patch = wrap_patch(&format!(
1570            r#"*** Update File: {}
1571@@
1572-foo
1573+FOO
1574 bar
1575"#,
1576            path.display()
1577        ));
1578
1579        let patch = parse_patch(&patch).unwrap();
1580        let chunks = match patch.hunks.as_slice() {
1581            [Hunk::UpdateFile { chunks, .. }] => chunks,
1582            _ => panic!("Expected a single UpdateFile hunk"),
1583        };
1584
1585        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1586        let expected_diff = r#"@@ -1,2 +1,2 @@
1587-foo
1588+FOO
1589 bar
1590"#;
1591        let expected = ApplyPatchFileUpdate {
1592            unified_diff: expected_diff.to_string(),
1593            content: "FOO\nbar\nbaz\n".to_string(),
1594        };
1595        assert_eq!(expected, diff);
1596    }
1597
1598    #[test]
1599    fn test_unified_diff_last_line_replacement() {
1600        // Replace the very last line of the file.
1601        let dir = tempdir().unwrap();
1602        let path = dir.path().join("last.txt");
1603        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1604
1605        let patch = wrap_patch(&format!(
1606            r#"*** Update File: {}
1607@@
1608 foo
1609 bar
1610-baz
1611+BAZ
1612"#,
1613            path.display()
1614        ));
1615
1616        let patch = parse_patch(&patch).unwrap();
1617        let chunks = match patch.hunks.as_slice() {
1618            [Hunk::UpdateFile { chunks, .. }] => chunks,
1619            _ => panic!("Expected a single UpdateFile hunk"),
1620        };
1621
1622        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1623        let expected_diff = r#"@@ -2,2 +2,2 @@
1624 bar
1625-baz
1626+BAZ
1627"#;
1628        let expected = ApplyPatchFileUpdate {
1629            unified_diff: expected_diff.to_string(),
1630            content: "foo\nbar\nBAZ\n".to_string(),
1631        };
1632        assert_eq!(expected, diff);
1633    }
1634
1635    #[test]
1636    fn test_unified_diff_insert_at_eof() {
1637        // Insert a new line at end‑of‑file.
1638        let dir = tempdir().unwrap();
1639        let path = dir.path().join("insert.txt");
1640        fs::write(&path, "foo\nbar\nbaz\n").unwrap();
1641
1642        let patch = wrap_patch(&format!(
1643            r#"*** Update File: {}
1644@@
1645+quux
1646*** End of File
1647"#,
1648            path.display()
1649        ));
1650
1651        let patch = parse_patch(&patch).unwrap();
1652        let chunks = match patch.hunks.as_slice() {
1653            [Hunk::UpdateFile { chunks, .. }] => chunks,
1654            _ => panic!("Expected a single UpdateFile hunk"),
1655        };
1656
1657        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1658        let expected_diff = r#"@@ -3 +3,2 @@
1659 baz
1660+quux
1661"#;
1662        let expected = ApplyPatchFileUpdate {
1663            unified_diff: expected_diff.to_string(),
1664            content: "foo\nbar\nbaz\nquux\n".to_string(),
1665        };
1666        assert_eq!(expected, diff);
1667    }
1668
1669    #[test]
1670    fn test_unified_diff_interleaved_changes() {
1671        // Original file with six lines.
1672        let dir = tempdir().unwrap();
1673        let path = dir.path().join("interleaved.txt");
1674        fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
1675
1676        // Patch replaces two separate lines and appends a new one at EOF using
1677        // three distinct chunks.
1678        let patch_body = format!(
1679            r#"*** Update File: {}
1680@@
1681 a
1682-b
1683+B
1684@@
1685 d
1686-e
1687+E
1688@@
1689 f
1690+g
1691*** End of File"#,
1692            path.display()
1693        );
1694        let patch = wrap_patch(&patch_body);
1695
1696        // Extract chunks then build the unified diff.
1697        let parsed = parse_patch(&patch).unwrap();
1698        let chunks = match parsed.hunks.as_slice() {
1699            [Hunk::UpdateFile { chunks, .. }] => chunks,
1700            _ => panic!("Expected a single UpdateFile hunk"),
1701        };
1702
1703        let diff = unified_diff_from_chunks(&path, chunks).unwrap();
1704
1705        let expected_diff = r#"@@ -1,6 +1,7 @@
1706 a
1707-b
1708+B
1709 c
1710 d
1711-e
1712+E
1713 f
1714+g
1715"#;
1716
1717        let expected = ApplyPatchFileUpdate {
1718            unified_diff: expected_diff.to_string(),
1719            content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
1720        };
1721
1722        assert_eq!(expected, diff);
1723
1724        let mut stdout = Vec::new();
1725        let mut stderr = Vec::new();
1726        apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1727        let contents = fs::read_to_string(path).unwrap();
1728        assert_eq!(
1729            contents,
1730            r#"a
1731B
1732c
1733d
1734E
1735f
1736g
1737"#
1738        );
1739    }
1740
1741    #[test]
1742    fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
1743        let session_dir = tempdir().unwrap();
1744        let relative_path = "source.txt";
1745
1746        // Note that we need this file to exist for the patch to be "verified"
1747        // and parsed correctly.
1748        let session_file_path = session_dir.path().join(relative_path);
1749        fs::write(&session_file_path, "session directory content\n").unwrap();
1750
1751        let argv = vec![
1752            "apply_patch".to_string(),
1753            r#"*** Begin Patch
1754*** Update File: source.txt
1755@@
1756-session directory content
1757+updated session directory content
1758*** End Patch"#
1759                .to_string(),
1760        ];
1761
1762        let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
1763
1764        // Verify the patch contents - as otherwise we may have pulled contents
1765        // from the wrong file (as we're using relative paths)
1766        assert_eq!(
1767            result,
1768            MaybeApplyPatchVerified::Body(ApplyPatchAction {
1769                changes: HashMap::from([(
1770                    session_dir.path().join(relative_path),
1771                    ApplyPatchFileChange::Update {
1772                        unified_diff: r#"@@ -1 +1 @@
1773-session directory content
1774+updated session directory content
1775"#
1776                        .to_string(),
1777                        move_path: None,
1778                        new_content: "updated session directory content\n".to_string(),
1779                    },
1780                )]),
1781                patch: argv[1].clone(),
1782                cwd: session_dir.path().to_path_buf(),
1783            })
1784        );
1785    }
1786
1787    #[test]
1788    fn test_apply_patch_fails_on_write_error() {
1789        let dir = tempdir().unwrap();
1790        let path = dir.path().join("readonly.txt");
1791        fs::write(&path, "before\n").unwrap();
1792        let mut perms = fs::metadata(&path).unwrap().permissions();
1793        perms.set_readonly(true);
1794        fs::set_permissions(&path, perms).unwrap();
1795
1796        let patch = wrap_patch(&format!(
1797            "*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
1798            path.display()
1799        ));
1800
1801        let mut stdout = Vec::new();
1802        let mut stderr = Vec::new();
1803        let result = apply_patch(&patch, &mut stdout, &mut stderr);
1804        assert!(result.is_err());
1805    }
1806}