use thiserror::Error;
mod section;
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(
"line {line}: a second {UPDATE:?} for {path} — that file is already \
open in this envelope. One header per file; separate its hunks with \
'@@'"
)]
RepeatedUpdate { line: usize, path: String },
#[error(
"add of {path} has no content lines; an add section's whole payload \
is its '+' lines — write every line of the new file with a leading \
'+' (a blank content line is a lone '+')"
)]
EmptyAdd { 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);
}
check_repeated_update(&lines, first + 1, last)?;
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) = section::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) = section::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 check_repeated_update(lines: &[&str], from: usize, until: usize) -> Result<(), Error> {
let mut seen = std::collections::BTreeSet::new();
for (i, line) in lines.iter().enumerate().take(until).skip(from) {
if let Some(path) = line.strip_prefix(UPDATE)
&& !seen.insert(path)
{
return Err(Error::RepeatedUpdate {
line: i + 1,
path: path.to_string(),
});
}
}
Ok(())
}
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(())
}