use thiserror::Error;
const BEGIN: &str = "*** Begin Patch";
const END: &str = "*** End Patch";
const ADD: &str = "*** Add File: ";
const DELETE: &str = "*** Delete File: ";
const UPDATE: &str = "*** Update File: ";
const MOVE: &str = "*** Move to: ";
const EOF_MARK: &str = "*** End of File";
#[derive(Debug, PartialEq)]
pub struct Patch {
pub ops: Vec<FileOp>,
}
#[derive(Debug, PartialEq)]
pub enum FileOp {
Add {
path: String,
lines: Vec<String>,
},
Delete {
path: String,
},
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Hunk>,
},
}
#[derive(Debug, Default, PartialEq)]
pub struct Hunk {
pub anchors: Vec<String>,
pub old: Vec<String>,
pub new: Vec<String>,
pub eof: bool,
}
impl Hunk {
fn is_blank(&self) -> bool {
self.anchors.is_empty() && self.old.is_empty() && self.new.is_empty() && !self.eof
}
}
#[derive(Debug, Error, PartialEq)]
pub enum Error {
#[error("patch must start with {BEGIN:?}")]
MissingBegin,
#[error("patch must end with {END:?}")]
MissingEnd,
#[error("patch contains no file operations")]
Empty,
#[error(
"line {line}: unrecognized patch line {content:?}; inside an update, \
lines start with ' ' (context), '-' (removal), '+' (addition), or '@@'"
)]
BadLine { line: usize, content: String },
#[error(
"line {line}: bare blank line; only an update body reads a blank \
line as empty context — in an add section write a lone '+' for \
a blank content line"
)]
BlankLine { line: usize },
#[error("line {line}: {MOVE:?} must directly follow a {UPDATE:?} line")]
MisplacedMove { line: usize },
#[error("update of {path} has no hunks")]
EmptyUpdate { path: String },
#[error("update of {path}: hunk {hunk} changes nothing")]
NoChange { path: String, hunk: usize },
#[error("{path} appears in more than one file operation")]
DuplicatePath { path: String },
}
pub fn parse(text: &str) -> Result<Patch, Error> {
let lines: Vec<&str> = text.lines().collect();
let first = lines.iter().position(|l| !l.trim().is_empty());
let last = lines.iter().rposition(|l| !l.trim().is_empty());
let (Some(first), Some(last)) = (first, last) else {
return Err(Error::MissingBegin);
};
if lines[first] != BEGIN {
return Err(Error::MissingBegin);
}
if lines[last] != END {
return Err(Error::MissingEnd);
}
let mut ops = Vec::new();
let mut i = first + 1;
while i < last {
let line = lines[i];
if let Some(path) = line.strip_prefix(ADD) {
let (op, next) = parse_add(path, &lines, i + 1, last);
ops.push(op);
i = next;
} else if let Some(path) = line.strip_prefix(DELETE) {
ops.push(FileOp::Delete {
path: path.to_string(),
});
i += 1;
} else if let Some(path) = line.strip_prefix(UPDATE) {
let (op, next) = parse_update(path, &lines, i + 1, last)?;
ops.push(op);
i = next;
} else if line.strip_prefix(MOVE).is_some() {
return Err(Error::MisplacedMove { line: i + 1 });
} else if line.trim().is_empty() {
return Err(Error::BlankLine { line: i + 1 });
} else {
return Err(Error::BadLine {
line: i + 1,
content: line.to_string(),
});
}
}
if ops.is_empty() {
return Err(Error::Empty);
}
check_duplicates(&ops)?;
Ok(Patch { ops })
}
fn is_section(line: &str) -> bool {
[ADD, DELETE, UPDATE, MOVE]
.iter()
.any(|m| line.starts_with(m))
}
fn parse_add(path: &str, lines: &[&str], from: usize, until: usize) -> (FileOp, usize) {
let mut content = Vec::new();
let mut i = from;
while i < until {
if let Some(rest) = lines[i].strip_prefix('+') {
content.push(rest.to_string());
} else {
break;
}
i += 1;
}
let op = FileOp::Add {
path: path.to_string(),
lines: content,
};
(op, i)
}
fn parse_update(
path: &str,
lines: &[&str],
from: usize,
until: usize,
) -> Result<(FileOp, usize), Error> {
let mut i = from;
let mut move_to = None;
if i < until
&& let Some(to) = lines[i].strip_prefix(MOVE)
{
move_to = Some(to.to_string());
i += 1;
}
let mut hunks: Vec<Hunk> = Vec::new();
let mut cur = Hunk::default();
let mut flush = |cur: &mut Hunk| -> Result<(), Error> {
let hunk = std::mem::take(cur);
if hunk.is_blank() {
return Ok(());
}
if hunk.old == hunk.new {
return Err(Error::NoChange {
path: path.to_string(),
hunk: hunks.len() + 1,
});
}
hunks.push(hunk);
Ok(())
};
let mut after_eof = false;
while i < until && !is_section(lines[i]) && lines[i] != END {
let line = lines[i];
if line == EOF_MARK {
cur.eof = true;
flush(&mut cur)?;
after_eof = true;
} else if line.is_empty() {
if !after_eof {
cur.old.push(String::new());
cur.new.push(String::new());
}
} else if line == "@@" {
flush(&mut cur)?;
after_eof = false;
} else if let Some(anchor) = line.strip_prefix("@@ ") {
if !cur.old.is_empty() || !cur.new.is_empty() {
flush(&mut cur)?;
}
cur.anchors.push(anchor.to_string());
after_eof = false;
} else if let Some(rest) = line.strip_prefix('+') {
cur.new.push(rest.to_string());
after_eof = false;
} else if let Some(rest) = line.strip_prefix('-') {
cur.old.push(rest.to_string());
after_eof = false;
} else if let Some(rest) = line.strip_prefix(' ') {
cur.old.push(rest.to_string());
cur.new.push(rest.to_string());
after_eof = false;
} else {
return Err(Error::BadLine {
line: i + 1,
content: line.to_string(),
});
}
i += 1;
}
flush(&mut cur)?;
if hunks.is_empty() {
return Err(Error::EmptyUpdate {
path: path.to_string(),
});
}
let op = FileOp::Update {
path: path.to_string(),
move_to,
hunks,
};
Ok((op, i))
}
fn check_duplicates(ops: &[FileOp]) -> Result<(), Error> {
let mut seen = std::collections::BTreeSet::new();
for op in ops {
let paths: Vec<&String> = match op {
FileOp::Add { path, .. } | FileOp::Delete { path } => vec![path],
FileOp::Update { path, move_to, .. } => {
std::iter::once(path).chain(move_to.iter()).collect()
}
};
for path in paths {
if !seen.insert(path.clone()) {
return Err(Error::DuplicatePath { path: path.clone() });
}
}
}
Ok(())
}