use async_trait::async_trait;
use boundgraph_core::{SchemaId, SlicerId};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::slicer::{Raw, Slice, SliceError, Slicer};
pub const DIFF_SCHEMA: &str = "boundgraph.diff.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Added,
Deleted,
Modified,
Renamed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hunk {
pub header: String,
pub lines: Vec<String>,
pub added: usize,
pub removed: usize,
}
impl Hunk {
pub fn with_context(&self, context: usize) -> Self {
let changed: Vec<usize> = self
.lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with('+') || l.starts_with('-'))
.map(|(i, _)| i)
.collect();
if changed.is_empty() {
return self.clone();
}
let mut keep = vec![false; self.lines.len()];
for &index in &changed {
let low = index.saturating_sub(context);
let high = (index + context).min(self.lines.len() - 1);
for slot in keep.iter_mut().take(high + 1).skip(low) {
*slot = true;
}
}
let mut lines = Vec::new();
let mut skipped = 0usize;
for (index, line) in self.lines.iter().enumerate() {
if keep[index] {
if skipped > 0 {
lines.push(format!(" … {skipped} unchanged lines"));
skipped = 0;
}
lines.push(line.clone());
} else {
skipped += 1;
}
}
if skipped > 0 {
lines.push(format!(" … {skipped} unchanged lines"));
}
Self {
header: self.header.clone(),
lines,
added: self.added,
removed: self.removed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileDiff {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
pub status: FileStatus,
pub added: usize,
pub removed: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub binary: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hunks: Vec<Hunk>,
}
impl FileDiff {
pub fn summary(&self) -> String {
let kind = match self.status {
FileStatus::Added => "added",
FileStatus::Deleted => "deleted",
FileStatus::Modified => "modified",
FileStatus::Renamed => "renamed",
};
let binary = if self.binary { " binary" } else { "" };
match &self.from {
Some(from) => format!(
"{from} -> {} ({kind}{binary}, +{}/-{})",
self.path, self.added, self.removed
),
None => format!(
"{} ({kind}{binary}, +{}/-{})",
self.path, self.added, self.removed
),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DiffSlicer {
context: usize,
min_context: usize,
}
impl Default for DiffSlicer {
fn default() -> Self {
Self::new()
}
}
impl DiffSlicer {
pub fn new() -> Self {
Self {
context: 3,
min_context: 0,
}
}
#[must_use]
pub fn context(mut self, context: usize, min_context: usize) -> Self {
self.context = context;
self.min_context = min_context.min(context);
self
}
pub fn parse(text: &str) -> Vec<FileDiff> {
let mut files: Vec<FileDiff> = Vec::new();
let mut hunk: Option<Hunk> = None;
fn flush(files: &mut [FileDiff], hunk: &mut Option<Hunk>) {
if let (Some(file), Some(done)) = (files.last_mut(), hunk.take()) {
file.added += done.added;
file.removed += done.removed;
file.hunks.push(done);
}
}
for line in text.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
flush(&mut files, &mut hunk);
let path = rest
.split_whitespace()
.next_back()
.unwrap_or("")
.strip_prefix("b/")
.unwrap_or(rest)
.to_string();
files.push(FileDiff {
path,
from: None,
status: FileStatus::Modified,
added: 0,
removed: 0,
binary: false,
hunks: Vec::new(),
});
continue;
}
if let Some(file) = files.last_mut() {
if line.starts_with("new file mode") {
file.status = FileStatus::Added;
continue;
}
if line.starts_with("deleted file mode") {
file.status = FileStatus::Deleted;
continue;
}
if let Some(from) = line.strip_prefix("rename from ") {
file.status = FileStatus::Renamed;
file.from = Some(from.to_string());
continue;
}
if line.starts_with("rename to ") {
continue;
}
if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
flush(&mut files, &mut hunk);
if let Some(file) = files.last_mut() {
file.binary = true;
}
continue;
}
}
if line.starts_with("@@") {
flush(&mut files, &mut hunk);
if files.is_empty() {
files.push(FileDiff {
path: "(unnamed)".to_string(),
from: None,
status: FileStatus::Modified,
added: 0,
removed: 0,
binary: false,
hunks: Vec::new(),
});
}
hunk = Some(Hunk {
header: line.to_string(),
lines: Vec::new(),
added: 0,
removed: 0,
});
continue;
}
if hunk.is_none() {
if let Some(path) = line.strip_prefix("+++ b/").or(line.strip_prefix("+++ ")) {
if let Some(file) = files.last_mut()
&& file.path.is_empty()
{
file.path = path.to_string();
}
}
continue;
}
if let Some(open) = hunk.as_mut() {
match line.chars().next() {
Some('+') => open.added += 1,
Some('-') => open.removed += 1,
Some(' ') | Some('\\') | None => {}
_ => {
flush(&mut files, &mut hunk);
continue;
}
}
if let Some(open) = hunk.as_mut() {
open.lines.push(line.to_string());
}
}
}
flush(&mut files, &mut hunk);
for file in &mut files {
if file.binary {
file.hunks.clear();
}
}
files
}
fn payload(files: &[FileDiff], context: usize, kept: usize) -> serde_json::Value {
let mut shown = 0usize;
let mut dropped_hunks = 0usize;
let mut dropped_paths: Vec<&str> = Vec::new();
let rendered: Vec<FileDiff> = files
.iter()
.map(|file| {
let mut out = file.clone();
out.hunks = Vec::new();
for hunk in &file.hunks {
if shown < kept {
out.hunks.push(hunk.with_context(context));
shown += 1;
} else {
dropped_hunks += 1;
}
}
if !file.hunks.is_empty() && out.hunks.is_empty() {
dropped_paths.push(&file.path);
}
out
})
.collect();
let mut payload = json!({
"files": rendered,
"stats": {
"files": files.len(),
"added": files.iter().map(|f| f.added).sum::<usize>(),
"removed": files.iter().map(|f| f.removed).sum::<usize>(),
"hunks": files.iter().map(|f| f.hunks.len()).sum::<usize>(),
},
});
if dropped_hunks > 0 {
payload["dropped"] = json!({
"hunks": dropped_hunks,
"files": dropped_paths.len(),
"paths": dropped_paths,
});
}
if context < 3 {
payload["context"] = json!(context);
}
payload
}
}
#[async_trait]
impl Slicer for DiffSlicer {
fn id(&self) -> SlicerId {
SlicerId::new("diff", 1)
}
async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
let text = raw.text();
let files = Self::parse(&text);
let schema = SchemaId::new(DIFF_SCHEMA, 1);
let total_hunks: usize = files.iter().map(|f| f.hunks.len()).sum();
let counter = self.counter();
let fits = |value: &serde_json::Value| {
counter.count(&serde_json::to_string(value).unwrap_or_default()) <= cap
};
let floor = Self::payload(&files, self.min_context, 0);
if !fits(&floor) {
return Err(SliceError::CapTooSmall {
cap,
minimum: counter.count(&serde_json::to_string(&floor).unwrap_or_default()),
});
}
for context in (self.min_context..=self.context).rev() {
let candidate = Self::payload(&files, context, total_hunks);
if fits(&candidate) {
return Ok(Slice {
schema,
lossy: context < self.context || files.iter().any(|f| f.binary),
payload: candidate,
});
}
}
let mut kept = total_hunks;
while kept > 0 {
kept -= 1;
let candidate = Self::payload(&files, self.min_context, kept);
if fits(&candidate) {
return Ok(Slice {
schema,
payload: candidate,
lossy: true,
});
}
}
Ok(Slice {
schema,
payload: floor,
lossy: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slicer::MemoryStore;
const SMALL: &str = "\
diff --git a/src/main.rs b/src/main.rs
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,5 @@
fn main() {
let a = 1;
- println!(\"old\");
+ println!(\"new\");
let b = 2;
}
";
fn big_patch() -> String {
let mut out = String::new();
for file in 0..8 {
out.push_str(&format!(
"diff --git a/src/mod_{file}.rs b/src/mod_{file}.rs\n--- a/src/mod_{file}.rs\n+++ b/src/mod_{file}.rs\n"
));
for hunk in 0..4 {
out.push_str(&format!(
"@@ -{0},8 +{0},9 @@ fn thing_{hunk}()\n",
hunk * 20 + 1
));
for c in 0..3 {
out.push_str(&format!(" untouched line {c}\n"));
}
out.push_str(&format!("- let old_{hunk} = 1;\n"));
out.push_str(&format!("+ let new_{hunk} = 2;\n"));
out.push_str(&format!("+ let extra_{hunk} = 3;\n"));
for c in 0..3 {
out.push_str(&format!(" trailing line {c}\n"));
}
}
}
out.push_str("diff --git a/assets/logo.png b/assets/logo.png\nBinary files a/assets/logo.png and b/assets/logo.png differ\n");
out.push_str("diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n");
out
}
fn raw_of(text: &str) -> Raw {
Raw::new(text.as_bytes().to_vec())
}
async fn slice(text: &str, cap: usize) -> Slice {
DiffSlicer::new()
.slice(&raw_of(text), cap)
.await
.expect("a satisfiable cap")
}
fn hunk_lines(slice: &Slice) -> Vec<String> {
slice.payload["files"]
.as_array()
.unwrap()
.iter()
.flat_map(|f| f["hunks"].as_array().cloned().unwrap_or_default())
.flat_map(|h| h["lines"].as_array().cloned().unwrap_or_default())
.map(|l| l.as_str().unwrap().to_string())
.collect()
}
#[test]
fn a_simple_patch_parses_to_one_file_and_one_hunk() {
let files = DiffSlicer::parse(SMALL);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/main.rs");
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].added, 1);
assert_eq!(files[0].removed, 1);
}
#[test]
fn the_file_header_is_not_counted_as_a_change() {
let files = DiffSlicer::parse(SMALL);
assert_eq!(files[0].added, 1, "exactly the one +line");
assert_eq!(files[0].removed, 1, "exactly the one -line");
}
#[test]
fn file_status_is_read_from_the_preamble() {
let patch = "\
diff --git a/new.rs b/new.rs
new file mode 100644
--- /dev/null
+++ b/new.rs
@@ -0,0 +1 @@
+fn main() {}
diff --git a/gone.rs b/gone.rs
deleted file mode 100644
@@ -1 +0,0 @@
-fn main() {}
diff --git a/old.rs b/moved.rs
rename from old.rs
rename to moved.rs
";
let files = DiffSlicer::parse(patch);
assert_eq!(files[0].status, FileStatus::Added);
assert_eq!(files[1].status, FileStatus::Deleted);
assert_eq!(files[2].status, FileStatus::Renamed);
assert_eq!(files[2].from.as_deref(), Some("old.rs"));
}
#[tokio::test]
async fn a_hunk_is_never_cut_in_half() {
let patch = big_patch();
for cap in [320, 400, 800, 1_600, 3_200, 8_000] {
let slice = slice(&patch, cap).await;
for file in slice.payload["files"].as_array().unwrap() {
for hunk in file["hunks"].as_array().cloned().unwrap_or_default() {
let header = hunk["header"].as_str().unwrap();
assert!(header.starts_with("@@"), "cap {cap}: {header}");
assert!(
!hunk["lines"].as_array().unwrap().is_empty(),
"cap {cap}: a hunk with a header and no lines is a cut hunk"
);
}
}
}
}
#[tokio::test]
async fn a_patch_that_fits_is_kept_whole_and_is_not_lossy() {
let slice = slice(SMALL, 500).await;
assert!(!slice.lossy);
assert!(slice.payload.get("dropped").is_none());
let lines = hunk_lines(&slice);
assert!(lines.iter().any(|l| l.contains("old")));
assert!(lines.iter().any(|l| l.contains("new")));
}
#[tokio::test]
async fn every_file_keeps_its_entry_and_stats_however_tight_the_cap() {
let patch = big_patch();
let all = DiffSlicer::parse(&patch);
for cap in [320, 400, 600, 1_000] {
let slice = slice(&patch, cap).await;
let files = slice.payload["files"].as_array().unwrap();
assert_eq!(files.len(), all.len(), "at cap {cap}");
for (shown, parsed) in files.iter().zip(&all) {
assert_eq!(shown["path"], parsed.path.as_str(), "at cap {cap}");
assert_eq!(shown["added"], parsed.added, "at cap {cap}");
assert_eq!(shown["removed"], parsed.removed, "at cap {cap}");
}
}
}
#[tokio::test]
async fn the_overall_statistics_are_always_present() {
let slice = slice(&big_patch(), 320).await;
let stats = &slice.payload["stats"];
assert_eq!(stats["files"], 10);
assert_eq!(stats["hunks"], 32);
assert!(stats["added"].as_u64().unwrap() > 0);
}
#[tokio::test]
async fn dropped_hunks_and_the_files_they_belonged_to_are_named() {
let slice = slice(&big_patch(), 420).await;
let dropped = &slice.payload["dropped"];
assert!(dropped["hunks"].as_u64().unwrap() > 0);
let paths = dropped["paths"].as_array().unwrap();
assert!(!paths.is_empty(), "a dropped file should be named");
assert!(paths[0].as_str().unwrap().contains("src/mod_"));
assert!(slice.lossy);
}
#[tokio::test]
async fn context_is_trimmed_before_any_hunk_is_dropped() {
let patch = big_patch();
let total: usize = DiffSlicer::parse(&patch)
.iter()
.map(|f| f.hunks.len())
.sum();
let mut trimmed_but_complete = None;
for cap in (320..6_000).step_by(40) {
let slice = slice(&patch, cap).await;
let shown: usize = slice.payload["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["hunks"].as_array().map_or(0, |h| h.len()))
.sum();
if shown == total && slice.payload.get("context").is_some() {
trimmed_but_complete = Some((cap, slice));
break;
}
}
let (cap, slice) =
trimmed_but_complete.expect("some cap should trim context while keeping every hunk");
assert!(slice.payload.get("dropped").is_none(), "at cap {cap}");
assert!(slice.lossy, "trimming context is still lossy");
}
#[test]
fn trimming_keeps_the_changed_lines_and_marks_what_it_removed() {
let hunk = Hunk {
header: "@@ -1,9 +1,9 @@".into(),
lines: vec![
" a".into(),
" b".into(),
" c".into(),
"-old".into(),
"+new".into(),
" d".into(),
" e".into(),
" f".into(),
],
added: 1,
removed: 1,
};
let tight = hunk.with_context(1);
assert!(tight.lines.iter().any(|l| l == "-old"));
assert!(tight.lines.iter().any(|l| l == "+new"));
assert!(
tight.lines.iter().any(|l| l.contains("unchanged lines")),
"removed context is marked, not silently dropped: {:?}",
tight.lines
);
assert!(tight.lines.len() < hunk.lines.len());
}
#[test]
fn trimming_to_zero_context_keeps_only_the_changes() {
let hunk = Hunk {
header: "@@ -1,5 +1,5 @@".into(),
lines: vec![" a".into(), "-old".into(), "+new".into(), " b".into()],
added: 1,
removed: 1,
};
let bare = hunk.with_context(0);
let kept: Vec<_> = bare
.lines
.iter()
.filter(|l| l.starts_with('+') || l.starts_with('-'))
.collect();
assert_eq!(kept.len(), 2);
}
#[test]
fn the_context_width_is_configurable() {
let slicer = DiffSlicer::new().context(5, 2);
assert_eq!(slicer.context, 5);
assert_eq!(slicer.min_context, 2);
let clamped = DiffSlicer::new().context(2, 9);
assert_eq!(clamped.min_context, 2);
}
#[tokio::test]
async fn a_binary_change_is_summarised_rather_than_inlined() {
let patch = "\
diff --git a/logo.png b/logo.png
index 1234..5678 100644
GIT binary patch
literal 20480
zcmeI5X;@QNzQ6VHl!yqA000mG000000AyiwXaE2J
";
let slice = slice(patch, 400).await;
let file = &slice.payload["files"][0];
assert_eq!(file["binary"], true);
assert!(
file.get("hunks").is_none(),
"no binary content should reach the payload"
);
assert!(
!serde_json::to_string(&slice.payload)
.unwrap()
.contains("zcmeI5"),
"the base85 body must not survive"
);
assert!(slice.lossy);
}
#[test]
fn a_binary_file_still_gets_a_summary_line() {
let file = FileDiff {
path: "logo.png".into(),
from: None,
status: FileStatus::Modified,
added: 0,
removed: 0,
binary: true,
hunks: Vec::new(),
};
assert!(file.summary().contains("logo.png"));
assert!(file.summary().contains("binary"));
}
#[test]
fn a_rename_summary_names_both_paths() {
let file = FileDiff {
path: "new.rs".into(),
from: Some("old.rs".into()),
status: FileStatus::Renamed,
added: 2,
removed: 1,
binary: false,
hunks: Vec::new(),
};
assert!(file.summary().contains("old.rs -> new.rs"));
assert!(file.summary().contains("+2/-1"));
}
#[tokio::test]
async fn the_output_never_exceeds_the_cap() {
let store = MemoryStore::new();
let raw = raw_of(&big_patch());
for cap in [320, 400, 800, 1_600, 3_200, 10_000] {
let condensate = DiffSlicer::new()
.condense(&raw, cap, &store)
.await
.unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
assert_eq!(condensate.cap, cap);
}
}
#[tokio::test]
async fn the_floor_grows_with_the_number_of_files() {
let floor_of = |files: usize| async move {
let mut patch = String::new();
for f in 0..files {
patch.push_str(&format!(
"diff --git a/src/f_{f}.rs b/src/f_{f}.rs\n@@ -1 +1 @@\n-a\n+b\n"
));
}
match DiffSlicer::new().slice(&raw_of(&patch), 1).await {
Err(SliceError::CapTooSmall { minimum, .. }) => minimum,
other => panic!("expected a floor, got {other:?}"),
}
};
let one = floor_of(1).await;
let twenty = floor_of(20).await;
assert!(
twenty > one * 5,
"twenty files ({twenty}) should cost far more than one ({one})"
);
}
#[tokio::test]
async fn a_cap_below_the_file_list_reports_the_measured_floor() {
let err = DiffSlicer::new()
.slice(&raw_of(&big_patch()), 30)
.await
.unwrap_err();
let SliceError::CapTooSmall { minimum, .. } = err else {
panic!("expected CapTooSmall, got {err:?}");
};
assert!(minimum > 30);
}
#[tokio::test]
async fn slicing_is_deterministic() {
let raw = raw_of(&big_patch());
let first = DiffSlicer::new().slice(&raw, 600).await.unwrap();
for _ in 0..8 {
assert_eq!(DiffSlicer::new().slice(&raw, 600).await.unwrap(), first);
}
}
#[tokio::test]
async fn something_that_is_not_a_diff_produces_an_empty_patch_rather_than_failing() {
let slice = slice("this is just prose, not a patch at all\n", 200).await;
assert_eq!(slice.payload["stats"]["files"], 0);
assert!(!slice.lossy);
}
#[tokio::test]
async fn a_bare_unified_diff_without_a_git_preamble_still_parses() {
let patch = "\
@@ -1,3 +1,3 @@
fn main() {
- old();
+ new();
";
let slice = slice(patch, 300).await;
assert_eq!(slice.payload["stats"]["files"], 1);
assert_eq!(slice.payload["stats"]["hunks"], 1);
}
}