use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ParsedPatch {
pub files: Vec<PatchFileChange>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PatchFileChange {
pub old_path: String,
pub new_path: String,
pub kind: FileChangeKind,
pub hunks: Vec<PatchHunk>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PatchHunk {
pub old_start: u64,
pub old_count: u64,
pub new_start: u64,
pub new_count: u64,
#[serde(default)]
pub model_id: Option<String>,
pub lines: Vec<TouchedLine>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TouchedLine {
pub kind: TouchedLineKind,
pub line_number: u64,
pub content: String,
#[serde(default)]
pub session_id: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TouchedLineKind {
Added,
Removed,
}
#[allow(dead_code)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseError {
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "patch parse error: {}", self.message)
}
}
impl std::error::Error for ParseError {}
#[allow(dead_code)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PatchLoadError {
pub message: String,
}
impl std::fmt::Display for PatchLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "patch load error: {}", self.message)
}
}
impl std::error::Error for PatchLoadError {}
pub fn load_patch_from_json(input: &str) -> Result<ParsedPatch, PatchLoadError> {
serde_json::from_str(input).map_err(|e| PatchLoadError {
message: format!("invalid patch JSON: {e}"),
})
}
#[allow(dead_code)]
pub fn load_patch_from_json_bytes(input: &[u8]) -> Result<ParsedPatch, PatchLoadError> {
serde_json::from_slice(input).map_err(|e| PatchLoadError {
message: format!("invalid patch JSON: {e}"),
})
}
#[allow(dead_code)]
pub fn intersect_patches(
constructed_patch: &ParsedPatch,
post_commit_patch: &ParsedPatch,
) -> ParsedPatch {
let mut result_files: Vec<PatchFileChange> = Vec::new();
for post_commit_file in &post_commit_patch.files {
let Some(constructed_file) = constructed_patch.files.iter().find(|constructed_file| {
paths_refer_to_same_file(&constructed_file.new_path, &post_commit_file.new_path)
}) else {
continue;
};
let available_lines: Vec<AvailableTouchedLine<'_>> = constructed_file
.hunks
.iter()
.flat_map(|hunk| {
hunk.lines.iter().map(move |line| AvailableTouchedLine {
line,
model_id: hunk.model_id.as_deref(),
})
})
.collect();
let mut used_lines = vec![false; available_lines.len()];
let mut result_hunks: Vec<PatchHunk> = Vec::new();
for post_commit_hunk in &post_commit_file.hunks {
let mut matched_model_id: Option<String> = None;
let overlapping_lines: Vec<TouchedLine> = post_commit_hunk
.lines
.iter()
.filter_map(|line| {
if let Some(index) = find_available_line_match(
&available_lines,
&used_lines,
line,
touched_lines_match_exact,
) {
if matched_model_id.is_none() {
matched_model_id = available_lines[index].model_id.map(str::to_string);
}
used_lines[index] = true;
let mut overlapping_line = line.clone();
overlapping_line
.session_id
.clone_from(&available_lines[index].line.session_id);
return Some(overlapping_line);
}
if let Some(index) = find_available_line_match(
&available_lines,
&used_lines,
line,
touched_lines_match_historical,
) {
if matched_model_id.is_none() {
matched_model_id = available_lines[index].model_id.map(str::to_string);
}
used_lines[index] = true;
let mut overlapping_line = line.clone();
overlapping_line
.session_id
.clone_from(&available_lines[index].line.session_id);
return Some(overlapping_line);
}
None
})
.collect();
if overlapping_lines.is_empty() {
continue;
}
result_hunks.push(PatchHunk {
old_start: post_commit_hunk.old_start,
old_count: post_commit_hunk.old_count,
new_start: post_commit_hunk.new_start,
new_count: post_commit_hunk.new_count,
model_id: matched_model_id,
lines: overlapping_lines,
});
}
if result_hunks.is_empty()
&& !(post_commit_file.hunks.is_empty() && constructed_file.hunks.is_empty())
{
continue;
}
result_files.push(PatchFileChange {
old_path: post_commit_file.old_path.clone(),
new_path: post_commit_file.new_path.clone(),
kind: post_commit_file.kind,
hunks: result_hunks,
});
}
ParsedPatch {
files: result_files,
}
}
struct AvailableTouchedLine<'a> {
line: &'a TouchedLine,
model_id: Option<&'a str>,
}
fn find_available_line_match(
available_lines: &[AvailableTouchedLine<'_>],
used_lines: &[bool],
target: &TouchedLine,
matcher: fn(&TouchedLine, &TouchedLine) -> bool,
) -> Option<usize> {
available_lines
.iter()
.enumerate()
.find_map(|(index, candidate)| {
(!used_lines[index] && matcher(candidate.line, target)).then_some(index)
})
}
fn touched_lines_match_exact(candidate: &TouchedLine, target: &TouchedLine) -> bool {
candidate.kind == target.kind
&& candidate.line_number == target.line_number
&& candidate.content == target.content
}
fn touched_lines_match_historical(candidate: &TouchedLine, target: &TouchedLine) -> bool {
candidate.kind == target.kind && candidate.content == target.content
}
fn paths_refer_to_same_file(path_a: &str, path_b: &str) -> bool {
if path_a == path_b {
return true;
}
let a_components = normalized_path_components(path_a);
let b_components = normalized_path_components(path_b);
if a_components.is_empty() || b_components.is_empty() {
return false;
}
path_has_relative_suffix(&a_components, &b_components)
|| path_has_relative_suffix(&b_components, &a_components)
}
fn normalized_path_components(path: &str) -> Vec<&str> {
Path::new(path)
.components()
.filter_map(|component| match component {
Component::Normal(part) => part.to_str(),
_ => None,
})
.collect()
}
fn path_has_relative_suffix<'a>(full_path: &[&'a str], suffix_candidate: &[&'a str]) -> bool {
full_path.len() > suffix_candidate.len()
&& full_path.ends_with(suffix_candidate)
&& !suffix_candidate.is_empty()
}
#[allow(dead_code)]
pub fn combine_patches(patches: &[ParsedPatch]) -> ParsedPatch {
use std::collections::HashMap;
type LineKey = (TouchedLineKind, u64, String);
type HunkMeta = (u64, u64, u64, u64);
type HunkGroupKey = (HunkMeta, Option<String>);
#[allow(clippy::type_complexity)]
struct FileAcc {
old_path: String,
kind: FileChangeKind,
lines: HashMap<LineKey, (TouchedLine, HunkGroupKey)>,
}
let mut file_order: Vec<String> = Vec::new();
let mut files: HashMap<String, FileAcc> = HashMap::new();
for patch in patches {
for file in &patch.files {
let acc = files.entry(file.new_path.clone()).or_insert_with(|| {
file_order.push(file.new_path.clone());
FileAcc {
old_path: file.old_path.clone(),
kind: file.kind,
lines: HashMap::new(),
}
});
acc.old_path.clone_from(&file.old_path);
acc.kind = file.kind;
for hunk in &file.hunks {
let hunk_meta: HunkMeta = (
hunk.old_start,
hunk.old_count,
hunk.new_start,
hunk.new_count,
);
let hunk_group_key: HunkGroupKey = (hunk_meta, hunk.model_id.clone());
for line in &hunk.lines {
let line_key = (line.kind, line.line_number, line.content.clone());
acc.lines
.insert(line_key, (line.clone(), hunk_group_key.clone()));
}
}
}
}
let mut result_files = Vec::new();
for path in file_order {
let acc = files.remove(&path).unwrap();
let mut hunk_groups: HashMap<HunkGroupKey, Vec<TouchedLine>> = HashMap::new();
for (_line_key, (line, hunk_group_key)) in acc.lines {
hunk_groups.entry(hunk_group_key).or_default().push(line);
}
let mut sorted_hunks: Vec<_> = hunk_groups.into_iter().collect();
sorted_hunks.sort_by_key(|((meta, model_id), _)| {
(meta.0, meta.1, meta.2, meta.3, model_id.clone())
});
let mut hunks = Vec::new();
for ((meta, model_id), mut lines) in sorted_hunks {
lines.sort_by(|a, b| {
a.line_number
.cmp(&b.line_number)
.then_with(|| {
let a_order = match a.kind {
TouchedLineKind::Removed => 0,
TouchedLineKind::Added => 1,
};
let b_order = match b.kind {
TouchedLineKind::Removed => 0,
TouchedLineKind::Added => 1,
};
a_order.cmp(&b_order)
})
.then_with(|| a.content.cmp(&b.content))
});
hunks.push(PatchHunk {
old_start: meta.0,
old_count: meta.1,
new_start: meta.2,
new_count: meta.3,
model_id,
lines,
});
}
result_files.push(PatchFileChange {
old_path: acc.old_path,
new_path: path,
kind: acc.kind,
hunks,
});
}
ParsedPatch {
files: result_files,
}
}
#[allow(dead_code)]
pub fn parse_patch(input: &str, session_id: Option<&str>) -> Result<ParsedPatch, ParseError> {
let mut files: Vec<PatchFileChange> = Vec::new();
let mut current_file: Option<FileBuilder> = None;
let mut lines = input.lines().peekable();
while let Some(line) = lines.next() {
if let Some(rest) = line.strip_prefix("diff --git ") {
if let Some(fb) = current_file.take() {
files.push(fb.build());
}
let paths = parse_git_diff_header(rest);
current_file = Some(FileBuilder::new(paths.old_path, paths.new_path));
continue;
}
if let Some(rest) = line.strip_prefix("Index: ") {
if let Some(fb) = current_file.take() {
files.push(fb.build());
}
let index_path = rest.trim().to_string();
current_file = Some(FileBuilder::new(index_path.clone(), index_path));
continue;
}
if line.starts_with("===") {
continue;
}
if let Some(rest) = line.strip_prefix("--- ") {
let fb = current_file.as_mut().ok_or_else(|| ParseError {
message: format!(
"encountered '---' line without a preceding file header: {line:?}"
),
})?;
fb.set_old_path(parse_diff_path(rest));
continue;
}
if let Some(rest) = line.strip_prefix("+++ ") {
let fb = current_file.as_mut().ok_or_else(|| ParseError {
message: format!(
"encountered '+++' line without a preceding file header: {line:?}"
),
})?;
fb.set_new_path(parse_diff_path(rest));
continue;
}
if line.starts_with("new file mode ")
|| line.starts_with("deleted file mode ")
|| line.starts_with("old mode ")
|| line.starts_with("new mode ")
|| line.starts_with("index ")
|| line.starts_with("similarity index ")
|| line.starts_with("rename from ")
|| line.starts_with("rename to ")
|| line.starts_with("copy from ")
|| line.starts_with("copy to ")
{
if let Some(fb) = current_file.as_mut() {
if line.starts_with("new file mode ") {
fb.mark_added();
} else if line.starts_with("deleted file mode ") {
fb.mark_deleted();
} else if line.starts_with("rename from ") || line.starts_with("rename to ") {
fb.mark_renamed();
}
}
continue;
}
if let Some(rest) = line.strip_prefix("@@ ") {
if let Some(fb) = current_file.as_mut() {
let hunk = parse_hunk_header_and_body(rest, &mut lines, session_id)?;
fb.add_hunk(hunk);
}
}
}
if let Some(fb) = current_file.take() {
files.push(fb.build());
}
Ok(ParsedPatch { files })
}
struct FileBuilder {
old_path: String,
new_path: String,
kind: Option<FileChangeKind>,
hunks: Vec<PatchHunk>,
}
impl FileBuilder {
fn new(old_path: String, new_path: String) -> Self {
Self {
old_path,
new_path,
kind: None,
hunks: Vec::new(),
}
}
fn set_old_path(&mut self, path: String) {
self.old_path = path;
}
fn set_new_path(&mut self, path: String) {
self.new_path = path;
}
fn mark_added(&mut self) {
self.kind = Some(FileChangeKind::Added);
}
fn mark_deleted(&mut self) {
self.kind = Some(FileChangeKind::Deleted);
}
fn mark_renamed(&mut self) {
self.kind = Some(FileChangeKind::Renamed);
}
fn add_hunk(&mut self, hunk: PatchHunk) {
self.hunks.push(hunk);
}
fn build(self) -> PatchFileChange {
let inferred_kind = self.kind.unwrap_or_else(|| {
infer_file_kind_from_hunks(&self.hunks)
.unwrap_or_else(|| determine_file_kind(&self.old_path, &self.new_path))
});
let (old_path, new_path) =
normalize_paths_for_kind(self.old_path, self.new_path, inferred_kind);
PatchFileChange {
old_path,
new_path,
kind: inferred_kind,
hunks: self.hunks,
}
}
}
fn infer_file_kind_from_hunks(hunks: &[PatchHunk]) -> Option<FileChangeKind> {
if hunks.is_empty() {
return None;
}
if hunks
.iter()
.all(|hunk| hunk.old_start == 0 && hunk.old_count == 0)
{
return Some(FileChangeKind::Added);
}
if hunks
.iter()
.all(|hunk| hunk.new_start == 0 && hunk.new_count == 0)
{
return Some(FileChangeKind::Deleted);
}
None
}
fn normalize_paths_for_kind(
mut old_path: String,
mut new_path: String,
kind: FileChangeKind,
) -> (String, String) {
match kind {
FileChangeKind::Added => old_path.clear(),
FileChangeKind::Deleted => new_path.clear(),
_ => {}
}
(old_path, new_path)
}
fn determine_file_kind(old_path: &str, new_path: &str) -> FileChangeKind {
if old_path == "/dev/null" || old_path.is_empty() {
FileChangeKind::Added
} else if new_path == "/dev/null" || new_path.is_empty() {
FileChangeKind::Deleted
} else if old_path != new_path {
FileChangeKind::Renamed
} else {
FileChangeKind::Modified
}
}
fn parse_git_diff_header(rest: &str) -> DiffPaths {
if let Some(idx) = rest.find(" b/") {
let old = rest[..idx]
.strip_prefix("a/")
.unwrap_or(&rest[..idx])
.to_string();
let new = rest[idx + 3..]
.strip_prefix("b/")
.unwrap_or(&rest[idx + 3..])
.to_string();
DiffPaths {
old_path: old,
new_path: new,
}
} else {
let path = rest.trim().to_string();
DiffPaths {
old_path: path.clone(),
new_path: path,
}
}
}
struct DiffPaths {
old_path: String,
new_path: String,
}
fn parse_diff_path(rest: &str) -> String {
let trimmed = rest.trim_end();
if trimmed == "/dev/null" {
return String::new();
}
if let Some(stripped) = trimmed.strip_prefix("a/") {
return stripped.to_string();
}
if let Some(stripped) = trimmed.strip_prefix("b/") {
return stripped.to_string();
}
trimmed.to_string()
}
fn parse_hunk_header_and_body<'a, I>(
rest: &str,
lines: &mut std::iter::Peekable<I>,
session_id: Option<&str>,
) -> Result<PatchHunk, ParseError>
where
I: Iterator<Item = &'a str>,
{
let header_end = rest.find("@@").ok_or_else(|| ParseError {
message: format!("invalid hunk header: missing closing '@@' in {rest:?}"),
})?;
let range_part = rest[..header_end].trim();
let mut ranges = range_part.split_whitespace();
let old_range = ranges.next().ok_or_else(|| ParseError {
message: format!("invalid hunk header: missing old range in {range_part:?}"),
})?;
let new_range = ranges.next().ok_or_else(|| ParseError {
message: format!("invalid hunk header: missing new range in {range_part:?}"),
})?;
let (old_start, old_count) = parse_range_part(old_range, '-')?;
let (new_start, new_count) = parse_range_part(new_range, '+')?;
let mut touched_lines: Vec<TouchedLine> = Vec::new();
let mut old_line_num = old_start;
let mut new_line_num = new_start;
while let Some(&line) = lines.peek() {
if line.starts_with("diff --git ") || line.starts_with("Index: ") || line.starts_with("===")
{
break;
}
if line.starts_with("@@ ") {
break;
}
if line.starts_with("--- ") || line.starts_with("+++ ") {
break;
}
if line.starts_with("new file mode ")
|| line.starts_with("deleted file mode ")
|| line.starts_with("old mode ")
|| line.starts_with("new mode ")
|| line.starts_with("index ")
|| line.starts_with("similarity index ")
|| line.starts_with("rename from ")
|| line.starts_with("rename to ")
|| line.starts_with("copy from ")
|| line.starts_with("copy to ")
{
break;
}
let line = lines.next().unwrap();
if let Some(content) = line.strip_prefix('+') {
touched_lines.push(TouchedLine {
kind: TouchedLineKind::Added,
line_number: new_line_num,
content: content.to_string(),
session_id: session_id.map(str::to_string),
});
new_line_num += 1;
} else if let Some(content) = line.strip_prefix('-') {
touched_lines.push(TouchedLine {
kind: TouchedLineKind::Removed,
line_number: old_line_num,
content: content.to_string(),
session_id: session_id.map(str::to_string),
});
old_line_num += 1;
} else if line.starts_with(' ') || line.starts_with('\t') {
old_line_num += 1;
new_line_num += 1;
} else if line.is_empty() {
old_line_num += 1;
new_line_num += 1;
} else if line.starts_with('\\') {
} else {
}
}
Ok(PatchHunk {
old_start,
old_count,
new_start,
new_count,
model_id: None,
lines: touched_lines,
})
}
fn parse_range_part(s: &str, prefix: char) -> Result<(u64, u64), ParseError> {
let s = s.strip_prefix(prefix).unwrap_or(s).trim();
let parts: Vec<&str> = s.splitn(2, ',').collect();
let start: u64 = parts[0].parse().map_err(|_| ParseError {
message: format!("invalid hunk range start in {s:?}"),
})?;
let count: u64 = if parts.len() > 1 {
parts[1].parse().map_err(|_| ParseError {
message: format!("invalid hunk range count in {s:?}"),
})?
} else {
1
};
Ok((start, count))
}
#[cfg(test)]
#[path = "patch/tests.rs"]
mod tests;