mod invocation; mod parser; mod seek_sequence; mod standalone_executable; mod streaming_parser; use std::collections::HashMap; use std::io; use std::path::Path; use std::path::PathBuf; use anyhow::Context; use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::RemoveOptions; use codex_utils_absolute_path::AbsolutePathBuf; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; pub use parser::UpdateFileChunk; pub use parser::parse_patch; use similar::TextDiff; pub use streaming_parser::StreamingPatchParser; use thiserror::Error; pub use invocation::maybe_parse_apply_patch_verified; pub use standalone_executable::main; use crate::invocation::ExtractHeredocError; /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); /// Special argv[1] flag used when the Codex executable self-invokes to run the /// internal `apply_patch` path. /// /// Although this constant lives in `codex-apply-patch` (to avoid forcing /// `codex-arg0` to depend on `codex-core`), it remains part of the "codex core" /// process-invocation contract for the standalone `apply_patch` command /// surface. pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { #[error(transparent)] ParseError(#[from] ParseError), #[error(transparent)] IoError(#[from] IoError), /// Error that occurs while computing replacements when applying patch chunks #[error("{0}")] ComputeReplacements(String), /// A raw patch body was provided without an explicit `apply_patch` invocation. #[error( "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" )] ImplicitInvocation, } impl From for ApplyPatchError { fn from(err: std::io::Error) -> Self { ApplyPatchError::IoError(IoError { context: "I/O error".to_string(), source: err, }) } } impl From<&std::io::Error> for ApplyPatchError { fn from(err: &std::io::Error) -> Self { ApplyPatchError::IoError(IoError { context: "I/O error".to_string(), source: std::io::Error::new(err.kind(), err.to_string()), }) } } #[derive(Debug, Error)] #[error("{context}: {source}")] pub struct IoError { context: String, #[source] source: std::io::Error, } impl PartialEq for IoError { fn eq(&self, other: &Self) -> bool { self.context == other.context && self.source.to_string() == other.source.to_string() } } /// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument /// parsed into hunks. #[derive(Debug, PartialEq)] pub struct ApplyPatchArgs { pub patch: String, pub hunks: Vec, pub workdir: Option, } #[derive(Debug, PartialEq)] pub enum ApplyPatchFileChange { Add { content: String, }, Delete { content: String, }, Update { unified_diff: String, move_path: Option, /// new_content that will result after the unified_diff is applied. new_content: String, }, } #[derive(Debug, PartialEq)] pub enum MaybeApplyPatchVerified { /// `argv` corresponded to an `apply_patch` invocation, and these are the /// resulting proposed file changes. Body(ApplyPatchAction), /// `argv` could not be parsed to determine whether it corresponds to an /// `apply_patch` invocation. ShellParseError(ExtractHeredocError), /// `argv` corresponded to an `apply_patch` invocation, but it could not /// be fulfilled due to the specified error. CorrectnessError(ApplyPatchError), /// `argv` decidedly did not correspond to an `apply_patch` invocation. NotApplyPatch, } /// ApplyPatchAction is the result of parsing an `apply_patch` command. By /// construction, all paths should be absolute paths. #[derive(Debug, PartialEq)] pub struct ApplyPatchAction { changes: HashMap, /// The raw patch argument that can be used to apply the patch. i.e., if the /// original arg was parsed in "lenient" mode with a /// heredoc, this should be the value without the heredoc wrapper. pub patch: String, /// The working directory that was used to resolve relative paths in the patch. pub cwd: AbsolutePathBuf, } impl ApplyPatchAction { pub fn is_empty(&self) -> bool { self.changes.is_empty() } /// Returns the changes that would be made by applying the patch. pub fn changes(&self) -> &HashMap { &self.changes } /// Should be used exclusively for testing. (Not worth the overhead of /// creating a feature flag for this.) pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self { #[expect(clippy::expect_used)] let filename = path .file_name() .expect("path should not be empty") .to_string_lossy(); let patch = format!( r#"*** Begin Patch *** Update File: {filename} @@ + {content} *** End Patch"#, ); let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); #[expect(clippy::expect_used)] Self { changes, cwd: path.parent().expect("path should have parent"), patch, } } } /// Textual file changes that were actually committed while applying a patch. #[derive(Clone, Debug, PartialEq)] pub struct AppliedPatchDelta { changes: Vec, exact: bool, } impl AppliedPatchDelta { fn new(changes: Vec, exact: bool) -> Self { Self { changes, exact } } fn empty() -> Self { Self::new(Vec::new(), /*exact*/ true) } pub fn changes(&self) -> &[AppliedPatchChange] { &self.changes } pub fn is_empty(&self) -> bool { self.changes.is_empty() } pub fn is_exact(&self) -> bool { self.exact } /// Appends a later committed prefix while preserving the aggregate exactness. pub fn append(&mut self, other: Self) { self.changes.extend(other.changes); self.exact &= other.exact; } } impl Default for AppliedPatchDelta { fn default() -> Self { Self::empty() } } /// A committed file change, preserved in the order it was applied. #[derive(Clone, Debug, PartialEq)] pub struct AppliedPatchChange { pub path: PathBuf, pub change: AppliedPatchFileChange, } #[derive(Clone, Debug, PartialEq)] pub enum AppliedPatchFileChange { Add { content: String, overwritten_content: Option, }, Delete { content: String, }, Update { move_path: Option, old_content: String, overwritten_move_content: Option, new_content: String, }, } /// A failed patch application together with the textual mutations that were /// definitely committed before the failure was observed. #[derive(Debug, Error)] #[error("{error}")] pub struct ApplyPatchFailure { #[source] error: ApplyPatchError, delta: AppliedPatchDelta, } impl ApplyPatchFailure { fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self { Self { error, delta } } fn without_delta(error: ApplyPatchError) -> Self { Self::new(error, AppliedPatchDelta::empty()) } pub fn delta(&self) -> &AppliedPatchDelta { &self.delta } pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) { (self.error, self.delta) } } /// Applies the patch and prints the result to stdout/stderr. pub async fn apply_patch( patch: &str, cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> Result { let hunks = match parse_patch(patch) { Ok(source) => source.hunks, Err(e) => { match &e { InvalidPatchError(message) => { writeln!(stderr, "Invalid patch: {message}") .map_err(ApplyPatchError::from) .map_err(ApplyPatchFailure::without_delta)?; } InvalidHunkError { message, line_number, } => { writeln!( stderr, "Invalid patch hunk on line {line_number}: {message}" ) .map_err(ApplyPatchError::from) .map_err(ApplyPatchFailure::without_delta)?; } } return Err(ApplyPatchFailure::without_delta( ApplyPatchError::ParseError(e), )); } }; apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await } /// Applies hunks and continues to update stdout/stderr pub async fn apply_hunks( hunks: &[Hunk], cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> Result { let mut delta = AppliedPatchDelta::empty(); match apply_hunks_to_files(hunks, cwd, fs, sandbox, &mut delta).await { Ok(affected_paths) => { print_summary(&affected_paths, stdout).map_err(|error| { ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) })?; Ok(delta) } Err(error) => { let msg = error.to_string(); writeln!(stderr, "{msg}").map_err(|error| { ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) })?; let error = if let Some(io) = error.downcast_ref::() { ApplyPatchError::from(io) } else { ApplyPatchError::IoError(IoError { context: msg, source: std::io::Error::other(error), }) }; Err(ApplyPatchFailure::new(error, delta)) } } } /// Applies each parsed patch hunk to the filesystem. /// Returns an error if any of the changes could not be applied. /// Tracks file paths affected by applying a patch, preserving the path spelling /// from the patch for user-facing summaries. pub struct AffectedPaths { pub added: Vec, pub modified: Vec, pub deleted: Vec, } /// Apply the hunks to the filesystem, returning which files were added, modified, or deleted. /// Returns an error if the patch could not be applied. async fn apply_hunks_to_files( hunks: &[Hunk], cwd: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, delta: &mut AppliedPatchDelta, ) -> anyhow::Result { if hunks.is_empty() { anyhow::bail!("No files were modified."); } let mut added: Vec = Vec::new(); let mut modified: Vec = Vec::new(); let mut deleted: Vec = Vec::new(); // A failed write can still have modified the target before surfacing an // error (for example by truncating before ENOSPC), so the accumulated // delta is no longer exact when a write fails. macro_rules! try_write { ($result:expr) => { match $result { Ok(value) => value, Err(error) => { delta.exact = false; return Err(anyhow::Error::from(error)); } } }; } for hunk in hunks { let affected_path = hunk.path().to_path_buf(); let path_abs = hunk.resolve_path(cwd); match hunk { Hunk::AddFile { contents, .. } => { let overwritten_content = read_optional_file_text_for_delta(&path_abs, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, &path_abs, contents.clone().into_bytes(), sandbox, ) .await ); delta.changes.push(AppliedPatchChange { path: path_abs.into_path_buf(), change: AppliedPatchFileChange::Add { content: contents.clone(), overwritten_content, }, }); added.push(affected_path); } Hunk::DeleteFile { .. } => { note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; let deleted_content = fs.read_file_text(&path_abs, sandbox).await.ok(); if deleted_content.is_none() { delta.exact = false; } ensure_not_directory(&path_abs, fs, sandbox) .await .with_context(|| format!("Failed to delete file {}", path_abs.display()))?; if let Err(error) = fs .remove( &path_abs, RemoveOptions { recursive: false, force: false, }, sandbox, ) .await .with_context(|| format!("Failed to delete file {}", path_abs.display())) { delta.exact &= remove_failure_was_side_effect_free( &path_abs, deleted_content.as_deref(), fs, sandbox, ) .await; return Err(error); } if let Some(content) = deleted_content { delta.changes.push(AppliedPatchChange { path: path_abs.into_path_buf(), change: AppliedPatchFileChange::Delete { content }, }); } deleted.push(affected_path); } Hunk::UpdateFile { move_path, chunks, .. } => { note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; let AppliedPatch { original_contents, new_contents, } = derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; if let Some(dest) = move_path { let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); let overwritten_move_content = read_optional_file_text_for_delta(&dest_abs, fs, sandbox, &mut delta.exact) .await; try_write!( write_file_with_missing_parent_retry( fs, &dest_abs, new_contents.clone().into_bytes(), sandbox, ) .await ); let dest_write_change_index = delta.changes.len(); delta.changes.push(AppliedPatchChange { path: dest_abs.to_path_buf(), change: AppliedPatchFileChange::Add { content: new_contents.clone(), overwritten_content: overwritten_move_content.clone(), }, }); ensure_not_directory(&path_abs, fs, sandbox) .await .with_context(|| { format!("Failed to remove original {}", path_abs.display()) })?; if let Err(error) = fs .remove( &path_abs, RemoveOptions { recursive: false, force: false, }, sandbox, ) .await .with_context(|| { format!("Failed to remove original {}", path_abs.display()) }) { delta.exact &= remove_failure_was_side_effect_free( &path_abs, Some(&original_contents), fs, sandbox, ) .await; return Err(error); } delta.changes[dest_write_change_index] = AppliedPatchChange { path: path_abs.into_path_buf(), change: AppliedPatchFileChange::Update { move_path: Some(dest_abs.into_path_buf()), old_content: original_contents, overwritten_move_content, new_content: new_contents, }, }; modified.push(affected_path); } else { try_write!( fs.write_file(&path_abs, new_contents.clone().into_bytes(), sandbox) .await .with_context(|| format!( "Failed to write file {}", path_abs.display() )) ); delta.changes.push(AppliedPatchChange { path: path_abs.into_path_buf(), change: AppliedPatchFileChange::Update { move_path: None, old_content: original_contents, overwritten_move_content: None, new_content: new_contents, }, }); modified.push(affected_path); } } } } Ok(AffectedPaths { added, modified, deleted, }) } async fn ensure_not_directory( path: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result<()> { let metadata = fs.get_metadata(path, sandbox).await?; if metadata.is_directory { return Err(io::Error::new( io::ErrorKind::InvalidInput, "path is a directory", )); } Ok(()) } async fn remove_failure_was_side_effect_free( path: &AbsolutePathBuf, expected_content: Option<&str>, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> bool { match expected_content { Some(expected_content) => fs .read_file_text(path, sandbox) .await .is_ok_and(|content| content == expected_content), None => false, } } async fn read_optional_file_text_for_delta( path: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) -> Option { note_existing_path_delta_support(path, fs, sandbox, exact).await; match fs.read_file_text(path, sandbox).await { Ok(content) => Some(content), Err(source) if source.kind() == io::ErrorKind::NotFound => None, Err(_) => { *exact = false; None } } } async fn note_existing_path_delta_support( path: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, exact: &mut bool, ) { match fs.get_metadata(path, sandbox).await { Ok(metadata) if metadata.is_file && !metadata.is_symlink => {} Ok(_) => *exact = false, Err(source) if source.kind() == io::ErrorKind::NotFound => {} Err(_) => *exact = false, } } async fn write_file_with_missing_parent_retry( fs: &dyn ExecutorFileSystem, path_abs: &AbsolutePathBuf, contents: Vec, sandbox: Option<&FileSystemSandboxContext>, ) -> anyhow::Result<()> { match fs.write_file(path_abs, contents.clone(), sandbox).await { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::NotFound => { if let Some(parent_abs) = path_abs.parent() { fs.create_directory( &parent_abs, CreateDirectoryOptions { recursive: true }, sandbox, ) .await .with_context(|| { format!( "Failed to create parent directories for {}", path_abs.display() ) })?; } fs.write_file(path_abs, contents, sandbox) .await .with_context(|| format!("Failed to write file {}", path_abs.display()))?; Ok(()) } Err(err) => { Err(err).with_context(|| format!("Failed to write file {}", path_abs.display())) } } } struct AppliedPatch { original_contents: String, new_contents: String, } /// Return *only* the new file contents (joined into a single `String`) after /// applying the chunks to the file at `path`. async fn derive_new_contents_from_chunks( path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| { ApplyPatchError::IoError(IoError { context: format!("Failed to read file to update {}", path_abs.display()), source: err, }) })?; let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); // Drop the trailing empty element that results from the final newline so // that line counts match the behaviour of standard `diff`. if original_lines.last().is_some_and(String::is_empty) { original_lines.pop(); } let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } let new_contents = new_lines.join("\n"); Ok(AppliedPatch { original_contents, new_contents, }) } /// Compute a list of replacements needed to transform `original_lines` into the /// new lines, given the patch `chunks`. Each replacement is returned as /// `(start_index, old_len, new_lines)`. fn compute_replacements( original_lines: &[String], path: &Path, chunks: &[UpdateFileChunk], ) -> std::result::Result)>, ApplyPatchError> { let mut replacements: Vec<(usize, usize, Vec)> = Vec::new(); let mut line_index: usize = 0; for chunk in chunks { // If a chunk has a `change_context`, we use seek_sequence to find it, then // adjust our `line_index` to continue from there. if let Some(ctx_line) = &chunk.change_context { if let Some(idx) = seek_sequence::seek_sequence( original_lines, std::slice::from_ref(ctx_line), line_index, /*eof*/ false, ) { line_index = idx + 1; } else { return Err(ApplyPatchError::ComputeReplacements(format!( "Failed to find context '{}' in {}", ctx_line, path.display() ))); } } if chunk.old_lines.is_empty() { // Pure addition (no old lines). We'll add them at the end or just // before the final empty line if one exists. let insertion_idx = if original_lines.last().is_some_and(String::is_empty) { original_lines.len() - 1 } else { original_lines.len() }; replacements.push((insertion_idx, 0, chunk.new_lines.clone())); continue; } // Otherwise, try to match the existing lines in the file with the old lines // from the chunk. If found, schedule that region for replacement. // Attempt to locate the `old_lines` verbatim within the file. In many // real‑world diffs the last element of `old_lines` is an *empty* string // representing the terminating newline of the region being replaced. // This sentinel is not present in `original_lines` because we strip the // trailing empty slice emitted by `split('\n')`. If a direct search // fails and the pattern ends with an empty string, retry without that // final element so that modifications touching the end‑of‑file can be // located reliably. let mut pattern: &[String] = &chunk.old_lines; let mut found = seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); let mut new_slice: &[String] = &chunk.new_lines; if found.is_none() && pattern.last().is_some_and(String::is_empty) { // Retry without the trailing empty line which represents the final // newline in the file. pattern = &pattern[..pattern.len() - 1]; if new_slice.last().is_some_and(String::is_empty) { new_slice = &new_slice[..new_slice.len() - 1]; } found = seek_sequence::seek_sequence( original_lines, pattern, line_index, chunk.is_end_of_file, ); } if let Some(start_idx) = found { replacements.push((start_idx, pattern.len(), new_slice.to_vec())); line_index = start_idx + pattern.len(); } else { return Err(ApplyPatchError::ComputeReplacements(format!( "Failed to find expected lines in {}:\n{}", path.display(), chunk.old_lines.join("\n"), ))); } } replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); Ok(replacements) } /// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`, /// returning the modified file contents as a vector of lines. fn apply_replacements( mut lines: Vec, replacements: &[(usize, usize, Vec)], ) -> Vec { // We must apply replacements in descending order so that earlier replacements // don't shift the positions of later ones. for (start_idx, old_len, new_segment) in replacements.iter().rev() { let start_idx = *start_idx; let old_len = *old_len; // Remove old lines. for _ in 0..old_len { if start_idx < lines.len() { lines.remove(start_idx); } } // Insert new lines. for (offset, new_line) in new_segment.iter().enumerate() { lines.insert(start_idx + offset, new_line.clone()); } } lines } /// Intended result of a file update for apply_patch. #[derive(Debug, Eq, PartialEq)] pub struct ApplyPatchFileUpdate { unified_diff: String, original_content: String, content: String, } pub async fn unified_diff_from_chunks( path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await } pub async fn unified_diff_from_chunks_with_context( path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], context: usize, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> std::result::Result { let AppliedPatch { original_contents, new_contents, } = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?; let text_diff = TextDiff::from_lines(&original_contents, &new_contents); let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { unified_diff, original_content: original_contents, content: new_contents, }) } /// Print the summary of changes in git-style format. /// Write a summary of changes to the given writer. pub fn print_summary( affected: &AffectedPaths, out: &mut impl std::io::Write, ) -> std::io::Result<()> { writeln!(out, "Success. Updated the following files:")?; for path in &affected.added { writeln!(out, "A {}", path.display())?; } for path in &affected.modified { writeln!(out, "M {}", path.display())?; } for path in &affected.deleted { writeln!(out, "D {}", path.display())?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; use tempfile::tempdir; /// Helper to construct a patch with the given body. fn wrap_patch(body: &str) -> String { format!("*** Begin Patch\n{body}\n*** End Patch") } #[tokio::test] async fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); let path = dir.path().join("add.txt"); let patch = wrap_patch(&format!( r#"*** Add File: {} +ab +cd"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); // Verify expected stdout and stderr outputs. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nA {}\n", path.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); let contents = fs::read_to_string(path).unwrap(); assert_eq!(contents, "ab\ncd\n"); } #[tokio::test] async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { let dir = tempdir().unwrap(); let cwd = dir.path().abs(); let relative_add = dir.path().join("relative-add.txt"); let absolute_add = dir.path().join("absolute-add.txt"); let relative_delete = dir.path().join("relative-delete.txt"); let absolute_delete = dir.path().join("absolute-delete.txt"); let relative_update = dir.path().join("relative-update.txt"); let absolute_update = dir.path().join("absolute-update.txt"); fs::write(&relative_delete, "delete relative\n").unwrap(); fs::write(&absolute_delete, "delete absolute\n").unwrap(); fs::write(&relative_update, "relative old\n").unwrap(); fs::write(&absolute_update, "absolute old\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Add File: relative-add.txt +relative add *** Add File: {} +absolute add *** Delete File: relative-delete.txt *** Delete File: {} *** Update File: relative-update.txt @@ -relative old +relative new *** Update File: {} @@ -absolute old +absolute new"#, absolute_add.display(), absolute_delete.display(), absolute_update.display(), )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n"); assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n"); assert!(!relative_delete.exists()); assert!(!absolute_delete.exists()); assert_eq!( fs::read_to_string(&relative_update).unwrap(), "relative new\n" ); assert_eq!( fs::read_to_string(&absolute_update).unwrap(), "absolute new\n" ); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!( String::from_utf8(stdout).unwrap(), format!( "Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n", absolute_add.display(), absolute_update.display(), absolute_delete.display(), ) ); } #[tokio::test] async fn test_delete_file_hunk_removes_file() { let dir = tempdir().unwrap(); let path = dir.path().join("del.txt"); fs::write(&path, "x").unwrap(); let patch = wrap_patch(&format!("*** Delete File: {}", path.display())); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nD {}\n", path.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); assert!(!path.exists()); } #[tokio::test] async fn test_update_file_hunk_modifies_content() { let dir = tempdir().unwrap(); let path = dir.path().join("update.txt"); fs::write(&path, "foo\nbar\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ foo -bar +baz"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); // Validate modified file contents and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nM {}\n", path.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); let contents = fs::read_to_string(&path).unwrap(); assert_eq!(contents, "foo\nbaz\n"); } #[tokio::test] async fn test_update_file_hunk_can_move_file() { let dir = tempdir().unwrap(); let src = dir.path().join("src.txt"); let dest = dir.path().join("dst.txt"); fs::write(&src, "line\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} *** Move to: {} @@ -line +line2"#, src.display(), dest.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); // Validate move semantics and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nM {}\n", dest.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); assert!(!src.exists()); let contents = fs::read_to_string(&dest).unwrap(); assert_eq!(contents, "line2\n"); } #[cfg(unix)] #[tokio::test] async fn test_failed_move_returns_committed_destination_delta() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); let source_dir = dir.path().join("locked"); let dest_dir = dir.path().join("out"); fs::create_dir(&source_dir).unwrap(); fs::create_dir(&dest_dir).unwrap(); let src = source_dir.join("src.txt"); let dest = dest_dir.join("dst.txt"); fs::write(&src, "line\n").unwrap(); fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap(); let patch = wrap_patch( "*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2", ); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let failure = apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .expect_err("source removal should fail after destination write"); fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap(); assert!( String::from_utf8(stderr) .unwrap() .contains(&format!("Failed to remove original {}", src.display())) ); assert_eq!( failure.delta(), &AppliedPatchDelta::new( vec![AppliedPatchChange { path: dest.clone(), change: AppliedPatchFileChange::Add { content: "line2\n".to_string(), overwritten_content: None, }, }], /*exact*/ true, ) ); assert_eq!(fs::read_to_string(src).unwrap(), "line\n"); assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n"); } /// Verify that a single `Update File` hunk with multiple change chunks can update different /// parts of a file and that the file is listed only once in the summary. #[tokio::test] async fn test_multiple_update_chunks_apply_to_single_file() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); // Construct an update patch with two separate change chunks. // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`. // The second chunk uses `baz` as context and transforms `qux` into `QUX`. let patch = wrap_patch(&format!( r#"*** Update File: {} @@ foo -bar +BAR @@ baz -qux +QUX"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nM {}\n", path.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); let contents = fs::read_to_string(&path).unwrap(); assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n"); } /// A more involved `Update File` hunk that exercises additions, deletions and /// replacements in separate chunks that appear in non‑adjacent parts of the /// file. Verifies that all edits are applied and that the summary lists the /// file only once. #[tokio::test] async fn test_update_file_hunk_interleaved_changes() { let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); // Original file: six numbered lines. fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); // Patch performs: // • Replace `b` → `B` // • Replace `e` → `E` (using surrounding context) // • Append new line `g` at the end‑of‑file let patch = wrap_patch(&format!( r#"*** Update File: {} @@ a -b +B @@ c d -e +E @@ f +g *** End of File"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( "Success. Updated the following files:\nM {}\n", path.display() ); assert_eq!(stdout_str, expected_out); assert_eq!(stderr_str, ""); let contents = fs::read_to_string(&path).unwrap(); assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } #[tokio::test] async fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); let path = dir.path().join("panic.txt"); fs::write(&path, "line1\nline2\nline3\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ +after-context +second-line @@ line1 -line2 -line3 +line2-replacement"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, "line1\nline2-replacement\nafter-context\nsecond-line\n" ); } /// Ensure that patches authored with ASCII characters can update lines that /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING /// HYPHEN). Historically `git apply` succeeds in such scenarios but our /// internal matcher failed requiring an exact byte-for-byte match. The /// fuzzy-matching pass that normalises common punctuation should now bridge /// the gap. #[tokio::test] async fn test_update_line_with_unicode_dash() { let dir = tempdir().unwrap(); let path = dir.path().join("unicode.py"); // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}). let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n"; std::fs::write(&path, original).unwrap(); // Patch uses plain ASCII dash / hyphen. let patch = wrap_patch(&format!( r#"*** Update File: {} @@ -import asyncio # local import - avoids top-level dep +import asyncio # HELLO"#, path.display() )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); // File should now contain the replaced comment. let expected = "import asyncio # HELLO\n"; let contents = std::fs::read_to_string(&path).unwrap(); assert_eq!(contents, expected); // Ensure success summary lists the file as modified. let stdout_str = String::from_utf8(stdout).unwrap(); let expected_out = format!( "Success. Updated the following files:\nM {}\n", path.display() ); assert_eq!(stdout_str, expected_out); // No stderr expected. assert_eq!(String::from_utf8(stderr).unwrap(), ""); } #[tokio::test] async fn test_unified_diff() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ foo -bar +BAR @@ baz -qux +QUX"#, path.display() )); let patch = parse_patch(&patch).unwrap(); let update_file_chunks = match patch.hunks.as_slice() { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; let path_abs = path.as_path().abs(); let diff = unified_diff_from_chunks( &path_abs, update_file_chunks, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let expected_diff = r#"@@ -1,4 +1,4 @@ foo -bar +BAR baz -qux +QUX "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), original_content: "foo\nbar\nbaz\nqux\n".to_string(), content: "foo\nBAR\nbaz\nQUX\n".to_string(), }; assert_eq!(expected, diff); } #[tokio::test] async fn test_unified_diff_first_line_replacement() { // Replace the very first line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("first.txt"); fs::write(&path, "foo\nbar\nbaz\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ -foo +FOO bar "#, path.display() )); let patch = parse_patch(&patch).unwrap(); let chunks = match patch.hunks.as_slice() { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; let path_abs = path.as_path().abs(); let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -1,2 +1,2 @@ -foo +FOO bar "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), original_content: "foo\nbar\nbaz\n".to_string(), content: "FOO\nbar\nbaz\n".to_string(), }; assert_eq!(expected, diff); } #[tokio::test] async fn test_unified_diff_last_line_replacement() { // Replace the very last line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("last.txt"); fs::write(&path, "foo\nbar\nbaz\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ foo bar -baz +BAZ "#, path.display() )); let patch = parse_patch(&patch).unwrap(); let chunks = match patch.hunks.as_slice() { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; let path_abs = path.as_path().abs(); let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz +BAZ "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nBAZ\n".to_string(), }; assert_eq!(expected, diff); } #[tokio::test] async fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. let dir = tempdir().unwrap(); let path = dir.path().join("insert.txt"); fs::write(&path, "foo\nbar\nbaz\n").unwrap(); let patch = wrap_patch(&format!( r#"*** Update File: {} @@ +quux *** End of File "#, path.display() )); let patch = parse_patch(&patch).unwrap(); let chunks = match patch.hunks.as_slice() { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; let path_abs = path.as_path().abs(); let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ baz +quux "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nbaz\nquux\n".to_string(), }; assert_eq!(expected, diff); } #[tokio::test] async fn test_unified_diff_interleaved_changes() { // Original file with six lines. let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); // Patch replaces two separate lines and appends a new one at EOF using // three distinct chunks. let patch_body = format!( r#"*** Update File: {} @@ a -b +B @@ d -e +E @@ f +g *** End of File"#, path.display() ); let patch = wrap_patch(&patch_body); // Extract chunks then build the unified diff. let parsed = parse_patch(&patch).unwrap(); let chunks = match parsed.hunks.as_slice() { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; let path_abs = path.as_path().abs(); let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await .unwrap(); let expected_diff = r#"@@ -1,6 +1,7 @@ a -b +B c d -e +E f +g "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), original_content: "a\nb\nc\nd\ne\nf\n".to_string(), content: "a\nB\nc\nd\nE\nf\ng\n".to_string(), }; assert_eq!(expected, diff); let mut stdout = Vec::new(); let mut stderr = Vec::new(); apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, r#"a B c d E f g "# ); } #[cfg(unix)] #[tokio::test] async fn test_apply_patch_fails_on_write_error() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); let locked_dir = dir.path().join("locked"); fs::create_dir(&locked_dir).unwrap(); fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap(); let patch = wrap_patch("*** Add File: locked/new.txt\n+after"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let result = apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await; let failure = result.expect_err("write should fail"); fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap(); assert!(!failure.delta().is_exact()); } #[tokio::test] async fn test_unreadable_destinations_return_inexact_delta() { let dir = tempdir().unwrap(); let path = dir.path().join("binary.dat"); fs::write(dir.path().join("source.txt"), "before\n").unwrap(); let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(); for patch in [ wrap_patch("*** Add File: binary.dat\n+text"), wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"), ] { fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap(); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = apply_patch( &patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); assert!(!delta.is_exact()); } } #[cfg(unix)] #[tokio::test] async fn test_delete_symlink_returns_inexact_delta() { use std::os::unix::fs::symlink; let dir = tempdir().unwrap(); fs::write(dir.path().join("target.txt"), "target\n").unwrap(); symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap(); let patch = wrap_patch("*** Delete File: link.txt"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = apply_patch( &patch, &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), /*sandbox*/ None, ) .await .unwrap(); assert!(!delta.is_exact()); } }