1use async_trait::async_trait;
41use boundgraph_core::{SchemaId, SlicerId};
42use serde::{Deserialize, Serialize};
43use serde_json::json;
44
45use crate::slicer::{Raw, Slice, SliceError, Slicer};
46
47pub const DIFF_SCHEMA: &str = "boundgraph.diff.v1";
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum FileStatus {
54 Added,
56 Deleted,
58 Modified,
60 Renamed,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Hunk {
67 pub header: String,
69 pub lines: Vec<String>,
71 pub added: usize,
73 pub removed: usize,
75}
76
77impl Hunk {
78 pub fn with_context(&self, context: usize) -> Self {
85 let changed: Vec<usize> = self
86 .lines
87 .iter()
88 .enumerate()
89 .filter(|(_, l)| l.starts_with('+') || l.starts_with('-'))
90 .map(|(i, _)| i)
91 .collect();
92
93 if changed.is_empty() {
94 return self.clone();
95 }
96
97 let mut keep = vec![false; self.lines.len()];
98 for &index in &changed {
99 let low = index.saturating_sub(context);
100 let high = (index + context).min(self.lines.len() - 1);
101 for slot in keep.iter_mut().take(high + 1).skip(low) {
102 *slot = true;
103 }
104 }
105
106 let mut lines = Vec::new();
107 let mut skipped = 0usize;
108 for (index, line) in self.lines.iter().enumerate() {
109 if keep[index] {
110 if skipped > 0 {
111 lines.push(format!(" … {skipped} unchanged lines"));
114 skipped = 0;
115 }
116 lines.push(line.clone());
117 } else {
118 skipped += 1;
119 }
120 }
121 if skipped > 0 {
122 lines.push(format!(" … {skipped} unchanged lines"));
123 }
124
125 Self {
126 header: self.header.clone(),
127 lines,
128 added: self.added,
129 removed: self.removed,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct FileDiff {
137 pub path: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub from: Option<String>,
142 pub status: FileStatus,
144 pub added: usize,
146 pub removed: usize,
148 #[serde(skip_serializing_if = "std::ops::Not::not")]
150 pub binary: bool,
151 #[serde(skip_serializing_if = "Vec::is_empty")]
154 pub hunks: Vec<Hunk>,
155}
156
157impl FileDiff {
158 pub fn summary(&self) -> String {
160 let kind = match self.status {
161 FileStatus::Added => "added",
162 FileStatus::Deleted => "deleted",
163 FileStatus::Modified => "modified",
164 FileStatus::Renamed => "renamed",
165 };
166 let binary = if self.binary { " binary" } else { "" };
167 match &self.from {
168 Some(from) => format!(
169 "{from} -> {} ({kind}{binary}, +{}/-{})",
170 self.path, self.added, self.removed
171 ),
172 None => format!(
173 "{} ({kind}{binary}, +{}/-{})",
174 self.path, self.added, self.removed
175 ),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy)]
206pub struct DiffSlicer {
207 context: usize,
208 min_context: usize,
209}
210
211impl Default for DiffSlicer {
212 fn default() -> Self {
213 Self::new()
214 }
215}
216
217impl DiffSlicer {
218 pub fn new() -> Self {
220 Self {
221 context: 3,
222 min_context: 0,
223 }
224 }
225
226 #[must_use]
232 pub fn context(mut self, context: usize, min_context: usize) -> Self {
233 self.context = context;
234 self.min_context = min_context.min(context);
235 self
236 }
237
238 pub fn parse(text: &str) -> Vec<FileDiff> {
244 let mut files: Vec<FileDiff> = Vec::new();
245 let mut hunk: Option<Hunk> = None;
246
247 fn flush(files: &mut [FileDiff], hunk: &mut Option<Hunk>) {
249 if let (Some(file), Some(done)) = (files.last_mut(), hunk.take()) {
250 file.added += done.added;
251 file.removed += done.removed;
252 file.hunks.push(done);
253 }
254 }
255
256 for line in text.lines() {
257 if let Some(rest) = line.strip_prefix("diff --git ") {
258 flush(&mut files, &mut hunk);
259 let path = rest
260 .split_whitespace()
261 .next_back()
262 .unwrap_or("")
263 .strip_prefix("b/")
264 .unwrap_or(rest)
265 .to_string();
266 files.push(FileDiff {
267 path,
268 from: None,
269 status: FileStatus::Modified,
270 added: 0,
271 removed: 0,
272 binary: false,
273 hunks: Vec::new(),
274 });
275 continue;
276 }
277
278 if let Some(file) = files.last_mut() {
279 if line.starts_with("new file mode") {
280 file.status = FileStatus::Added;
281 continue;
282 }
283 if line.starts_with("deleted file mode") {
284 file.status = FileStatus::Deleted;
285 continue;
286 }
287 if let Some(from) = line.strip_prefix("rename from ") {
288 file.status = FileStatus::Renamed;
289 file.from = Some(from.to_string());
290 continue;
291 }
292 if line.starts_with("rename to ") {
293 continue;
294 }
295 if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
297 flush(&mut files, &mut hunk);
298 if let Some(file) = files.last_mut() {
299 file.binary = true;
300 }
301 continue;
302 }
303 }
304
305 if line.starts_with("@@") {
306 flush(&mut files, &mut hunk);
307 if files.is_empty() {
308 files.push(FileDiff {
310 path: "(unnamed)".to_string(),
311 from: None,
312 status: FileStatus::Modified,
313 added: 0,
314 removed: 0,
315 binary: false,
316 hunks: Vec::new(),
317 });
318 }
319 hunk = Some(Hunk {
320 header: line.to_string(),
321 lines: Vec::new(),
322 added: 0,
323 removed: 0,
324 });
325 continue;
326 }
327
328 if hunk.is_none() {
332 if let Some(path) = line.strip_prefix("+++ b/").or(line.strip_prefix("+++ ")) {
333 if let Some(file) = files.last_mut()
334 && file.path.is_empty()
335 {
336 file.path = path.to_string();
337 }
338 }
339 continue;
340 }
341
342 if let Some(open) = hunk.as_mut() {
343 match line.chars().next() {
344 Some('+') => open.added += 1,
345 Some('-') => open.removed += 1,
346 Some(' ') | Some('\\') | None => {}
348 _ => {
350 flush(&mut files, &mut hunk);
351 continue;
352 }
353 }
354 if let Some(open) = hunk.as_mut() {
355 open.lines.push(line.to_string());
356 }
357 }
358 }
359
360 flush(&mut files, &mut hunk);
361 for file in &mut files {
363 if file.binary {
364 file.hunks.clear();
365 }
366 }
367 files
368 }
369
370 fn payload(files: &[FileDiff], context: usize, kept: usize) -> serde_json::Value {
375 let mut shown = 0usize;
376 let mut dropped_hunks = 0usize;
377 let mut dropped_paths: Vec<&str> = Vec::new();
378
379 let rendered: Vec<FileDiff> = files
380 .iter()
381 .map(|file| {
382 let mut out = file.clone();
383 out.hunks = Vec::new();
384 for hunk in &file.hunks {
385 if shown < kept {
386 out.hunks.push(hunk.with_context(context));
387 shown += 1;
388 } else {
389 dropped_hunks += 1;
390 }
391 }
392 if !file.hunks.is_empty() && out.hunks.is_empty() {
393 dropped_paths.push(&file.path);
394 }
395 out
396 })
397 .collect();
398
399 let mut payload = json!({
400 "files": rendered,
401 "stats": {
402 "files": files.len(),
403 "added": files.iter().map(|f| f.added).sum::<usize>(),
404 "removed": files.iter().map(|f| f.removed).sum::<usize>(),
405 "hunks": files.iter().map(|f| f.hunks.len()).sum::<usize>(),
406 },
407 });
408
409 if dropped_hunks > 0 {
410 payload["dropped"] = json!({
411 "hunks": dropped_hunks,
412 "files": dropped_paths.len(),
413 "paths": dropped_paths,
414 });
415 }
416 if context < 3 {
417 payload["context"] = json!(context);
418 }
419 payload
420 }
421}
422
423#[async_trait]
424impl Slicer for DiffSlicer {
425 fn id(&self) -> SlicerId {
426 SlicerId::new("diff", 1)
427 }
428
429 async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
430 let text = raw.text();
431 let files = Self::parse(&text);
432 let schema = SchemaId::new(DIFF_SCHEMA, 1);
433 let total_hunks: usize = files.iter().map(|f| f.hunks.len()).sum();
434
435 let counter = self.counter();
436 let fits = |value: &serde_json::Value| {
437 counter.count(&serde_json::to_string(value).unwrap_or_default()) <= cap
438 };
439
440 let floor = Self::payload(&files, self.min_context, 0);
443 if !fits(&floor) {
444 return Err(SliceError::CapTooSmall {
445 cap,
446 minimum: counter.count(&serde_json::to_string(&floor).unwrap_or_default()),
447 });
448 }
449
450 for context in (self.min_context..=self.context).rev() {
452 let candidate = Self::payload(&files, context, total_hunks);
453 if fits(&candidate) {
454 return Ok(Slice {
455 schema,
456 lossy: context < self.context || files.iter().any(|f| f.binary),
457 payload: candidate,
458 });
459 }
460 }
461
462 let mut kept = total_hunks;
464 while kept > 0 {
465 kept -= 1;
466 let candidate = Self::payload(&files, self.min_context, kept);
467 if fits(&candidate) {
468 return Ok(Slice {
469 schema,
470 payload: candidate,
471 lossy: true,
472 });
473 }
474 }
475
476 Ok(Slice {
477 schema,
478 payload: floor,
479 lossy: true,
480 })
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::slicer::MemoryStore;
488
489 const SMALL: &str = "\
490diff --git a/src/main.rs b/src/main.rs
491--- a/src/main.rs
492+++ b/src/main.rs
493@@ -1,5 +1,5 @@
494 fn main() {
495 let a = 1;
496- println!(\"old\");
497+ println!(\"new\");
498 let b = 2;
499 }
500";
501
502 fn big_patch() -> String {
504 let mut out = String::new();
505 for file in 0..8 {
506 out.push_str(&format!(
507 "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"
508 ));
509 for hunk in 0..4 {
510 out.push_str(&format!(
511 "@@ -{0},8 +{0},9 @@ fn thing_{hunk}()\n",
512 hunk * 20 + 1
513 ));
514 for c in 0..3 {
515 out.push_str(&format!(" untouched line {c}\n"));
516 }
517 out.push_str(&format!("- let old_{hunk} = 1;\n"));
518 out.push_str(&format!("+ let new_{hunk} = 2;\n"));
519 out.push_str(&format!("+ let extra_{hunk} = 3;\n"));
520 for c in 0..3 {
521 out.push_str(&format!(" trailing line {c}\n"));
522 }
523 }
524 }
525 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");
526 out.push_str("diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n");
527 out
528 }
529
530 fn raw_of(text: &str) -> Raw {
531 Raw::new(text.as_bytes().to_vec())
532 }
533
534 async fn slice(text: &str, cap: usize) -> Slice {
535 DiffSlicer::new()
536 .slice(&raw_of(text), cap)
537 .await
538 .expect("a satisfiable cap")
539 }
540
541 fn hunk_lines(slice: &Slice) -> Vec<String> {
542 slice.payload["files"]
543 .as_array()
544 .unwrap()
545 .iter()
546 .flat_map(|f| f["hunks"].as_array().cloned().unwrap_or_default())
547 .flat_map(|h| h["lines"].as_array().cloned().unwrap_or_default())
548 .map(|l| l.as_str().unwrap().to_string())
549 .collect()
550 }
551
552 #[test]
555 fn a_simple_patch_parses_to_one_file_and_one_hunk() {
556 let files = DiffSlicer::parse(SMALL);
557 assert_eq!(files.len(), 1);
558 assert_eq!(files[0].path, "src/main.rs");
559 assert_eq!(files[0].hunks.len(), 1);
560 assert_eq!(files[0].added, 1);
561 assert_eq!(files[0].removed, 1);
562 }
563
564 #[test]
567 fn the_file_header_is_not_counted_as_a_change() {
568 let files = DiffSlicer::parse(SMALL);
569 assert_eq!(files[0].added, 1, "exactly the one +line");
570 assert_eq!(files[0].removed, 1, "exactly the one -line");
571 }
572
573 #[test]
574 fn file_status_is_read_from_the_preamble() {
575 let patch = "\
576diff --git a/new.rs b/new.rs
577new file mode 100644
578--- /dev/null
579+++ b/new.rs
580@@ -0,0 +1 @@
581+fn main() {}
582diff --git a/gone.rs b/gone.rs
583deleted file mode 100644
584@@ -1 +0,0 @@
585-fn main() {}
586diff --git a/old.rs b/moved.rs
587rename from old.rs
588rename to moved.rs
589";
590 let files = DiffSlicer::parse(patch);
591 assert_eq!(files[0].status, FileStatus::Added);
592 assert_eq!(files[1].status, FileStatus::Deleted);
593 assert_eq!(files[2].status, FileStatus::Renamed);
594 assert_eq!(files[2].from.as_deref(), Some("old.rs"));
595 }
596
597 #[tokio::test]
601 async fn a_hunk_is_never_cut_in_half() {
602 let patch = big_patch();
603
604 for cap in [320, 400, 800, 1_600, 3_200, 8_000] {
605 let slice = slice(&patch, cap).await;
606 for file in slice.payload["files"].as_array().unwrap() {
607 for hunk in file["hunks"].as_array().cloned().unwrap_or_default() {
608 let header = hunk["header"].as_str().unwrap();
609 assert!(header.starts_with("@@"), "cap {cap}: {header}");
610 assert!(
611 !hunk["lines"].as_array().unwrap().is_empty(),
612 "cap {cap}: a hunk with a header and no lines is a cut hunk"
613 );
614 }
615 }
616 }
617 }
618
619 #[tokio::test]
620 async fn a_patch_that_fits_is_kept_whole_and_is_not_lossy() {
621 let slice = slice(SMALL, 500).await;
622
623 assert!(!slice.lossy);
624 assert!(slice.payload.get("dropped").is_none());
625 let lines = hunk_lines(&slice);
626 assert!(lines.iter().any(|l| l.contains("old")));
627 assert!(lines.iter().any(|l| l.contains("new")));
628 }
629
630 #[tokio::test]
636 async fn every_file_keeps_its_entry_and_stats_however_tight_the_cap() {
637 let patch = big_patch();
638 let all = DiffSlicer::parse(&patch);
639
640 for cap in [320, 400, 600, 1_000] {
641 let slice = slice(&patch, cap).await;
642 let files = slice.payload["files"].as_array().unwrap();
643 assert_eq!(files.len(), all.len(), "at cap {cap}");
644
645 for (shown, parsed) in files.iter().zip(&all) {
646 assert_eq!(shown["path"], parsed.path.as_str(), "at cap {cap}");
647 assert_eq!(shown["added"], parsed.added, "at cap {cap}");
648 assert_eq!(shown["removed"], parsed.removed, "at cap {cap}");
649 }
650 }
651 }
652
653 #[tokio::test]
654 async fn the_overall_statistics_are_always_present() {
655 let slice = slice(&big_patch(), 320).await;
656 let stats = &slice.payload["stats"];
657
658 assert_eq!(stats["files"], 10);
659 assert_eq!(stats["hunks"], 32);
660 assert!(stats["added"].as_u64().unwrap() > 0);
661 }
662
663 #[tokio::test]
665 async fn dropped_hunks_and_the_files_they_belonged_to_are_named() {
666 let slice = slice(&big_patch(), 420).await;
667 let dropped = &slice.payload["dropped"];
668
669 assert!(dropped["hunks"].as_u64().unwrap() > 0);
670 let paths = dropped["paths"].as_array().unwrap();
671 assert!(!paths.is_empty(), "a dropped file should be named");
672 assert!(paths[0].as_str().unwrap().contains("src/mod_"));
673 assert!(slice.lossy);
674 }
675
676 #[tokio::test]
681 async fn context_is_trimmed_before_any_hunk_is_dropped() {
682 let patch = big_patch();
683 let total: usize = DiffSlicer::parse(&patch)
684 .iter()
685 .map(|f| f.hunks.len())
686 .sum();
687
688 let mut trimmed_but_complete = None;
690 for cap in (320..6_000).step_by(40) {
691 let slice = slice(&patch, cap).await;
692 let shown: usize = slice.payload["files"]
693 .as_array()
694 .unwrap()
695 .iter()
696 .map(|f| f["hunks"].as_array().map_or(0, |h| h.len()))
697 .sum();
698 if shown == total && slice.payload.get("context").is_some() {
699 trimmed_but_complete = Some((cap, slice));
700 break;
701 }
702 }
703
704 let (cap, slice) =
705 trimmed_but_complete.expect("some cap should trim context while keeping every hunk");
706 assert!(slice.payload.get("dropped").is_none(), "at cap {cap}");
707 assert!(slice.lossy, "trimming context is still lossy");
708 }
709
710 #[test]
711 fn trimming_keeps_the_changed_lines_and_marks_what_it_removed() {
712 let hunk = Hunk {
713 header: "@@ -1,9 +1,9 @@".into(),
714 lines: vec![
715 " a".into(),
716 " b".into(),
717 " c".into(),
718 "-old".into(),
719 "+new".into(),
720 " d".into(),
721 " e".into(),
722 " f".into(),
723 ],
724 added: 1,
725 removed: 1,
726 };
727
728 let tight = hunk.with_context(1);
729 assert!(tight.lines.iter().any(|l| l == "-old"));
730 assert!(tight.lines.iter().any(|l| l == "+new"));
731 assert!(
732 tight.lines.iter().any(|l| l.contains("unchanged lines")),
733 "removed context is marked, not silently dropped: {:?}",
734 tight.lines
735 );
736 assert!(tight.lines.len() < hunk.lines.len());
737 }
738
739 #[test]
740 fn trimming_to_zero_context_keeps_only_the_changes() {
741 let hunk = Hunk {
742 header: "@@ -1,5 +1,5 @@".into(),
743 lines: vec![" a".into(), "-old".into(), "+new".into(), " b".into()],
744 added: 1,
745 removed: 1,
746 };
747 let bare = hunk.with_context(0);
748 let kept: Vec<_> = bare
749 .lines
750 .iter()
751 .filter(|l| l.starts_with('+') || l.starts_with('-'))
752 .collect();
753 assert_eq!(kept.len(), 2);
754 }
755
756 #[test]
757 fn the_context_width_is_configurable() {
758 let slicer = DiffSlicer::new().context(5, 2);
759 assert_eq!(slicer.context, 5);
760 assert_eq!(slicer.min_context, 2);
761
762 let clamped = DiffSlicer::new().context(2, 9);
764 assert_eq!(clamped.min_context, 2);
765 }
766
767 #[tokio::test]
772 async fn a_binary_change_is_summarised_rather_than_inlined() {
773 let patch = "\
774diff --git a/logo.png b/logo.png
775index 1234..5678 100644
776GIT binary patch
777literal 20480
778zcmeI5X;@QNzQ6VHl!yqA000mG000000AyiwXaE2J
779";
780 let slice = slice(patch, 400).await;
781 let file = &slice.payload["files"][0];
782
783 assert_eq!(file["binary"], true);
784 assert!(
785 file.get("hunks").is_none(),
786 "no binary content should reach the payload"
787 );
788 assert!(
789 !serde_json::to_string(&slice.payload)
790 .unwrap()
791 .contains("zcmeI5"),
792 "the base85 body must not survive"
793 );
794 assert!(slice.lossy);
795 }
796
797 #[test]
798 fn a_binary_file_still_gets_a_summary_line() {
799 let file = FileDiff {
800 path: "logo.png".into(),
801 from: None,
802 status: FileStatus::Modified,
803 added: 0,
804 removed: 0,
805 binary: true,
806 hunks: Vec::new(),
807 };
808 assert!(file.summary().contains("logo.png"));
809 assert!(file.summary().contains("binary"));
810 }
811
812 #[test]
813 fn a_rename_summary_names_both_paths() {
814 let file = FileDiff {
815 path: "new.rs".into(),
816 from: Some("old.rs".into()),
817 status: FileStatus::Renamed,
818 added: 2,
819 removed: 1,
820 binary: false,
821 hunks: Vec::new(),
822 };
823 assert!(file.summary().contains("old.rs -> new.rs"));
824 assert!(file.summary().contains("+2/-1"));
825 }
826
827 #[tokio::test]
830 async fn the_output_never_exceeds_the_cap() {
831 let store = MemoryStore::new();
832 let raw = raw_of(&big_patch());
833
834 for cap in [320, 400, 800, 1_600, 3_200, 10_000] {
835 let condensate = DiffSlicer::new()
836 .condense(&raw, cap, &store)
837 .await
838 .unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
839 assert_eq!(condensate.cap, cap);
840 }
841 }
842
843 #[tokio::test]
849 async fn the_floor_grows_with_the_number_of_files() {
850 let floor_of = |files: usize| async move {
851 let mut patch = String::new();
852 for f in 0..files {
853 patch.push_str(&format!(
854 "diff --git a/src/f_{f}.rs b/src/f_{f}.rs\n@@ -1 +1 @@\n-a\n+b\n"
855 ));
856 }
857 match DiffSlicer::new().slice(&raw_of(&patch), 1).await {
858 Err(SliceError::CapTooSmall { minimum, .. }) => minimum,
859 other => panic!("expected a floor, got {other:?}"),
860 }
861 };
862
863 let one = floor_of(1).await;
864 let twenty = floor_of(20).await;
865 assert!(
866 twenty > one * 5,
867 "twenty files ({twenty}) should cost far more than one ({one})"
868 );
869 }
870
871 #[tokio::test]
872 async fn a_cap_below_the_file_list_reports_the_measured_floor() {
873 let err = DiffSlicer::new()
874 .slice(&raw_of(&big_patch()), 30)
875 .await
876 .unwrap_err();
877
878 let SliceError::CapTooSmall { minimum, .. } = err else {
879 panic!("expected CapTooSmall, got {err:?}");
880 };
881 assert!(minimum > 30);
882 }
883
884 #[tokio::test]
885 async fn slicing_is_deterministic() {
886 let raw = raw_of(&big_patch());
887 let first = DiffSlicer::new().slice(&raw, 600).await.unwrap();
888 for _ in 0..8 {
889 assert_eq!(DiffSlicer::new().slice(&raw, 600).await.unwrap(), first);
890 }
891 }
892
893 #[tokio::test]
894 async fn something_that_is_not_a_diff_produces_an_empty_patch_rather_than_failing() {
895 let slice = slice("this is just prose, not a patch at all\n", 200).await;
896 assert_eq!(slice.payload["stats"]["files"], 0);
897 assert!(!slice.lossy);
898 }
899
900 #[tokio::test]
901 async fn a_bare_unified_diff_without_a_git_preamble_still_parses() {
902 let patch = "\
903@@ -1,3 +1,3 @@
904 fn main() {
905- old();
906+ new();
907";
908 let slice = slice(patch, 300).await;
909 assert_eq!(slice.payload["stats"]["files"], 1);
910 assert_eq!(slice.payload["stats"]["hunks"], 1);
911 }
912}