mod invocation;
mod parser;
mod seek_sequence;
mod standalone_executable;
mod streaming_parser;
use std::collections::HashMap;
use std::io;
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_path_uri::PathUri;
use codex_utils_path_uri::PathUriParseError;
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 invocation::verify_apply_patch_args;
pub use standalone_executable::main;
use crate::invocation::ExtractHeredocError;
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("{0}")]
ComputeReplacements(String),
#[error(transparent)]
PathUri(#[from] PathUriParseError),
#[error(
"patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"<patch>\"]"
)]
ImplicitInvocation,
}
impl From<std::io::Error> 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()
}
}
#[derive(Debug, PartialEq)]
pub struct ApplyPatchArgs {
pub patch: String,
pub hunks: Vec<Hunk>,
pub workdir: Option<String>,
pub environment_id: Option<String>,
}
#[derive(Debug, PartialEq)]
pub enum ApplyPatchFileChange {
Add {
content: String,
},
Delete {
content: String,
},
Update {
unified_diff: String,
move_path: Option<PathUri>,
new_content: String,
},
}
#[derive(Debug, PartialEq)]
pub enum MaybeApplyPatchVerified {
Body(ApplyPatchAction),
ShellParseError(ExtractHeredocError),
CorrectnessError(ApplyPatchError),
NotApplyPatch,
}
#[derive(Debug, PartialEq)]
pub struct ApplyPatchAction {
changes: HashMap<PathUri, ApplyPatchFileChange>,
pub patch: String,
pub cwd: PathUri,
}
impl ApplyPatchAction {
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
pub fn changes(&self) -> &HashMap<PathUri, ApplyPatchFileChange> {
&self.changes
}
pub fn new_add_for_test(path: &PathUri, content: String) -> Self {
#[expect(clippy::expect_used)]
let filename = path.basename().expect("path should not be empty");
let patch = format!(
r#"*** Begin Patch
*** Update File: {filename}
@@
+ {content}
*** End Patch"#,
);
let changes = HashMap::from([(path.clone(), ApplyPatchFileChange::Add { content })]);
#[expect(clippy::expect_used)]
Self {
changes,
cwd: path.parent().expect("path should have parent"),
patch,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AppliedPatchDelta {
changes: Vec<AppliedPatchChange>,
exact: bool,
}
impl AppliedPatchDelta {
fn new(changes: Vec<AppliedPatchChange>, exact: bool) -> Self {
Self { changes, exact }
}
fn empty() -> Self {
Self::new(Vec::new(), 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
}
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()
}
}
#[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<String>,
},
Delete {
content: String,
},
Update {
move_path: Option<PathBuf>,
old_content: String,
overwritten_move_content: Option<String>,
new_content: String,
},
}
#[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)
}
}
pub async fn apply_patch(
patch: &str,
cwd: &PathUri,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
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
}
pub async fn apply_hunks(
hunks: &[Hunk],
cwd: &PathUri,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<AppliedPatchDelta, ApplyPatchFailure> {
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::<std::io::Error>() {
ApplyPatchError::from(io)
} else {
ApplyPatchError::IoError(IoError {
context: msg,
source: std::io::Error::other(error),
})
};
Err(ApplyPatchFailure::new(error, delta))
}
}
}
pub struct AffectedPaths {
pub added: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
}
async fn apply_hunks_to_files(
hunks: &[Hunk],
cwd: &PathUri,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
delta: &mut AppliedPatchDelta,
) -> anyhow::Result<AffectedPaths> {
if hunks.is_empty() {
anyhow::bail!("No files were modified.");
}
let mut added: Vec<PathBuf> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut deleted: Vec<PathBuf> = Vec::new();
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_uri = hunk.resolve_path(cwd)?;
match hunk {
Hunk::AddFile { contents, .. } => {
let overwritten_content =
read_optional_file_text_for_delta(&path_uri, fs, sandbox, &mut delta.exact)
.await;
try_write!(
write_file_with_missing_parent_retry(
fs,
&path_uri,
contents.clone().into_bytes(),
sandbox,
)
.await
);
delta.changes.push(AppliedPatchChange {
path: path_uri.to_path_buf(),
change: AppliedPatchFileChange::Add {
content: contents.clone(),
overwritten_content,
},
});
added.push(affected_path);
}
Hunk::DeleteFile { .. } => {
note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await;
let deleted_content = fs.read_file_text(&path_uri, sandbox).await.ok();
if deleted_content.is_none() {
delta.exact = false;
}
ensure_not_directory(&path_uri, fs, sandbox)
.await
.with_context(|| {
format!(
"Failed to delete file {}",
path_uri.inferred_native_path_string()
)
})?;
if let Err(error) = fs
.remove(
&path_uri,
RemoveOptions {
recursive: false,
force: false,
},
sandbox,
)
.await
.with_context(|| {
format!(
"Failed to delete file {}",
path_uri.inferred_native_path_string()
)
})
{
delta.exact &= remove_failure_was_side_effect_free(
&path_uri,
deleted_content.as_deref(),
fs,
sandbox,
)
.await;
return Err(error);
}
if let Some(content) = deleted_content {
delta.changes.push(AppliedPatchChange {
path: path_uri.to_path_buf(),
change: AppliedPatchFileChange::Delete { content },
});
}
deleted.push(affected_path);
}
Hunk::UpdateFile {
move_path, chunks, ..
} => {
note_existing_path_delta_support(&path_uri, fs, sandbox, &mut delta.exact).await;
let AppliedPatch {
original_contents,
new_contents,
} = derive_new_contents_from_chunks(&path_uri, chunks, fs, sandbox).await?;
if let Some(dest) = move_path {
let dest_uri = cwd.join(&dest.to_string_lossy())?;
let overwritten_move_content =
read_optional_file_text_for_delta(&dest_uri, fs, sandbox, &mut delta.exact)
.await;
try_write!(
write_file_with_missing_parent_retry(
fs,
&dest_uri,
new_contents.clone().into_bytes(),
sandbox,
)
.await
);
let dest_write_change_index = delta.changes.len();
delta.changes.push(AppliedPatchChange {
path: dest_uri.to_path_buf(),
change: AppliedPatchFileChange::Add {
content: new_contents.clone(),
overwritten_content: overwritten_move_content.clone(),
},
});
ensure_not_directory(&path_uri, fs, sandbox)
.await
.with_context(|| {
format!(
"Failed to remove original {}",
path_uri.inferred_native_path_string()
)
})?;
if let Err(error) = fs
.remove(
&path_uri,
RemoveOptions {
recursive: false,
force: false,
},
sandbox,
)
.await
.with_context(|| {
format!(
"Failed to remove original {}",
path_uri.inferred_native_path_string()
)
})
{
delta.exact &= remove_failure_was_side_effect_free(
&path_uri,
Some(&original_contents),
fs,
sandbox,
)
.await;
return Err(error);
}
delta.changes[dest_write_change_index] = AppliedPatchChange {
path: path_uri.to_path_buf(),
change: AppliedPatchFileChange::Update {
move_path: Some(dest_uri.to_path_buf()),
old_content: original_contents,
overwritten_move_content,
new_content: new_contents,
},
};
modified.push(affected_path);
} else {
try_write!(
fs.write_file(&path_uri, new_contents.clone().into_bytes(), sandbox)
.await
.with_context(|| format!(
"Failed to write file {}",
path_uri.inferred_native_path_string()
))
);
delta.changes.push(AppliedPatchChange {
path: path_uri.to_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: &PathUri,
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: &PathUri,
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: &PathUri,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
exact: &mut bool,
) -> Option<String> {
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: &PathUri,
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: &PathUri,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> anyhow::Result<()> {
match fs.write_file(path, contents.clone(), sandbox).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if let Some(parent) = path.parent() {
fs.create_directory(&parent, CreateDirectoryOptions { recursive: true }, sandbox)
.await
.with_context(|| {
format!(
"Failed to create parent directories for {}",
path.inferred_native_path_string()
)
})?;
}
fs.write_file(path, contents, sandbox)
.await
.with_context(|| {
format!(
"Failed to write file {}",
path.inferred_native_path_string()
)
})?;
Ok(())
}
Err(err) => Err(err).with_context(|| {
format!(
"Failed to write file {}",
path.inferred_native_path_string()
)
}),
}
}
struct AppliedPatch {
original_contents: String,
new_contents: String,
}
async fn derive_new_contents_from_chunks(
path: &PathUri,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = fs.read_file_text(path, sandbox).await.map_err(|err| {
ApplyPatchError::IoError(IoError {
context: format!(
"Failed to read file to update {}",
path.inferred_native_path_string()
),
source: err,
})
})?;
let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect();
if original_lines.last().is_some_and(String::is_empty) {
original_lines.pop();
}
let path_text = path.inferred_native_path_string();
let replacements = compute_replacements(&original_lines, &path_text, 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,
})
}
fn compute_replacements(
original_lines: &[String],
path: &str,
chunks: &[UpdateFileChunk],
) -> std::result::Result<Vec<(usize, usize, Vec<String>)>, ApplyPatchError> {
let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new();
let mut line_index: usize = 0;
for chunk in chunks {
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,
false,
) {
line_index = idx + 1;
} else {
return Err(ApplyPatchError::ComputeReplacements(format!(
"Failed to find context '{ctx_line}' in {path}"
)));
}
}
if chunk.old_lines.is_empty() {
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;
}
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) {
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,
chunk.old_lines.join("\n"),
)));
}
}
replacements.sort_by_key(|(index, _, _)| *index);
Ok(replacements)
}
fn apply_replacements(
mut lines: Vec<String>,
replacements: &[(usize, usize, Vec<String>)],
) -> Vec<String> {
for (start_idx, old_len, new_segment) in replacements.iter().rev() {
let start_idx = *start_idx;
let old_len = *old_len;
for _ in 0..old_len {
if start_idx < lines.len() {
lines.remove(start_idx);
}
}
for (offset, new_line) in new_segment.iter().enumerate() {
lines.insert(start_idx + offset, new_line.clone());
}
}
lines
}
#[derive(Debug, Eq, PartialEq)]
pub struct ApplyPatchFileUpdate {
unified_diff: String,
original_content: String,
content: String,
}
pub async fn unified_diff_from_chunks(
path: &PathUri,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
unified_diff_from_chunks_with_context(path, chunks, 1, fs, sandbox).await
}
pub async fn unified_diff_from_chunks_with_context(
path: &PathUri,
chunks: &[UpdateFileChunk],
context: usize,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
let AppliedPatch {
original_contents,
new_contents,
} = derive_new_contents_from_chunks(path, 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,
})
}
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 pretty_assertions::assert_eq;
use std::fs;
use std::string::ToString;
use tempfile::tempdir;
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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:\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 = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
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(),
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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\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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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",
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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,
},
}],
true,
)
);
assert_eq!(fs::read_to_string(src).unwrap(), "line\n");
assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n");
}
#[tokio::test]
async fn test_multiple_update_chunks_apply_to_single_file() {
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 mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(
&patch,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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");
}
#[tokio::test]
async fn test_update_file_hunk_interleaved_changes() {
let dir = tempdir().unwrap();
let path = dir.path().join("interleaved.txt");
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
None,
)
.await
.unwrap();
let contents = fs::read_to_string(path).unwrap();
assert_eq!(
contents,
"line1\nline2-replacement\nafter-context\nsecond-line\n"
);
}
#[tokio::test]
async fn test_update_line_with_unicode_dash() {
let dir = tempdir().unwrap();
let path = dir.path().join("unicode.py");
let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n";
std::fs::write(&path, original).unwrap();
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
None,
)
.await
.unwrap();
let expected = "import asyncio # HELLO\n";
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, expected);
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);
assert_eq!(String::from_utf8(stderr).unwrap(), "");
}
#[tokio::test]
async fn test_unified_diff() {
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_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
let diff = unified_diff_from_chunks(
&path_uri,
update_file_chunks,
LOCAL_FS.as_ref(),
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() {
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 resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
let diff = unified_diff_from_chunks(
&resolved_path,
chunks,
LOCAL_FS.as_ref(),
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() {
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 resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
let diff = unified_diff_from_chunks(
&resolved_path,
chunks,
LOCAL_FS.as_ref(),
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() {
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_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
let diff =
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), 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() {
let dir = tempdir().unwrap();
let path = dir.path().join("interleaved.txt");
fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap();
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);
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_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
let diff =
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), 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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
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 = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
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(),
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,
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
None,
)
.await
.unwrap();
assert!(!delta.is_exact());
}
}