use std::borrow::Cow;
use std::path::PathBuf;
use super::jit::{Touched, diffusion_features, size_features};
use crate::vcs::error::Error;
use crate::vcs::jit::{
JIT_SCHEMA_VERSION, JIT_SCORE_VERSION, JitDiffReport, JitSource, score_diff_features,
};
pub(crate) fn score_diff(diff: &str) -> Result<JitDiffReport, Error> {
let touched = parse_unified_diff(diff)?;
let size = size_features(&touched);
let diffusion = diffusion_features(&touched);
let (partial_risk_score, contributions) = score_diff_features(size, diffusion);
Ok(JitDiffReport {
jit_schema_version: JIT_SCHEMA_VERSION,
jit_score_version: JIT_SCORE_VERSION,
source: JitSource::Diff,
partial_risk_score,
size,
diffusion,
contributions,
})
}
fn parse_unified_diff(diff: &str) -> Result<Vec<Touched>, Error> {
let mut files: Vec<Touched> = Vec::new();
let mut current: Option<DiffFile> = None;
let mut saw_orphan_marker = false;
for raw in diff.lines() {
let line = raw.strip_suffix('\r').unwrap_or(raw);
if let Some(rest) = line.strip_prefix("diff --git ") {
flush_diff_file(&mut files, current.take());
current = Some(DiffFile::new(diff_git_new_path(rest)));
continue;
}
let Some(file) = current.as_mut() else {
if line.starts_with("@@")
|| line.starts_with("diff --cc ")
|| line.starts_with("diff --combined ")
{
saw_orphan_marker = true;
}
continue;
};
file.classify_body_line(line)?;
}
flush_diff_file(&mut files, current.take());
if files.is_empty() && saw_orphan_marker {
return Err(Error::InvalidDiff(
"no `diff --git` file headers found; expected a git-style unified \
diff (plain `diff -u` and combined/merge diffs are not supported)"
.to_owned(),
));
}
Ok(files)
}
struct DiffFile {
new_path: Option<PathBuf>,
saw_hunk: bool,
added: u64,
deleted: u64,
hunks: u32,
}
impl DiffFile {
fn new(new_path: Option<PathBuf>) -> Self {
Self {
new_path,
saw_hunk: false,
added: 0,
deleted: 0,
hunks: 0,
}
}
fn set_new_path(&mut self, raw: &str) {
if let Some(path) = unified_path(raw) {
self.new_path = Some(path);
}
}
fn set_rename_to_path(&mut self, raw: &str) {
if let Some(path) = rename_to_path(raw) {
self.new_path = Some(path);
}
}
fn require_open_hunk(&self) -> Result<(), Error> {
if self.saw_hunk {
Ok(())
} else {
Err(Error::InvalidDiff(
"a +/- line appears before any @@ hunk header".to_owned(),
))
}
}
fn classify_body_line(&mut self, line: &str) -> Result<(), Error> {
if line.starts_with("@@") {
parse_hunk_header(line)?;
self.saw_hunk = true;
self.hunks = self.hunks.saturating_add(1);
} else if !self.saw_hunk && line.starts_with("+++ ") {
if let Some(path) = line.strip_prefix("+++ ") {
self.set_new_path(path);
}
} else if !self.saw_hunk && line.starts_with("rename to ") {
if let Some(path) = line.strip_prefix("rename to ") {
self.set_rename_to_path(path);
}
} else if !self.saw_hunk && line.starts_with("--- ") {
} else if line.starts_with('+') {
self.require_open_hunk()?;
self.added = self.added.saturating_add(1);
} else if line.starts_with('-') {
self.require_open_hunk()?;
self.deleted = self.deleted.saturating_add(1);
}
Ok(())
}
}
fn flush_diff_file(files: &mut Vec<Touched>, file: Option<DiffFile>) {
let Some(file) = file else { return };
let path = file.new_path.unwrap_or_else(|| PathBuf::from(""));
files.push(Touched {
path,
parent_path: None,
added: file.added,
deleted: file.deleted,
hunks: file.hunks,
});
}
fn parse_hunk_header(line: &str) -> Result<(), Error> {
if line.starts_with("@@@") {
return Err(Error::InvalidDiff(
"combined/merge diffs (@@@ headers) are not supported".to_owned(),
));
}
if line.contains(" -") && line.contains(" +") {
Ok(())
} else {
Err(Error::InvalidDiff(format!(
"malformed hunk header: {line:?}"
)))
}
}
fn diff_git_new_path(rest: &str) -> Option<PathBuf> {
let rest = rest.trim();
if let Some(tok) = last_quoted_token(rest)
&& let Some(p) = unquote_git_path(tok)
.strip_prefix("b/")
.filter(|p| !p.is_empty())
{
return Some(PathBuf::from(p));
}
if let Some(p) = symmetric_modify_new_path(rest) {
return Some(p);
}
rest.rsplit(' ')
.find_map(|tok| tok.strip_prefix("b/"))
.filter(|p| !p.is_empty())
.map(PathBuf::from)
}
fn last_quoted_token(rest: &str) -> Option<&str> {
let bytes = rest.as_bytes();
let mut i = 0;
let mut last = None;
while i < bytes.len() {
match bytes[i] {
b' ' => i += 1,
b'"' => {
let start = i;
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i = if bytes[i] == b'\\' {
(i + 2).min(bytes.len())
} else {
i + 1
};
}
i = (i + 1).min(bytes.len());
last = Some(&rest[start..i]);
}
_ => {
while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'"' {
i += 1;
}
}
}
}
last
}
fn symmetric_modify_new_path(rest: &str) -> Option<PathBuf> {
let body = rest.strip_prefix("a/")?;
let x_len = body.len().checked_sub(3)? / 2;
let (first, tail) = body.split_at_checked(x_len)?;
let second = tail.strip_prefix(" b/")?;
(first == second && !first.is_empty()).then(|| PathBuf::from(first))
}
fn decode_named_escape(b: u8) -> Option<u8> {
Some(match b {
b'a' => 0x07,
b'b' => 0x08,
b't' => b'\t',
b'n' => b'\n',
b'v' => 0x0b,
b'f' => 0x0c,
b'r' => b'\r',
b'"' => b'"',
b'\\' => b'\\',
_ => return None,
})
}
fn unquote_git_path(token: &str) -> Cow<'_, str> {
let Some(inner) = token.strip_prefix('"').and_then(|t| t.strip_suffix('"')) else {
return Cow::Borrowed(token);
};
let raw = inner.as_bytes();
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
let next = (raw[i] == b'\\').then(|| raw.get(i + 1).copied()).flatten();
let Some(next) = next else {
out.push(raw[i]);
i += 1;
continue;
};
if let Some(byte) = decode_named_escape(next) {
out.push(byte);
i += 2;
} else if next.is_ascii_digit() && next < b'8' {
let mut val: u16 = 0;
let mut j = i + 1;
while j < raw.len() && j < i + 4 && raw[j].is_ascii_digit() && raw[j] < b'8' {
val = val * 8 + u16::from(raw[j] - b'0');
j += 1;
}
out.push((val & 0xFF) as u8);
i = j;
} else {
out.push(b'\\');
i += 1;
}
}
match String::from_utf8(out) {
Ok(s) => Cow::Owned(s),
Err(e) => Cow::Owned(String::from_utf8_lossy(e.as_bytes()).into_owned()),
}
}
fn rename_to_path(raw: &str) -> Option<PathBuf> {
let decoded = unquote_git_path(raw.trim());
let path = decoded.as_ref();
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
}
fn unified_path(raw: &str) -> Option<PathBuf> {
let trimmed = raw.split('\t').next().unwrap_or(raw).trim();
let path = unquote_git_path(trimmed);
let path = path.as_ref();
if path == "/dev/null" || path.is_empty() {
return None;
}
let stripped = path
.strip_prefix("a/")
.or_else(|| path.strip_prefix("b/"))
.unwrap_or(path);
if stripped.is_empty() {
None
} else {
Some(PathBuf::from(stripped))
}
}
#[cfg(test)]
#[path = "diff_parse_tests.rs"]
mod tests;