use anyhow::Result; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; pub struct PatchParser; impl PatchParser { /// Splits an mbox series into the raw text of each message. pub fn split_patch_series(patch: &str) -> Vec<&str> { Self::envelopes(patch) .into_iter() .map(|message| message.text) .collect() } /// Parses a git diff into file diffs, returning an empty diff without one. pub fn patch_diffs(patch: &str) -> Result { if !patch.lines().any(|line| line.starts_with("diff --git ")) { return Ok(CommitDiff::new(Vec::new())); } let mut files = Vec::new(); for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { files.push(Self::file_diff(file?)); } Ok(CommitDiff::new(files)) } /// Lists the commits represented by the mbox messages that carry an id. pub fn patch_commits(patch: &str) -> Vec { Self::envelopes(patch) .into_iter() .filter(|message| !message.id.is_empty()) .map(|message| { FileCommit::new( message.id.to_string(), message .header("Subject") .map(Self::strip_patch_prefix) .unwrap_or_default(), None, message .header("From") .map(Self::name_from_address) .unwrap_or_default(), message .header("Date") .and_then(|value| gix::date::parse(value.trim(), None).ok()) .map(|time| time.seconds) .unwrap_or(0), ) }) .collect() } /// Splits `patch` on `From <40-hex> ` envelopes, falling back to one message. fn envelopes(patch: &str) -> Vec> { let mut messages: Vec> = Vec::new(); let mut current: Option<(usize, &str, Vec<&str>)> = None; let mut headers_closed = false; let mut offset = 0usize; for line in patch.lines() { let line_start = offset; offset += line.len() + 1; let is_envelope = line .strip_prefix("From ") .and_then(|rest| rest.split_whitespace().next()) .is_some_and(|id| id.len() == 40); if is_envelope { if let Some((start, id, headers)) = current.take() { messages.push(Envelope::new(&patch[start..], id, headers)); } let id = line .strip_prefix("From ") .and_then(|rest| rest.split_whitespace().next()) .unwrap_or(""); current = Some((line_start, id, Vec::new())); headers_closed = false; continue; } if let Some((_, _, headers)) = &mut current { if headers_closed { continue; } if line.is_empty() { headers_closed = true; } else { headers.push(line); } } } if let Some((start, id, headers)) = current.take() { messages.push(Envelope::new(&patch[start..], id, headers)); } if messages.is_empty() { messages.push(Envelope::new(patch, "", Vec::new())); } messages } /// Extracts the display name from a `From` header value. fn name_from_address(from: &str) -> String { match from.trim().find('<') { Some(ix) => from[..ix].trim().to_string(), None => from.trim().to_string(), } } /// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`. fn strip_patch_prefix(subject: &str) -> String { let trimmed = subject.trim(); let Some(rest) = trimmed.strip_prefix('[') else { return trimmed.to_string(); }; let Some(end) = rest.find(']') else { return trimmed.to_string(); }; if rest[..end].to_ascii_lowercase().contains("patch") { rest[end + 1..].trim().to_string() } else { trimmed.to_string() } } /// Converts one parsed file patch, keeping rename and copy paths unstripped. fn file_diff(file: FilePatch<'_, str>) -> FileDiff { let stripped; let operation = match file.operation() { operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, operation => { stripped = operation.strip_prefix(1); &stripped } }; let (path, old_path, status) = match operation { FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), FileOperation::Modify { modified, .. } => { (modified.as_ref(), None, DiffStatus::Modified) } FileOperation::Rename { from, to } => { (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) } FileOperation::Copy { from, to } => { (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied) } }; let mut insertions = 0usize; let mut deletions = 0usize; let mut hunks = Vec::new(); let patch = file.patch(); if let Some(text) = patch.as_text() { for hunk in text.hunks() { let hunk = Self::hunk_diff(hunk); insertions += hunk .lines .iter() .filter(|line| line.kind == DiffLineKind::Addition) .count(); deletions += hunk .lines .iter() .filter(|line| line.kind == DiffLineKind::Deletion) .count(); hunks.push(hunk); } } FileDiff::new( path.to_owned(), old_path.map(str::to_owned), status, insertions, deletions, patch.is_binary(), hunks, ) } /// Converts a diffy hunk into a `DiffHunk` with per-line old and new numbers. fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { let old_range = hunk.old_range(); let new_range = hunk.new_range(); let mut old = old_range.start() as u32; let mut new = new_range.start() as u32; let mut lines = Vec::with_capacity(hunk.lines().len()); for line in hunk.lines() { let (kind, text) = match line { Line::Context(text) => (DiffLineKind::Context, *text), Line::Delete(text) => (DiffLineKind::Deletion, *text), Line::Insert(text) => (DiffLineKind::Addition, *text), }; let (old_no, new_no) = match kind { DiffLineKind::Context => { let numbers = (Some(old), Some(new)); old += 1; new += 1; numbers } DiffLineKind::Addition => { let number = Some(new); new += 1; (None, number) } DiffLineKind::Deletion => { let number = Some(old); old += 1; (number, None) } }; let text = text .strip_suffix('\n') .unwrap_or(text) .strip_suffix('\r') .unwrap_or(text); lines.push(DiffLine::new(kind, old_no, new_no, text.to_owned())); } DiffHunk::new( old_range.start() as u32, old_range.len() as u32, new_range.start() as u32, new_range.len() as u32, lines, ) } } struct Envelope<'a> { text: &'a str, id: &'a str, headers: Vec<&'a str>, } impl<'a> Envelope<'a> { /// Creates an envelope from its text, commit id, and header lines. fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self { Self { text, id, headers } } /// Returns the first header line matching `name: `. fn header(&self, name: &str) -> Option<&str> { let prefix = format!("{name}: "); self.headers .iter() .find(|line| line.starts_with(&prefix)) .map(|line| &line[prefix.len()..]) } }