use super::{ESCAPED_CHARS_BYTES, Hunk, HunkRange, Line, NO_NEWLINE_AT_EOF};
use crate::{
LineEnd,
patch::Diff,
utils::{LineIter, Text},
};
use std::{borrow::Cow, fmt};
type Result<T, E = ParsePatchError> = std::result::Result<T, E>;
#[derive(Debug)]
pub enum HeaderLineKind {
Adding,
Removing,
}
impl fmt::Display for HeaderLineKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match self {
HeaderLineKind::Adding => "+++",
HeaderLineKind::Removing => "---",
}
)
}
}
#[derive(thiserror::Error, Debug)]
pub enum ParsePatchError {
#[error("unexpected end of file")]
UnexpectedEof,
#[error("multiple '{0}' lines")]
HeaderMultipleLines(HeaderLineKind),
#[error("unable to parse filename")]
UnableToParseFilename,
#[error("filename unterminated")]
UnterminatedFilename,
#[error("invalid char in unquoted filename")]
InvalidCharInUnquotedFilename,
#[error("expected escaped character")]
ExpectedEscapedCharacter,
#[error("invalid escaped character")]
InvalidEscapedCharacter,
#[error("invalid unescaped character")]
InvalidUnescapedCharacter,
#[error("no hunks found")]
NoHunks,
#[error("hunks not in order or overlap")]
HunksOrder,
#[error("hunk header does not match hunk")]
HunkHeaderHunkMismatch,
#[error("unable to parse hunk header")]
HunkHeader,
#[error("hunk header unterminated")]
HunkHeaderUnterminated,
#[error("can't parse range")]
Range,
#[error("expected end of hunk")]
ExpectedEndOfHunk,
#[error("expected no more deleted lines")]
UnexpectedDeletedLine,
#[error("expected no more inserted lines")]
UnexpectedInsertLine,
#[error("unexpected 'No newline at end of file' line")]
UnexpectedNoNewlineAtEOF,
#[error("unexpected line in hunk body")]
UnexpectedLineInHunkBody,
#[error("missing newline")]
MissingNewline,
}
#[derive(Debug, Clone, Default)]
pub enum HunkRangeStrategy {
#[default]
Check,
Recount,
Ignore,
}
#[derive(Debug, Clone)]
pub struct ParserConfig {
pub hunk_strategy: HunkRangeStrategy,
pub skip_order_check: bool,
pub strip_ab_prefix: bool,
}
impl Default for ParserConfig {
fn default() -> Self {
Self {
hunk_strategy: HunkRangeStrategy::default(),
skip_order_check: false,
strip_ab_prefix: true,
}
}
}
struct Parser<'a, T: Text + ?Sized> {
lines: std::iter::Peekable<LineIter<'a, T>>,
config: ParserConfig,
}
impl<'a, T: Text + ?Sized> Parser<'a, T> {
fn new(input: &'a T) -> Self {
Self::with_config(input, ParserConfig::default())
}
fn with_config(input: &'a T, config: ParserConfig) -> Self {
Self {
lines: LineIter::new(input).peekable(),
config,
}
}
fn peek(&mut self) -> Option<&(&'a T, Option<LineEnd>)> {
self.lines.peek()
}
fn next(&mut self) -> Result<(&'a T, Option<LineEnd>)> {
let line = self.lines.next().ok_or(ParsePatchError::UnexpectedEof)?;
Ok(line)
}
}
pub fn parse_multiple(input: &str) -> Result<Vec<Diff<'_, str>>> {
parse_multiple_with_config(input, ParserConfig::default())
}
pub fn parse_multiple_with_config(input: &str, config: ParserConfig) -> Result<Vec<Diff<'_, str>>> {
let mut parser = Parser::with_config(input, config);
let mut patches = vec![];
loop {
match (patch_header(&mut parser), hunks(&mut parser)) {
(Ok(header), Ok(hunks)) => {
let original = header.0.map(|(line, _end)| convert_cow_to_str(line));
let modified = header.1.map(|(line, _end)| convert_cow_to_str(line));
patches.push(Diff::new(original, modified, hunks))
}
(Ok((None, None)), Err(_)) => break,
(Ok(header), Err(ParsePatchError::NoHunks))
if header.0.is_some() || header.1.is_some() =>
{
let original = header.0.map(|(line, _end)| convert_cow_to_str(line));
let modified = header.1.map(|(line, _end)| convert_cow_to_str(line));
patches.push(Diff::new(original, modified, vec![]))
}
(Ok(_), Err(e)) | (Err(e), _) => {
return Err(e);
}
}
}
Ok(patches)
}
pub fn parse(input: &str) -> Result<Diff<'_, str>> {
let mut parser = Parser::new(input);
let header = patch_header(&mut parser)?;
let hunks = hunks(&mut parser)?;
let original = header.0.map(|(line, _end)| convert_cow_to_str(line));
let modified = header.1.map(|(line, _end)| convert_cow_to_str(line));
Ok(Diff::new(original, modified, hunks))
}
pub fn parse_bytes_multiple(input: &[u8]) -> Result<Vec<Diff<'_, [u8]>>> {
parse_bytes_multiple_with_config(input, ParserConfig::default())
}
pub fn parse_bytes_multiple_with_config(
input: &[u8],
config: ParserConfig,
) -> Result<Vec<Diff<'_, [u8]>>> {
let mut parser = Parser::with_config(input, config);
let mut patches = vec![];
loop {
match (patch_header(&mut parser), hunks(&mut parser)) {
(Ok(header), Ok(hunks)) => {
let original = header.0.map(|(line, _end)| line);
let modified = header.1.map(|(line, _end)| line);
patches.push(Diff::new(original, modified, hunks))
}
(Ok((None, None)), Err(_)) | (Err(_), Err(_)) => break,
(Ok(header), Err(ParsePatchError::NoHunks))
if header.0.is_some() || header.1.is_some() =>
{
let original = header.0.map(|(line, _end)| line);
let modified = header.1.map(|(line, _end)| line);
patches.push(Diff::new(original, modified, vec![]))
}
(Ok(_), Err(e)) | (Err(e), Ok(_)) => {
return Err(e);
}
}
}
Ok(patches)
}
pub fn parse_bytes(input: &[u8]) -> Result<Diff<'_, [u8]>> {
let mut parser = Parser::new(input);
let header = patch_header(&mut parser)?;
let hunks = hunks(&mut parser)?;
let original = header.0.map(|(line, _end)| line);
let modified = header.1.map(|(line, _end)| line);
Ok(Diff::new(original, modified, hunks))
}
fn convert_cow_to_str(cow: Cow<'_, [u8]>) -> Cow<'_, str> {
match cow {
Cow::Borrowed(b) => std::str::from_utf8(b).unwrap().into(),
Cow::Owned(o) => String::from_utf8(o).unwrap().into(),
}
}
#[allow(clippy::type_complexity)]
fn patch_header<'a, T: Text + ToOwned + ?Sized>(
parser: &mut Parser<'a, T>,
) -> Result<(
Option<(Cow<'a, [u8]>, Option<LineEnd>)>,
Option<(Cow<'a, [u8]>, Option<LineEnd>)>,
)> {
let (git_original, git_modified) = header_preamble(parser)?;
let strip_ab_prefix = parser.config.strip_ab_prefix;
let mut filename1 = None;
let mut filename2 = None;
let mut saw_traditional_header1 = false;
let mut saw_traditional_header2 = false;
while let Some((line, _end)) = parser.peek() {
if line.starts_with("--- ") {
if saw_traditional_header1 {
return Err(ParsePatchError::HeaderMultipleLines(
HeaderLineKind::Removing,
));
}
saw_traditional_header1 = true;
filename1 = parse_filename("--- ", parser.next()?, strip_ab_prefix)?;
} else if line.starts_with("+++ ") {
if saw_traditional_header2 {
return Err(ParsePatchError::HeaderMultipleLines(HeaderLineKind::Adding));
}
saw_traditional_header2 = true;
filename2 = parse_filename("+++ ", parser.next()?, strip_ab_prefix)?;
} else {
break;
}
}
let original = if saw_traditional_header1 {
filename1
} else {
git_original
};
let modified = if saw_traditional_header2 {
filename2
} else {
git_modified
};
Ok((original, modified))
}
#[allow(clippy::type_complexity)]
fn header_preamble<'a, T: Text + ToOwned + ?Sized>(
parser: &mut Parser<'a, T>,
) -> Result<(
Option<(Cow<'a, [u8]>, Option<LineEnd>)>,
Option<(Cow<'a, [u8]>, Option<LineEnd>)>,
)> {
let strip_ab_prefix = parser.config.strip_ab_prefix;
let mut git_original = None;
let mut git_modified = None;
let mut rename_from = None;
let mut rename_to = None;
let mut seen_diff_git = false;
while let Some((line, end)) = parser.peek() {
if line.starts_with("--- ") | line.starts_with("+++ ") | line.starts_with("@@ ") {
break;
}
if line.starts_with("diff --git ") {
if seen_diff_git && (git_original.is_some() || rename_from.is_some()) {
break;
}
seen_diff_git = true;
if let Some(rest) = line.strip_prefix("diff --git ") {
if let Some((file1, file2)) = rest.split_at_exclusive(" b/") {
let has_prefix = strip_ab_prefix;
git_original = parse_git_filename(file1, has_prefix).map(|f| (f, *end));
git_modified = parse_git_filename(file2, has_prefix).map(|f| (f, *end));
} else if let Some((file1, file2)) = rest.split_at_exclusive(" ") {
let has_dev_null =
file1.as_bytes() == b"/dev/null" || file2.as_bytes() == b"/dev/null";
let has_prefix = has_dev_null && strip_ab_prefix;
git_original = parse_git_filename(file1, has_prefix).map(|f| (f, *end));
git_modified = parse_git_filename(file2, has_prefix).map(|f| (f, *end));
}
}
}
else if line.starts_with("rename from ")
&& let Some(filename) = line.strip_prefix("rename from ")
{
rename_from = Some((Cow::Borrowed(filename.as_bytes()), *end));
} else if line.starts_with("rename to ")
&& let Some(filename) = line.strip_prefix("rename to ")
{
rename_to = Some((Cow::Borrowed(filename.as_bytes()), *end));
}
parser.next()?;
}
let original = rename_from.or(git_original);
let modified = rename_to.or(git_modified);
Ok((original, modified))
}
#[allow(clippy::type_complexity)]
fn parse_filename<'a, T: Text + ToOwned + ?Sized>(
prefix: &str,
l: (&'a T, Option<LineEnd>),
strip_ab_prefix: bool,
) -> Result<Option<(Cow<'a, [u8]>, Option<LineEnd>)>> {
let line =
l.0.strip_prefix(prefix)
.ok_or(ParsePatchError::UnableToParseFilename)?;
let filename = if let Some((filename, _)) = line.split_at_exclusive("\t") {
filename
} else if let Some((filename, _)) = line.split_at_exclusive(" ") {
filename
} else if let Some((filename, _)) = line.split_at_exclusive("\n") {
filename
} else {
line
};
if filename.as_bytes() == b"/dev/null" {
return Ok(None);
}
let mut parsed_filename = if let Some(quoted) = is_quoted(filename) {
escaped_filename(quoted)?
} else {
unescaped_filename(filename)?
};
if strip_ab_prefix && let Cow::Borrowed(bytes) = parsed_filename {
if let Some(rest) = std::str::from_utf8(bytes)
.ok()
.and_then(|s| s.strip_prefix("a/"))
{
parsed_filename = Cow::Borrowed(rest.as_bytes());
} else if let Some(rest) = std::str::from_utf8(bytes)
.ok()
.and_then(|s| s.strip_prefix("b/"))
{
parsed_filename = Cow::Borrowed(rest.as_bytes());
}
}
Ok(Some((parsed_filename, l.1)))
}
fn is_quoted<T: Text + ?Sized>(s: &T) -> Option<&T> {
s.strip_prefix("\"").and_then(|s| s.strip_suffix("\""))
}
fn unescaped_filename<T: Text + ToOwned + ?Sized>(filename: &T) -> Result<Cow<'_, [u8]>> {
let bytes = filename.as_bytes().trim_ascii_end();
if bytes.iter().any(|b| ESCAPED_CHARS_BYTES.contains(b)) {
return Err(ParsePatchError::InvalidCharInUnquotedFilename);
}
Ok(bytes.into())
}
fn escaped_filename<T: Text + ToOwned + ?Sized>(escaped: &T) -> Result<Cow<'_, [u8]>> {
let mut filename = Vec::new();
let mut chars = escaped.as_bytes().iter().copied();
while let Some(c) = chars.next() {
if c == b'\\' {
let ch = match chars
.next()
.ok_or(ParsePatchError::ExpectedEscapedCharacter)?
{
b'n' => b'\n',
b't' => b'\t',
b'0' => b'\0',
b'r' => b'\r',
b'\"' => b'\"',
b'\\' => b'\\',
_ => return Err(ParsePatchError::InvalidEscapedCharacter),
};
filename.push(ch);
} else if ESCAPED_CHARS_BYTES.contains(&c) {
return Err(ParsePatchError::InvalidUnescapedCharacter);
} else {
filename.push(c);
}
}
Ok(filename.into())
}
fn parse_git_filename<T: Text + ?Sized>(filename: &T, has_prefix: bool) -> Option<Cow<'_, [u8]>> {
if filename.as_bytes() == b"/dev/null" {
return None;
}
if has_prefix {
if let Some(stripped) = filename.strip_prefix("a/") {
return Some(Cow::Borrowed(stripped.as_bytes()));
} else if let Some(stripped) = filename.strip_prefix("b/") {
return Some(Cow::Borrowed(stripped.as_bytes()));
}
}
Some(Cow::Borrowed(filename.as_bytes()))
}
fn verify_hunks_in_order<T: ?Sized + ToOwned>(hunks: &[Hunk<'_, T>]) -> bool {
for hunk in hunks.windows(2) {
if hunk[0].old_range.end() > hunk[1].old_range.start()
|| hunk[0].new_range.end() > hunk[1].new_range.start()
{
return false;
}
}
true
}
fn hunks<'a, T: Text + ?Sized + ToOwned>(parser: &mut Parser<'a, T>) -> Result<Vec<Hunk<'a, T>>> {
let mut hunks = Vec::new();
while parser.peek().is_some() {
let r = hunk(parser);
if let Ok(h) = r {
hunks.push(h);
} else {
break;
}
}
if hunks.is_empty() {
return Err(ParsePatchError::NoHunks);
}
if !parser.config.skip_order_check && !verify_hunks_in_order(&hunks) {
return Err(ParsePatchError::HunksOrder);
}
Ok(hunks)
}
fn tolerance_level<T: Text + ?Sized + ToOwned>(lines: &[Line<'_, T>]) -> (usize, bool) {
let mut tolerance = 0;
let mut revlines = lines.iter().rev();
while let Some(Line::Context((_, end))) = revlines.next() {
if end.is_some() {
tolerance += 1;
} else {
break;
}
}
let line_ends_with_newline = matches!(revlines.next(), Some(Line::Context((_, Some(_)))));
(tolerance, line_ends_with_newline)
}
fn hunk<'a, T: Text + ?Sized + ToOwned>(parser: &mut Parser<'a, T>) -> Result<Hunk<'a, T>> {
let n = *parser.peek().ok_or(ParsePatchError::UnexpectedEof)?;
let (mut range1, mut range2, function_context) = hunk_header(n)?;
let _ = parser.next();
let mut lines = hunk_lines(parser, &range1, &range2)?;
let (len1, len2) = super::hunk_lines_count(&lines);
match parser.config.hunk_strategy {
HunkRangeStrategy::Check => {
let t = tolerance_level(&lines);
let tolerance = t.0 + usize::from(t.1);
if len1.abs_diff(range1.len) > tolerance || len2.abs_diff(range2.len) > tolerance {
return Err(ParsePatchError::HunkHeaderHunkMismatch);
}
}
HunkRangeStrategy::Recount => {
let empty_context_lines = lines
.iter()
.rev()
.take_while(|l| match *l {
Line::Context(c) => c.0.len() == 0 && c.1.is_some(),
_ => false,
})
.count();
lines = lines
.into_iter()
.rev()
.skip(empty_context_lines)
.rev()
.collect();
range1.len = len1 - empty_context_lines;
range2.len = len2 - empty_context_lines;
}
HunkRangeStrategy::Ignore => (),
}
Ok(Hunk::new(range1, range2, function_context, lines))
}
type HunkHeader<'a, T> = (HunkRange, HunkRange, Option<(&'a T, Option<LineEnd>)>);
fn hunk_header<T: Text + ?Sized>(oinput: (&T, Option<LineEnd>)) -> Result<HunkHeader<'_, T>> {
let input = oinput
.0
.strip_prefix("@@ ")
.ok_or(ParsePatchError::HunkHeader)?;
let (ranges, function_context) = input
.split_at_exclusive(" @@")
.ok_or(ParsePatchError::HunkHeaderUnterminated)?;
let function_context = function_context.strip_prefix(" ");
let (range1, range2) = ranges
.split_at_exclusive(" ")
.ok_or(ParsePatchError::HunkHeader)?;
let range1 = range(
range1
.strip_prefix("-")
.ok_or(ParsePatchError::HunkHeader)?,
)?;
let range2 = range(
range2
.strip_prefix("+")
.ok_or(ParsePatchError::HunkHeader)?,
)?;
Ok((range1, range2, function_context.map(|fc| (fc, oinput.1))))
}
fn range<T: Text + ?Sized>(s: &T) -> Result<HunkRange> {
let (start, len) = if let Some((start, len)) = s.split_at_exclusive(",") {
(
start.parse().ok_or(ParsePatchError::Range)?,
len.parse().ok_or(ParsePatchError::Range)?,
)
} else {
(s.parse().ok_or(ParsePatchError::Range)?, 1)
};
Ok(HunkRange::new(start, len))
}
fn hunk_lines<'a, T: Text + ?Sized + ToOwned>(
parser: &mut Parser<'a, T>,
old_range: &HunkRange,
new_range: &HunkRange,
) -> Result<Vec<Line<'a, T>>> {
let mut lines: Vec<Line<'a, T>> = Vec::new();
let mut no_newline_context = false;
let mut no_newline_delete = false;
let mut no_newline_insert = false;
let mut old_lines_seen = 0;
let mut new_lines_seen = 0;
let expected_old_lines = old_range.len;
let expected_new_lines = new_range.len;
while let Some(line) = parser.peek() {
if old_lines_seen >= expected_old_lines && new_lines_seen >= expected_new_lines {
if !line.0.starts_with(NO_NEWLINE_AT_EOF) {
break;
}
}
let line = if no_newline_context {
return Err(ParsePatchError::ExpectedEndOfHunk);
} else if let Some(l) = line.0.strip_prefix(" ") {
old_lines_seen += 1;
new_lines_seen += 1;
Line::Context((l, line.1))
} else if line.0.len() == 0 && line.1.is_some() {
old_lines_seen += 1;
new_lines_seen += 1;
Line::Context(*line)
} else if let Some(l) = line.0.strip_prefix("-") {
if no_newline_delete {
return Err(ParsePatchError::UnexpectedDeletedLine);
}
old_lines_seen += 1;
Line::Delete((l, line.1))
} else if let Some(l) = line.0.strip_prefix("+") {
if no_newline_insert {
return Err(ParsePatchError::UnexpectedInsertLine);
}
new_lines_seen += 1;
Line::Insert((l, line.1))
} else if line.0.starts_with(NO_NEWLINE_AT_EOF) {
let last_line = lines
.pop()
.ok_or(ParsePatchError::UnexpectedNoNewlineAtEOF)?;
match last_line {
Line::Context((line, _end)) => {
no_newline_context = true;
Line::Context((line, None))
}
Line::Delete((line, _end)) => {
no_newline_delete = true;
Line::Delete((line, None))
}
Line::Insert((line, _end)) => {
no_newline_insert = true;
Line::Insert((line, None))
}
}
} else {
return Err(ParsePatchError::UnexpectedLineInHunkBody);
};
lines.push(line);
parser.next()?;
}
Ok(lines)
}
#[cfg(test)]
mod tests {
use crate::patch::Line;
use crate::patch::parse::{
HunkRangeStrategy, ParsePatchError, ParserConfig, parse_multiple_with_config,
};
use super::{parse, parse_bytes, parse_multiple};
#[test]
fn test_escaped_filenames() {
let s = "\
--- original
+++ modified
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap();
parse_bytes(s.as_ref()).unwrap();
let s = "\
--- ori\"ginal
+++ modified
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap_err();
parse_bytes(s.as_ref()).unwrap_err();
let s = "\
--- \"ori\\\"g\rinal\"
+++ modified
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap_err();
parse_bytes(s.as_ref()).unwrap_err();
let s = r#"\
--- "ori\"g\tinal"
+++ "mo\0\t\r\n\\dified"
@@ -1,0 +1,1 @@
+Oathbringer
"#;
let p = parse(s).unwrap();
assert_eq!(p.original(), Some("ori\"g\tinal"));
assert_eq!(p.modified(), Some("mo\0\t\r\n\\dified"));
let b = parse_bytes(s.as_ref()).unwrap();
assert_eq!(b.original(), Some(&b"ori\"g\tinal"[..]));
assert_eq!(b.modified(), Some(&b"mo\0\t\r\n\\dified"[..]));
}
#[test]
fn test_missing_filename_header() {
let patch = r#"
@@ -1,11 +1,12 @@
diesel::table! {
users1 (id) {
- id -> Nullable<Integer>,
+ id -> Integer,
}
}
diesel::table! {
- users2 (id) {
- id -> Nullable<Integer>,
+ users2 (myid) {
+ #[sql_name = "id"]
+ myid -> Integer,
}
}
"#;
parse(patch).unwrap();
let s = "\
+++ modified
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap();
let s = "\
--- original
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap();
let s = "\
+++ modified
--- original
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap();
let s = "\
--- original
--- modified
@@ -1,0 +1,1 @@
+Oathbringer
";
parse(s).unwrap_err();
}
#[test]
fn adjacent_hunks_correctly_parse() {
let s = "\
--- original
+++ modified
@@ -110,7 +110,7 @@
--
I am afraid, however, that all I have known - that my story - will be forgotten.
I am afraid for the world that is to come.
-Afraid that my plans will fail. Afraid of a doom worse than the Deepness.
+Afraid that Alendi will fail. Afraid of a doom brought by the Deepness.
Alendi was never the Hero of Ages.
@@ -117,7 +117,7 @@
At best, I have amplified his virtues, creating a Hero where there was none.
-At worst, I fear that all we believe may have been corrupted.
+At worst, I fear that I have corrupted all we believe.
--
Alendi must not reach the Well of Ascension. He must not take the power for himself.
";
parse(s).unwrap();
}
#[test]
fn test_real_world_patches() {
insta::glob!("test-data/*.patch", |path| {
let input = std::fs::read_to_string(path).unwrap();
let patches = parse_multiple_with_config(
&input,
ParserConfig {
hunk_strategy: HunkRangeStrategy::Recount,
skip_order_check: true,
..Default::default()
},
);
insta::assert_debug_snapshot!(patches);
});
}
#[test]
fn test_malformed_patch_strict_mode_fails() {
let input =
std::fs::read_to_string("src/patch/test-data/0002-cross-CMakeLists.txt.patch").unwrap();
let result = parse_multiple(&input);
assert!(
matches!(result, Err(ParsePatchError::HunksOrder)),
"Expected HunksOrder error in strict mode, got {:?}",
result
);
let result = parse_multiple_with_config(
&input,
ParserConfig {
hunk_strategy: HunkRangeStrategy::Recount,
skip_order_check: true,
..Default::default()
},
);
assert!(
result.is_ok(),
"Expected successful parse with lenient config"
);
let patches = result.unwrap();
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].hunks().len(), 16);
}
#[test]
fn test_multi_patch_file() {
let input = std::fs::read_to_string("src/patch/test-data/40.patch").unwrap();
let result = parse_multiple_with_config(
&input,
ParserConfig {
hunk_strategy: HunkRangeStrategy::Recount,
..Default::default()
},
);
match &result {
Ok(patches) => {
assert_eq!(
patches.len(),
16,
"Should parse all 16 file changes from the multi-commit patch"
);
}
Err(e) => {
panic!("Failed to parse multi-patch file: {:?}", e);
}
}
}
#[test]
fn test_from_in_patch_content() {
let patch_with_from_content = r#"--- a/email.txt
+++ b/email.txt
@@ -1,4 +1,4 @@
To: someone@example.com
-From: old@example.com
+From: new@example.com
Subject: Test
Hello world
"#;
let result = parse(patch_with_from_content).unwrap();
assert_eq!(result.hunks().len(), 1);
let hunk = &result.hunks()[0];
let lines: Vec<_> = hunk.lines().iter().collect();
assert_eq!(lines.len(), 5);
match lines[1] {
Line::Delete((content, _)) => assert_eq!(*content, "From: old@example.com"),
_ => panic!("Expected delete line with 'From: old@example.com'"),
}
match lines[2] {
Line::Insert((content, _)) => assert_eq!(*content, "From: new@example.com"),
_ => panic!("Expected insert line with 'From: new@example.com'"),
}
}
#[test]
fn test_pure_renames() {
let patch = r#"diff --git a/old_file.txt b/new_file.txt
similarity index 100%
rename from old_file.txt
rename to new_file.txt
diff --git a/another_old.txt b/another_new.txt
similarity index 100%
rename from another_old.txt
rename to another_new.txt
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 2, "Should parse two rename patches");
assert_eq!(result[0].original(), Some("old_file.txt"));
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(
result[0].hunks().len(),
0,
"Pure rename should have no hunks"
);
assert_eq!(result[1].original(), Some("another_old.txt"));
assert_eq!(result[1].modified(), Some("another_new.txt"));
assert_eq!(
result[1].hunks().len(),
0,
"Pure rename should have no hunks"
);
}
#[test]
fn test_deleted_file() {
let patch = r#"diff --git a/deleted_file.txt b/deleted_file.txt
deleted file mode 100644
index e69de29bb2d1d..0000000000000
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1, "Should parse one delete patch");
assert_eq!(result[0].original(), Some("deleted_file.txt"));
assert_eq!(result[0].modified(), Some("deleted_file.txt"));
assert_eq!(
result[0].hunks().len(),
0,
"Deleted file should have no hunks"
);
}
#[test]
fn test_git_diff_without_rename_metadata() {
let patch = r#"diff --git a/file1.txt b/file2.txt
similarity index 100%
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("file1.txt"));
assert_eq!(result[0].modified(), Some("file2.txt"));
assert_eq!(result[0].hunks().len(), 0);
}
#[test]
fn test_git_diff_without_prefix() {
let patch = r#"diff --git old_file.txt new_file.txt
similarity index 100%
rename from old_file.txt
rename to new_file.txt
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1, "Should parse one rename patch");
assert_eq!(result[0].original(), Some("old_file.txt"));
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 0);
}
#[test]
fn test_git_diff_no_prefix_without_rename_metadata() {
let patch = r#"diff --git deleted_file.txt deleted_file.txt
deleted file mode 100644
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("deleted_file.txt"));
assert_eq!(result[0].modified(), Some("deleted_file.txt"));
assert_eq!(result[0].hunks().len(), 0);
}
#[test]
fn test_git_diff_no_prefix_with_a_in_filename() {
let patch = r#"diff --git a/file.txt a/file.txt
deleted file mode 100644
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("a/file.txt"));
assert_eq!(result[0].modified(), Some("a/file.txt"));
assert_eq!(result[0].hunks().len(), 0);
}
#[test]
fn test_git_diff_new_file_with_dev_null() {
let patch = r#"diff --git a/new_file.txt b/new_file.txt
new file mode 100644
--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1,3 @@
+line 1
+line 2
+line 3
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), None);
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_git_diff_deleted_file_with_dev_null() {
let patch = r#"diff --git a/deleted_file.txt b/deleted_file.txt
deleted file mode 100644
--- a/deleted_file.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-line 1
-line 2
-line 3
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("deleted_file.txt"));
assert_eq!(result[0].modified(), None);
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_git_diff_dev_null_in_git_header() {
let patch = r#"diff --git /dev/null b/new_file.txt
new file mode 100644
--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1,2 @@
+new content
+here
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), None);
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_traditional_unified_diff_no_git_header() {
let patch = r#"--- a/old_file.txt
+++ b/new_file.txt
@@ -1,3 +1,3 @@
line 1
-old line
+new line
line 3
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("old_file.txt"));
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_traditional_unified_diff_no_strip() {
let patch = r#"--- a/old_file.txt
+++ b/new_file.txt
@@ -1,3 +1,3 @@
line 1
-old line
+new line
line 3
"#;
let result = parse_multiple_with_config(
patch,
ParserConfig {
strip_ab_prefix: false,
..Default::default()
},
)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("a/old_file.txt"));
assert_eq!(result[0].modified(), Some("b/new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_traditional_diff_no_ab_prefix() {
let patch = r#"--- old_file.txt
+++ new_file.txt
@@ -1,3 +1,3 @@
line 1
-old line
+new line
line 3
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("old_file.txt"));
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_strip_ab_prefix_new_file_plain_format() {
let patch = r#"--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1,3 @@
+line 1
+line 2
+line 3
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), None);
assert_eq!(result[0].modified(), Some("new_file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_git_diff_no_strip() {
let patch = r#"diff --git a/file.txt b/file.txt
--- a/file.txt
+++ b/file.txt
@@ -1,3 +1,3 @@
line 1
-old line
+new line
line 3
"#;
let result = parse_multiple_with_config(
patch,
ParserConfig {
strip_ab_prefix: false,
..Default::default()
},
)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("a/file.txt"));
assert_eq!(result[0].modified(), Some("b/file.txt"));
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_git_diff_dev_null_deleted_in_git_header() {
let patch = r#"diff --git a/deleted.txt /dev/null
deleted file mode 100644
--- a/deleted.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-old content
-here
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("deleted.txt"));
assert_eq!(result[0].modified(), None);
assert_eq!(result[0].hunks().len(), 1);
}
#[test]
fn test_git_diff_dev_null_new_file_git_header_only() {
let patch = r#"diff --git /dev/null b/new_file.txt
new file mode 100644
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), None);
assert_eq!(result[0].modified(), Some("new_file.txt"));
}
#[test]
fn test_git_diff_dev_null_deleted_git_header_only() {
let patch = r#"diff --git a/deleted.txt /dev/null
deleted file mode 100644
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("deleted.txt"));
assert_eq!(result[0].modified(), None);
}
#[test]
fn test_plain_diff_dev_null_deleted() {
let patch = r#"--- a/deleted.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-old content
-here
"#;
let result = parse_multiple(patch).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].original(), Some("deleted.txt"));
assert_eq!(result[0].modified(), None);
assert_eq!(result[0].hunks().len(), 1);
}
}