use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, PartialEq)]
pub enum Hunk {
AddFile {
path: PathBuf,
contents: String,
},
DeleteFile {
path: PathBuf,
},
UpdateFile {
path: PathBuf,
move_path: Option<PathBuf>,
chunks: Vec<UpdateFileChunk>,
},
}
impl Hunk {
#[must_use]
pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
let path = match self {
Hunk::AddFile { path, .. } => path,
Hunk::DeleteFile { path } => path,
Hunk::UpdateFile { path, .. } => path,
};
if path.is_absolute() {
path.clone()
} else {
cwd.join(path)
}
}
}
#[derive(Debug, PartialEq)]
pub struct UpdateFileChunk {
pub change_context: Option<String>,
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
pub is_end_of_file: bool,
}
#[derive(Debug, Error, PartialEq)]
pub enum ParseError {
#[error("Invalid patch format: {0}")]
InvalidPatchError(String),
#[error("Invalid hunk at line {line_number}: {message}")]
InvalidHunkError { message: String, line_number: usize },
}
use ParseError::{InvalidHunkError, InvalidPatchError};
pub fn parse_patch(patch: &str) -> Result<Vec<Hunk>, ParseError> {
let mut lines = patch.lines().peekable();
match lines.next() {
Some("*** Begin Patch") => {}
_ => {
return Err(InvalidPatchError(
"Expected `*** Begin Patch` on the first line".to_string(),
))
}
}
let mut hunks: Vec<Hunk> = Vec::new();
while let Some(line) = lines.next() {
if line == "*** End Patch" {
if lines.next().is_some() {
return Err(InvalidPatchError(
"Unexpected content after `*** End Patch`".to_string(),
));
}
return Ok(hunks);
}
let mut parts = line.splitn(2, ": ");
let directive = parts.next().unwrap_or("");
let path_str = parts.next().unwrap_or("").trim();
if path_str.is_empty() {
return Err(InvalidHunkError {
message: "Missing path for file operation".to_string(),
line_number: 0,
});
}
let file_path = PathBuf::from(path_str);
match directive {
"*** Add File" => {
let mut contents = String::new();
while let Some(peek_line) = lines.peek() {
if peek_line.starts_with("*** ") {
break;
}
let content_line = lines.next().unwrap();
if let Some(text) = content_line.strip_prefix('+') {
contents.push_str(text);
contents.push('\n');
} else {
return Err(InvalidHunkError {
message: "Expected line to start with '+'".to_string(),
line_number: 0,
});
}
}
hunks.push(Hunk::AddFile { path: file_path, contents });
}
"*** Delete File" => {
hunks.push(Hunk::DeleteFile { path: file_path });
}
"*** Update File" => {
let mut move_path: Option<PathBuf> = None;
if let Some(peek_line) = lines.peek() {
if peek_line.starts_with("*** Move to: ") {
let move_line = lines.next().unwrap();
let mut move_parts = move_line.splitn(2, ": ");
move_parts.next(); move_path = move_parts.next().map(|s| PathBuf::from(s.trim()));
}
}
let mut chunks: Vec<UpdateFileChunk> = Vec::new();
while let Some(peek_line) = lines.peek() {
if *peek_line == "*** End of File" {
let _ = lines.next(); if let Some(last_chunk) = chunks.last_mut() {
last_chunk.is_end_of_file = true;
}
break;
}
if !peek_line.starts_with("@@") {
break;
}
lines.next(); let mut change_context: Option<String> = None;
let mut old_lines: Vec<String> = Vec::new();
let mut new_lines: Vec<String> = Vec::new();
while let Some(chunk_line) = lines.peek() {
if chunk_line.starts_with("*** ") || chunk_line.starts_with("@@") {
break;
}
let line = lines.next().unwrap();
if let Some(stripped) = line.strip_prefix(' ') {
change_context = Some(stripped.to_string());
} else if let Some(stripped) = line.strip_prefix('-') {
old_lines.push(stripped.to_string());
} else if let Some(stripped) = line.strip_prefix('+') {
new_lines.push(stripped.to_string());
} else {
return Err(InvalidHunkError {
message: "Invalid line in update chunk. Must start with ' ', '+', or '-'.".to_string(),
line_number: 0,
});
}
}
chunks.push(UpdateFileChunk {
change_context,
old_lines,
new_lines,
is_end_of_file: false,
});
}
hunks.push(Hunk::UpdateFile {
path: file_path,
move_path,
chunks,
});
}
_ => {
return Err(InvalidHunkError {
message: "Unknown file operation directive".to_string(),
line_number: 0,
})
}
}
}
Err(InvalidPatchError(
"Expected `*** End Patch` at the end of the patch".to_string(),
))
}