mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
e26f734f91
## Why `apply_patch` maintained separate batch and streaming parsers for the same patch grammar. That duplicated the parsing rules and allowed final execution to disagree with the live streamed preview. ## What changed - Make `StreamingPatchParser` the single owner of hunk and environment ID parsing. - Keep heredoc and outer patch-boundary normalization in the existing `parse_patch` wrapper, preserving its public API. - Reject non-whitespace content after `*** End Patch` and preserve separator handling after `*** End of File`. - Reject duplicate environment ID preambles explicitly. - Remove the duplicate batch hunk parser and its implementation-specific tests. The change removes 201 net lines while retaining focused coverage for the unified parser's boundary behavior. ## Validation - `just test -p codex-apply-patch` - Compared a 24-hour corpus of 2,788,059 observed `apply_patch` payloads against the previous batch parser. All 2,779,502 accepted payloads produced identical hunks, canonical patch text, and environment IDs; the remaining 8,557 payloads were rejected by both parsers, with zero acceptance or payload mismatches.
661 lines
21 KiB
Rust
661 lines
21 KiB
Rust
//! This module is responsible for parsing & validating a patch into a list of "hunks".
|
|
//! (It does not attempt to actually check that the patch can be applied to the filesystem.)
|
|
//!
|
|
//! The official Lark grammar for the apply-patch format is:
|
|
//!
|
|
//! start: begin_patch environment_id? hunk+ end_patch
|
|
//! begin_patch: "*** Begin Patch" LF
|
|
//! environment_id: "*** Environment ID: " filename LF
|
|
//! end_patch: "*** End Patch" LF?
|
|
//!
|
|
//! hunk: add_hunk | delete_hunk | update_hunk
|
|
//! add_hunk: "*** Add File: " filename LF add_line+
|
|
//! delete_hunk: "*** Delete File: " filename LF
|
|
//! update_hunk: "*** Update File: " filename LF change_move? change?
|
|
//! filename: /(.+)/
|
|
//! add_line: "+" /(.+)/ LF -> line
|
|
//!
|
|
//! change_move: "*** Move to: " filename LF
|
|
//! change: (change_context | change_line)+ eof_line?
|
|
//! change_context: ("@@" | "@@ " /(.+)/) LF
|
|
//! change_line: ("+" | "-" | " ") /(.+)/ LF
|
|
//! eof_line: "*** End of File" LF
|
|
//!
|
|
//! The parser below is a little more lenient than the explicit spec and allows for
|
|
//! leading/trailing whitespace around patch markers.
|
|
use crate::ApplyPatchArgs;
|
|
use crate::streaming_parser::StreamingPatchParser;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
#[cfg(test)]
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
use thiserror::Error;
|
|
|
|
pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
|
|
pub(crate) const END_PATCH_MARKER: &str = "*** End Patch";
|
|
pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: ";
|
|
pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: ";
|
|
pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: ";
|
|
pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: ";
|
|
pub(crate) const EOF_MARKER: &str = "*** End of File";
|
|
pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ ";
|
|
pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
|
|
|
|
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
|
|
/// gpt-4.1. While we could try to require everyone to pass in a strictness
|
|
/// param when invoking apply_patch, it is a pain to thread it through all of
|
|
/// the call sites, so we resign ourselves allowing lenient parsing for all
|
|
/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
|
|
/// gpt-4.1.
|
|
const PARSE_IN_STRICT_MODE: bool = false;
|
|
|
|
#[derive(Debug, PartialEq, Error, Clone)]
|
|
pub enum ParseError {
|
|
#[error("invalid patch: {0}")]
|
|
InvalidPatchError(String),
|
|
#[error("invalid hunk at line {line_number}, {message}")]
|
|
InvalidHunkError { message: String, line_number: usize },
|
|
}
|
|
use ParseError::*;
|
|
|
|
#[derive(Debug, PartialEq, Clone)]
|
|
#[allow(clippy::enum_variant_names)]
|
|
pub enum Hunk {
|
|
AddFile {
|
|
path: PathBuf,
|
|
contents: String,
|
|
},
|
|
DeleteFile {
|
|
path: PathBuf,
|
|
},
|
|
UpdateFile {
|
|
path: PathBuf,
|
|
move_path: Option<PathBuf>,
|
|
|
|
/// Chunks should be in order, i.e. the `change_context` of one chunk
|
|
/// should occur later in the file than the previous chunk.
|
|
chunks: Vec<UpdateFileChunk>,
|
|
},
|
|
}
|
|
|
|
impl Hunk {
|
|
pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
|
|
let path = match self {
|
|
Hunk::UpdateFile { path, .. } => path,
|
|
Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),
|
|
};
|
|
AbsolutePathBuf::resolve_path_against_base(path, cwd)
|
|
}
|
|
|
|
/// Returns the path affected by this hunk, using the move destination for rename hunks.
|
|
pub fn path(&self) -> &Path {
|
|
match self {
|
|
Hunk::AddFile { path, .. } => path,
|
|
Hunk::DeleteFile { path } => path,
|
|
Hunk::UpdateFile {
|
|
move_path: Some(path),
|
|
..
|
|
} => path,
|
|
Hunk::UpdateFile {
|
|
path,
|
|
move_path: None,
|
|
..
|
|
} => path,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
use Hunk::*;
|
|
|
|
#[derive(Debug, PartialEq, Clone)]
|
|
pub struct UpdateFileChunk {
|
|
/// A single line of context used to narrow down the position of the chunk
|
|
/// (this is usually a class, method, or function definition.)
|
|
pub change_context: Option<String>,
|
|
|
|
/// A contiguous block of lines that should be replaced with `new_lines`.
|
|
/// `old_lines` must occur strictly after `change_context`.
|
|
pub old_lines: Vec<String>,
|
|
pub new_lines: Vec<String>,
|
|
|
|
/// If set to true, `old_lines` must occur at the end of the source file.
|
|
/// (Tolerance around trailing newlines should be encouraged.)
|
|
pub is_end_of_file: bool,
|
|
}
|
|
|
|
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
|
|
let mode = if PARSE_IN_STRICT_MODE {
|
|
ParseMode::Strict
|
|
} else {
|
|
ParseMode::Lenient
|
|
};
|
|
parse_patch_text(patch, mode)
|
|
}
|
|
|
|
enum ParseMode {
|
|
/// Parse the patch text argument as is.
|
|
Strict,
|
|
|
|
/// GPT-4.1 is known to formulate the `command` array for the `local_shell`
|
|
/// tool call for `apply_patch` call using something like the following:
|
|
///
|
|
/// ```json
|
|
/// [
|
|
/// "apply_patch",
|
|
/// "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
|
|
/// ]
|
|
/// ```
|
|
///
|
|
/// This is a problem because `local_shell` is a bit of a misnomer: the
|
|
/// `command` is not invoked by passing the arguments to a shell like Bash,
|
|
/// but are invoked using something akin to `execvpe(3)`.
|
|
///
|
|
/// This is significant in this case because where a shell would interpret
|
|
/// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is
|
|
/// fine, as `apply_patch` is specified to read from stdin if no argument is
|
|
/// passed), `execvpe(3)` interprets the heredoc as a literal string. To get
|
|
/// the `local_shell` tool to run a command the way shell would, the
|
|
/// `command` array must be something like:
|
|
///
|
|
/// ```json
|
|
/// [
|
|
/// "bash",
|
|
/// "-lc",
|
|
/// "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
|
|
/// ]
|
|
/// ```
|
|
///
|
|
/// In lenient mode, we check if the argument to `apply_patch` starts with
|
|
/// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
|
|
/// trim() the result, and treat what is left as the patch text.
|
|
Lenient,
|
|
}
|
|
|
|
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
|
|
let lines: Vec<&str> = patch.trim().lines().collect();
|
|
let patch_lines = match mode {
|
|
ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
|
|
ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
|
|
};
|
|
|
|
let patch = patch_lines.join("\n");
|
|
let mut parser = StreamingPatchParser::default();
|
|
parser.push_delta(&patch)?;
|
|
let hunks = parser.finish()?;
|
|
let environment_id = parser.environment_id().map(str::to_owned);
|
|
Ok(ApplyPatchArgs {
|
|
hunks,
|
|
patch,
|
|
workdir: None,
|
|
environment_id,
|
|
})
|
|
}
|
|
|
|
/// Checks the start and end lines of the patch text for `apply_patch`,
|
|
/// returning an error if they do not match the expected markers.
|
|
fn check_patch_boundaries_strict<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> {
|
|
let (first_line, last_line) = match lines {
|
|
[] => (None, None),
|
|
[first] => (Some(first), Some(first)),
|
|
[first, .., last] => (Some(first), Some(last)),
|
|
};
|
|
check_start_and_end_lines_strict(first_line, last_line)?;
|
|
Ok(lines)
|
|
}
|
|
|
|
/// If we are in lenient mode, we check if the first line starts with `<<EOF`
|
|
/// (possibly quoted) and the last line ends with `EOF`. There must be at least
|
|
/// 4 lines total because the heredoc markers take up 2 lines and the patch text
|
|
/// must have at least 2 lines.
|
|
///
|
|
/// If successful, returns the lines of the patch text that contain the patch
|
|
/// contents, excluding the heredoc markers.
|
|
fn check_patch_boundaries_lenient<'a>(
|
|
original_lines: &'a [&'a str],
|
|
) -> Result<&'a [&'a str], ParseError> {
|
|
let original_parse_error = match check_patch_boundaries_strict(original_lines) {
|
|
Ok(lines) => return Ok(lines),
|
|
Err(e) => e,
|
|
};
|
|
|
|
match original_lines {
|
|
[first, .., last] => {
|
|
if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
|
|
&& last.ends_with("EOF")
|
|
&& original_lines.len() >= 4
|
|
{
|
|
let inner_lines = &original_lines[1..original_lines.len() - 1];
|
|
check_patch_boundaries_strict(inner_lines)
|
|
} else {
|
|
Err(original_parse_error)
|
|
}
|
|
}
|
|
_ => Err(original_parse_error),
|
|
}
|
|
}
|
|
|
|
fn check_start_and_end_lines_strict(
|
|
first_line: Option<&&str>,
|
|
last_line: Option<&&str>,
|
|
) -> Result<(), ParseError> {
|
|
let first_line = first_line.map(|line| line.trim());
|
|
let last_line = last_line.map(|line| line.trim());
|
|
|
|
match (first_line, last_line) {
|
|
(Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
|
|
Ok(())
|
|
}
|
|
(Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
|
|
"The first line of the patch must be '*** Begin Patch'",
|
|
))),
|
|
_ => Err(InvalidPatchError(String::from(
|
|
"The last line of the patch must be '*** End Patch'",
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_patch() {
|
|
assert_eq!(
|
|
parse_patch_text("bad", ParseMode::Strict),
|
|
Err(InvalidPatchError(
|
|
"The first line of the patch must be '*** Begin Patch'".to_string()
|
|
))
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
|
|
Err(InvalidPatchError(
|
|
"The last line of the patch must be '*** End Patch'".to_string()
|
|
))
|
|
);
|
|
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
concat!(
|
|
"*** Begin Patch",
|
|
" ",
|
|
"\n*** Add File: foo\n+hi\n",
|
|
" ",
|
|
"*** End Patch"
|
|
),
|
|
ParseMode::Strict
|
|
)
|
|
.unwrap()
|
|
.hunks,
|
|
vec![AddFile {
|
|
path: PathBuf::from("foo"),
|
|
contents: "hi\n".to_string()
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** Update File: test.py\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
),
|
|
Err(InvalidHunkError {
|
|
message: "Update file hunk for path 'test.py' is empty".to_string(),
|
|
line_number: 2,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
)
|
|
.unwrap()
|
|
.hunks,
|
|
Vec::new()
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** Add File: path/add.py\n\
|
|
+abc\n\
|
|
+def\n\
|
|
*** Delete File: path/delete.py\n\
|
|
*** Update File: path/update.py\n\
|
|
*** Move to: path/update2.py\n\
|
|
@@ def f():\n\
|
|
- pass\n\
|
|
+ return 123\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
)
|
|
.unwrap()
|
|
.hunks,
|
|
vec![
|
|
AddFile {
|
|
path: PathBuf::from("path/add.py"),
|
|
contents: "abc\ndef\n".to_string()
|
|
},
|
|
DeleteFile {
|
|
path: PathBuf::from("path/delete.py")
|
|
},
|
|
UpdateFile {
|
|
path: PathBuf::from("path/update.py"),
|
|
move_path: Some(PathBuf::from("path/update2.py")),
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: Some("def f():".to_string()),
|
|
old_lines: vec![" pass".to_string()],
|
|
new_lines: vec![" return 123".to_string()],
|
|
is_end_of_file: false
|
|
}]
|
|
}
|
|
]
|
|
);
|
|
// Update hunk followed by another hunk (Add File).
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** Update File: file.py\n\
|
|
@@\n\
|
|
+line\n\
|
|
*** Add File: other.py\n\
|
|
+content\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
)
|
|
.unwrap()
|
|
.hunks,
|
|
vec![
|
|
UpdateFile {
|
|
path: PathBuf::from("file.py"),
|
|
move_path: None,
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: None,
|
|
old_lines: vec![],
|
|
new_lines: vec!["line".to_string()],
|
|
is_end_of_file: false
|
|
}],
|
|
},
|
|
AddFile {
|
|
path: PathBuf::from("other.py"),
|
|
contents: "content\n".to_string()
|
|
}
|
|
]
|
|
);
|
|
|
|
// Update hunk without an explicit @@ header for the first chunk should parse.
|
|
// Use a raw string to preserve the leading space diff marker on the context line.
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
r#"*** Begin Patch
|
|
*** Update File: file2.py
|
|
import foo
|
|
+bar
|
|
*** End Patch"#,
|
|
ParseMode::Strict
|
|
)
|
|
.unwrap()
|
|
.hunks,
|
|
vec![UpdateFile {
|
|
path: PathBuf::from("file2.py"),
|
|
move_path: None,
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: None,
|
|
old_lines: vec!["import foo".to_string()],
|
|
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
|
is_end_of_file: false,
|
|
}],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_patch_preserves_end_of_file_marker() {
|
|
let patch =
|
|
"*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch";
|
|
assert_eq!(
|
|
parse_patch(patch),
|
|
Ok(ApplyPatchArgs {
|
|
hunks: vec![UpdateFile {
|
|
path: PathBuf::from("file.txt"),
|
|
move_path: None,
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: None,
|
|
old_lines: Vec::new(),
|
|
new_lines: vec!["quux".to_string()],
|
|
is_end_of_file: true,
|
|
}],
|
|
}],
|
|
patch: patch.to_string(),
|
|
workdir: None,
|
|
environment_id: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let absolute_delete = dir.path().join("absolute-delete.py").abs();
|
|
let absolute_update = dir.path().join("absolute-update.py").abs();
|
|
let patch_text = format!(
|
|
r#"*** Begin Patch
|
|
*** Add File: relative-add.py
|
|
+content
|
|
*** Delete File: {}
|
|
*** Update File: {}
|
|
@@
|
|
-old
|
|
+new
|
|
*** End Patch"#,
|
|
absolute_delete.display(),
|
|
absolute_update.display()
|
|
);
|
|
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text, ParseMode::Strict)
|
|
.unwrap()
|
|
.hunks,
|
|
vec![
|
|
AddFile {
|
|
path: PathBuf::from("relative-add.py"),
|
|
contents: "content\n".to_string()
|
|
},
|
|
DeleteFile {
|
|
path: absolute_delete.to_path_buf()
|
|
},
|
|
UpdateFile {
|
|
path: absolute_update.to_path_buf(),
|
|
move_path: None,
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: None,
|
|
old_lines: vec!["old".to_string()],
|
|
new_lines: vec!["new".to_string()],
|
|
is_end_of_file: false
|
|
}]
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
|
|
let cwd_dir = tempfile::tempdir().unwrap();
|
|
let cwd = cwd_dir.path().to_path_buf().abs();
|
|
let absolute_dir = tempfile::tempdir().unwrap();
|
|
let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
|
|
let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
|
|
let absolute_update = absolute_dir.path().join("absolute-update.py").abs();
|
|
|
|
for (hunk, expected_path) in [
|
|
(
|
|
AddFile {
|
|
path: PathBuf::from("relative-add.py"),
|
|
contents: String::new(),
|
|
},
|
|
cwd.join("relative-add.py"),
|
|
),
|
|
(
|
|
DeleteFile {
|
|
path: PathBuf::from("relative-delete.py"),
|
|
},
|
|
cwd.join("relative-delete.py"),
|
|
),
|
|
(
|
|
UpdateFile {
|
|
path: PathBuf::from("relative-update.py"),
|
|
move_path: None,
|
|
chunks: Vec::new(),
|
|
},
|
|
cwd.join("relative-update.py"),
|
|
),
|
|
(
|
|
AddFile {
|
|
path: absolute_add.to_path_buf(),
|
|
contents: String::new(),
|
|
},
|
|
absolute_add,
|
|
),
|
|
(
|
|
DeleteFile {
|
|
path: absolute_delete.to_path_buf(),
|
|
},
|
|
absolute_delete,
|
|
),
|
|
(
|
|
UpdateFile {
|
|
path: absolute_update.to_path_buf(),
|
|
move_path: None,
|
|
chunks: Vec::new(),
|
|
},
|
|
absolute_update,
|
|
),
|
|
] {
|
|
assert_eq!(hunk.resolve_path(&cwd), expected_path);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_patch_lenient() {
|
|
let patch_text = r#"*** Begin Patch
|
|
*** Update File: file2.py
|
|
import foo
|
|
+bar
|
|
*** End Patch"#;
|
|
let expected_patch = vec![UpdateFile {
|
|
path: PathBuf::from("file2.py"),
|
|
move_path: None,
|
|
chunks: vec![UpdateFileChunk {
|
|
change_context: None,
|
|
old_lines: vec!["import foo".to_string()],
|
|
new_lines: vec!["import foo".to_string(), "bar".to_string()],
|
|
is_end_of_file: false,
|
|
}],
|
|
}];
|
|
let expected_error =
|
|
InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
|
|
|
|
let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
|
|
Err(expected_error.clone())
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
|
|
Ok(ApplyPatchArgs {
|
|
hunks: expected_patch.clone(),
|
|
patch: patch_text.to_string(),
|
|
workdir: None,
|
|
environment_id: None,
|
|
})
|
|
);
|
|
|
|
let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
|
|
Err(expected_error.clone())
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
|
|
Ok(ApplyPatchArgs {
|
|
hunks: expected_patch.clone(),
|
|
patch: patch_text.to_string(),
|
|
workdir: None,
|
|
environment_id: None,
|
|
})
|
|
);
|
|
|
|
let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
|
|
Err(expected_error.clone())
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
|
|
Ok(ApplyPatchArgs {
|
|
hunks: expected_patch,
|
|
patch: patch_text.to_string(),
|
|
workdir: None,
|
|
environment_id: None,
|
|
})
|
|
);
|
|
|
|
let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
|
|
Err(expected_error.clone())
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
|
|
Err(expected_error.clone())
|
|
);
|
|
|
|
let patch_text_with_missing_closing_heredoc =
|
|
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
|
|
Err(expected_error)
|
|
);
|
|
assert_eq!(
|
|
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
|
|
Err(InvalidPatchError(
|
|
"The last line of the patch must be '*** End Patch'".to_string()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_patch_environment_id_preamble() {
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** Environment ID: remote\n\
|
|
*** Add File: hello.txt\n\
|
|
+hello\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
),
|
|
Ok(ApplyPatchArgs {
|
|
hunks: vec![AddFile {
|
|
path: PathBuf::from("hello.txt"),
|
|
contents: "hello\n".to_string(),
|
|
}],
|
|
patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(),
|
|
workdir: None,
|
|
environment_id: Some("remote".to_string()),
|
|
})
|
|
);
|
|
|
|
assert_eq!(
|
|
parse_patch_text(
|
|
"*** Begin Patch\n\
|
|
*** Environment ID: \n\
|
|
*** Add File: hello.txt\n\
|
|
+hello\n\
|
|
*** End Patch",
|
|
ParseMode::Strict
|
|
),
|
|
Err(InvalidPatchError(
|
|
"apply_patch environment_id cannot be empty".to_string()
|
|
))
|
|
);
|
|
}
|