Skip to main content

boundgraph_slice/
diff.rs

1//! Condensing patches: whole hunks, or none of them.
2//!
3//! # Why a truncated diff is worse than no diff
4//!
5//! Every other slicer here drops information the model can tell is missing. A
6//! diff cut at an arbitrary byte does not: it produces something that parses,
7//! reads as complete, and describes a change that was never made. A model shown
8//! the first half of a hunk sees a deletion with no corresponding insertion and
9//! concludes the code was removed.
10//!
11//! So this slicer never cuts a hunk. Everything it does is at hunk granularity
12//! or coarser, and what it drops it names.
13//!
14//! # The order things are given up
15//!
16//! 1. **Nothing.** If the whole patch fits, it is kept whole.
17//! 2. **Context lines.** The unchanged lines around each change are the
18//!    cheapest thing to lose and the least informative — the changed lines are
19//!    still there, and the file is still named. Trimmed symmetrically, three
20//!    lines to two to one to none.
21//! 3. **Whole hunks**, from the end of the patch backwards.
22//!
23//! Context goes before hunks because a change with no surrounding context is
24//! still a true statement about the patch, whereas a missing hunk is a change
25//! the model cannot see at all. Which hunks *matter* is explicitly out of scope
26//! (US-0704), so the order is positional and predictable rather than clever.
27//!
28//! # The file list always survives
29//!
30//! Even a file whose every hunk was dropped keeps its entry, its status, and
31//! its `+n/-m` counts. That is the difference between "this patch did not touch
32//! `auth.rs`" and "you were not shown what it did to `auth.rs`" — and a review
33//! agent that cannot tell those apart is worse than useless.
34//!
35//! # Binary files are summarised, never inlined
36//!
37//! A binary patch is megabytes of base85 that no model can read and every model
38//! will happily spend a context window on. It becomes one line.
39
40use 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
47/// The schema a diff condensate declares.
48pub const DIFF_SCHEMA: &str = "boundgraph.diff.v1";
49
50/// What happened to a file.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum FileStatus {
54    /// The file is new.
55    Added,
56    /// The file is gone.
57    Deleted,
58    /// The file changed in place.
59    Modified,
60    /// The file moved, possibly with changes.
61    Renamed,
62}
63
64/// One hunk of one file's diff.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Hunk {
67    /// The `@@ … @@` header, including the trailing function context git adds.
68    pub header: String,
69    /// The hunk's lines, each still carrying its leading ` `, `+`, or `-`.
70    pub lines: Vec<String>,
71    /// Lines added by this hunk.
72    pub added: usize,
73    /// Lines removed by this hunk.
74    pub removed: usize,
75}
76
77impl Hunk {
78    /// The hunk with at most `context` unchanged lines on each side of every
79    /// run of changes.
80    ///
81    /// Trimming is symmetric and interior: context *between* two nearby changes
82    /// is kept when it is short enough to serve both, because dropping it would
83    /// imply the changes are adjacent when they are not.
84    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                    // Say that unchanged lines were removed, so the hunk cannot
112                    // be misread as contiguous.
113                    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/// One file's worth of a patch.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct FileDiff {
137    /// The path after the change.
138    pub path: String,
139    /// The path before it, when the file moved.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub from: Option<String>,
142    /// What happened to it.
143    pub status: FileStatus,
144    /// Lines added.
145    pub added: usize,
146    /// Lines removed.
147    pub removed: usize,
148    /// Whether this is a binary change, summarised rather than shown.
149    #[serde(skip_serializing_if = "std::ops::Not::not")]
150    pub binary: bool,
151    /// The hunks retained for this file. Empty where they were all dropped —
152    /// the entry itself always survives.
153    #[serde(skip_serializing_if = "Vec::is_empty")]
154    pub hunks: Vec<Hunk>,
155}
156
157impl FileDiff {
158    /// A summary line, which is what survives when the hunks do not.
159    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/// Condenses unified diffs.
181///
182/// # Example
183///
184/// ```
185/// use boundgraph_slice::{DiffSlicer, MemoryStore, Raw, Slicer};
186///
187/// let patch = "\
188/// diff --git a/src/main.rs b/src/main.rs
189/// --- a/src/main.rs
190/// +++ b/src/main.rs
191/// @@ -1,3 +1,3 @@
192///  fn main() {
193/// -    println!(\"old\");
194/// +    println!(\"new\");
195///  }
196/// ";
197///
198/// let condensate = tokio::runtime::Builder::new_current_thread()
199///     .build()?
200///     .block_on(DiffSlicer::new().condense(&Raw::new(patch), 400, &MemoryStore::new()))?;
201///
202/// assert!(!condensate.lossy, "a small patch survives whole");
203/// # Ok::<(), Box<dyn std::error::Error>>(())
204/// ```
205#[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    /// A slicer keeping three context lines, trimming to none under pressure.
219    pub fn new() -> Self {
220        Self {
221            context: 3,
222            min_context: 0,
223        }
224    }
225
226    /// Keep at most `context` unchanged lines around each change, and never
227    /// trim below `min_context`.
228    ///
229    /// A floor above zero is for a reviewer who needs to see *where* a change
230    /// landed even at the cost of dropping more hunks.
231    #[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    /// Parse a unified diff into files and hunks.
239    ///
240    /// Tolerant by design: a patch with an unrecognised header line is still
241    /// mostly a patch, and refusing the whole thing over one line git added in
242    /// a version nobody here has seen would be worse than ignoring it.
243    pub fn parse(text: &str) -> Vec<FileDiff> {
244        let mut files: Vec<FileDiff> = Vec::new();
245        let mut hunk: Option<Hunk> = None;
246
247        /// Close the hunk being built onto the file being built.
248        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                // Both the textual marker and git's binary patch body.
296                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                    // A bare unified diff with no `diff --git` preamble.
309                    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            // `--- a/x` and `+++ b/x` name the file; they are not hunk content,
329            // and mistaking them for it would count them as a removal and an
330            // addition in every single file.
331            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                    // A context line, or git's "\ No newline at end of file".
347                    Some(' ') | Some('\\') | None => {}
348                    // Anything else ends the hunk: the next file's preamble.
349                    _ => {
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        // Binary changes carry no readable hunks, whatever the patch contained.
362        for file in &mut files {
363            if file.binary {
364                file.hunks.clear();
365            }
366        }
367        files
368    }
369
370    /// The payload for a given context width and hunk budget.
371    ///
372    /// `kept` is how many hunks, counted across the whole patch in order,
373    /// survive. Every file entry is present regardless.
374    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        // The file list and its statistics are retained whatever the cap, so if
441        // they alone do not fit there is no slice to make.
442        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        // 1 and 2: widest context that fits with every hunk, trimming down.
451        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        // 3: drop whole hunks from the end, at the tightest context.
463        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    /// Several files, several hunks each, plus a binary and a rename.
503    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    // ── parsing ─────────────────────────────────────────────────────────────
553
554    #[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    /// `--- a/x` and `+++ b/x` are not hunk content. Counting them would report
565    /// one extra addition and removal in every file in every patch.
566    #[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    // ── hunks are never cut ─────────────────────────────────────────────────
598
599    /// The criterion the whole slicer is built around.
600    #[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    // ── the file list survives ──────────────────────────────────────────────
631
632    /// The criterion: the file list and per-file stats are always retained.
633    /// The difference between "the patch did not touch auth.rs" and "you were
634    /// not shown what it did to auth.rs".
635    #[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    /// The criterion: dropped files and counts are reported.
664    #[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    // ── context is trimmed before hunks are dropped ─────────────────────────
677
678    /// The criterion. A change with no context is still a true statement about
679    /// the patch; a missing hunk is a change the model cannot see at all.
680    #[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        // A cap where the patch does not fit at full context but does at less.
689        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        // A floor above the width is clamped rather than inverting the range.
763        let clamped = DiffSlicer::new().context(2, 9);
764        assert_eq!(clamped.min_context, 2);
765    }
766
767    // ── binary ──────────────────────────────────────────────────────────────
768
769    /// The criterion: a binary change is a summary, not megabytes of base85 a
770    /// model cannot read and will happily spend a window on.
771    #[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    // ── the contract ────────────────────────────────────────────────────────
828
829    #[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    /// A consequence of retaining the file list unconditionally: the floor
844    /// grows with the number of files touched. A step that reviews
845    /// twenty-file patches needs a bigger `CtxSpec` than one that reviews
846    /// single-file patches, and the error says so with a number rather than
847    /// leaving the author to work it out.
848    #[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}