use std::collections::HashSet;
use super::{Ctx, Mode, attributed, elide, elide_into, fill_by_score, pick_mode, query_bonus};
const MAX_FILES: usize = 20;
const MAX_HUNKS: usize = 10;
const MAX_CONTEXT: usize = 3;
struct FileDiff<'a> {
header: Vec<&'a str>,
hunks: Vec<Hunk<'a>>,
}
struct Hunk<'a> {
header: &'a str,
body: Vec<&'a str>,
}
impl Hunk<'_> {
fn changes(&self) -> usize {
self.body.iter().filter(|l| is_change(l)).count()
}
}
impl FileDiff<'_> {
fn changes(&self) -> usize {
self.hunks.iter().map(Hunk::changes).sum()
}
fn lines(&self) -> Vec<&str> {
let mut lines: Vec<&str> = self.header.clone();
for h in &self.hunks {
lines.push(h.header);
lines.extend(&h.body);
}
lines
}
}
fn is_change(line: &str) -> bool {
(line.starts_with('+') || line.starts_with('-')) && !line.starts_with("\\")
}
pub fn compress(text: &str, ctx: &Ctx, query: &HashSet<String>) -> Option<String> {
let files = parse(text);
if files.is_empty() {
return None;
}
let total: usize = files
.iter()
.flat_map(|f| &f.hunks)
.map(|h| h.body.len())
.sum();
let changes: usize = files.iter().map(FileDiff::changes).sum();
let max_context = match pick_mode(ctx.mode, total, changes) {
Mode::Aggressive => 0,
Mode::Adaptive => MAX_CONTEXT,
};
let keep_file = cap_by_score(
&files.iter().map(|f| f.changes() as f64).collect::<Vec<_>>(),
MAX_FILES,
);
let mut out: Vec<String> = Vec::new();
let mut changed = false;
for (fi, file) in files.iter().enumerate() {
if !keep_file[fi] {
out.push(file.header.first().copied().unwrap_or_default().to_string());
out.push(elide(&file.lines()));
changed = true;
continue;
}
out.extend(file.header.iter().map(|l| l.to_string()));
let scores: Vec<f64> = file
.hunks
.iter()
.map(|h| {
let q = h
.body
.iter()
.map(|l| query_bonus(l, query))
.fold(0.0, f64::max);
(h.changes() as f64 * 0.05).min(0.5) + q
})
.collect();
let keep_hunk = cap_by_score(&scores, MAX_HUNKS);
for (hi, hunk) in file.hunks.iter().enumerate() {
if keep_hunk[hi] {
out.push(hunk.header.to_string());
let (trimmed, trimmed_any) = trim_context(&hunk.body, max_context);
out.extend(trimmed);
changed |= trimmed_any;
} else {
let mut lines = vec![hunk.header];
lines.extend(&hunk.body);
out.push(elide(&lines));
changed = true;
}
}
}
changed.then(|| attributed(out, text.lines().count()))
}
fn cap_by_score(scores: &[f64], max: usize) -> Vec<bool> {
let n = scores.len();
if n <= max {
return vec![true; n];
}
let mut keep = vec![false; n];
keep[0] = true;
keep[n - 1] = true;
fill_by_score(&mut keep, scores, max);
keep
}
fn trim_context(body: &[&str], max_context: usize) -> (Vec<String>, bool) {
let n = body.len();
let change: Vec<bool> = body.iter().map(|l| is_change(l)).collect();
let mut dist = vec![usize::MAX; n];
let mut last = usize::MAX;
for i in 0..n {
if change[i] {
last = 0;
} else if last != usize::MAX {
last += 1;
}
dist[i] = last;
}
let mut next = usize::MAX;
for i in (0..n).rev() {
if change[i] {
next = 0;
} else if next != usize::MAX {
next += 1;
}
dist[i] = dist[i].min(next);
}
let keep: Vec<bool> = (0..n)
.map(|i| change[i] || dist[i] <= max_context)
.collect();
let mut out: Vec<String> = Vec::new();
let mut trimmed = false;
let mut i = 0;
while i < n {
if keep[i] {
out.push(body[i].to_string());
i += 1;
} else {
let start = i;
while i < n && !keep[i] {
i += 1;
}
trimmed |= elide_into(&body[start..i], &mut out);
}
}
(out, trimmed)
}
fn parse(text: &str) -> Vec<FileDiff<'_>> {
let git_mode = text.starts_with("diff --git ") || text.contains("\ndiff --git ");
let lines: Vec<&str> = text.lines().collect();
let is_file_start = |i: usize| {
let line = lines[i];
if git_mode {
line.starts_with("diff --git ")
} else {
line.starts_with("--- ") && lines.get(i + 1).is_some_and(|n| n.starts_with("+++ "))
}
};
let mut files: Vec<FileDiff> = Vec::new();
let mut cur: Option<FileDiff> = None;
for (i, &line) in lines.iter().enumerate() {
if is_file_start(i) {
if let Some(f) = cur.take() {
files.push(f);
}
cur = Some(FileDiff {
header: vec![line],
hunks: Vec::new(),
});
continue;
}
let Some(f) = cur.as_mut() else {
continue; };
if line.starts_with("@@") {
f.hunks.push(Hunk {
header: line,
body: Vec::new(),
});
} else if let Some(h) = f.hunks.last_mut() {
h.body.push(line);
} else {
f.header.push(line);
}
}
if let Some(f) = cur.take() {
files.push(f);
}
files
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stages::toolout::{ModeSetting, test_ctx};
fn padded_diff() -> String {
let mut lines = vec![
"diff --git a/big.rs b/big.rs".to_string(),
"--- a/big.rs".to_string(),
"+++ b/big.rs".to_string(),
"@@ -1,30 +1,30 @@".to_string(),
"-let removed = 0;".to_string(),
"+let added = 1;".to_string(),
];
for i in 0..25 {
lines.push(format!(" unchanged context line {i}"));
}
lines.join("\n")
}
#[test]
fn trims_far_context_keeps_changes() {
let diff = padded_diff();
let out = compress(&diff, &test_ctx(), &HashSet::new()).expect("compresses");
assert!(out.contains("-let removed = 0;"), "removal kept");
assert!(out.contains("+let added = 1;"), "addition kept");
assert!(out.contains("@@ -1,30 +1,30 @@"), "hunk header kept");
assert!(
out.contains(" unchanged context line 0"),
"near context kept"
);
assert!(!out.contains("context line 24"), "far context trimmed");
assert!(
out.contains("omitted"),
"trimmed context elided by position"
);
}
#[test]
fn drops_least_changed_files_over_cap() {
let mut sections = vec![format!(
"diff --git a/hot.rs b/hot.rs\n--- a/hot.rs\n+++ b/hot.rs\n@@ -1,5 +1,5 @@\n{}",
(0..5)
.map(|i| format!("-old{i}\n+new{i}"))
.collect::<Vec<_>>()
.join("\n")
)];
for i in 0..MAX_FILES + 5 {
sections.push(format!(
"diff --git a/f{i}.rs b/f{i}.rs\n--- a/f{i}.rs\n+++ b/f{i}.rs\n@@ -1 +1 @@\n-a\n+b"
));
}
let diff = sections.join("\n");
let out = compress(&diff, &test_ctx(), &HashSet::new()).expect("compresses");
assert!(out.contains("a/hot.rs"), "the heavily-changed file is kept");
assert!(
out.matches("+++ b/").count() <= MAX_FILES,
"rendered files are capped at MAX_FILES"
);
assert!(
out.contains("omitted"),
"dropped files became elision markers"
);
}
#[test]
fn dropped_file_marker_reports_real_line_count() {
let mut sections = vec![format!(
"diff --git a/hot.rs b/hot.rs\n--- a/hot.rs\n+++ b/hot.rs\n@@ -1,6 +1,6 @@\n{}",
(0..6)
.map(|i| format!("-old{i}\n+new{i}"))
.collect::<Vec<_>>()
.join("\n")
)];
for i in 0..MAX_FILES + 5 {
sections.push(format!(
"diff --git a/c{i}.rs b/c{i}.rs\n--- a/c{i}.rs\n+++ b/c{i}.rs\n@@ -1 +1 @@\n-a\n+b"
));
}
let diff = sections.join("\n");
let out = compress(&diff, &test_ctx(), &HashSet::new()).expect("compresses");
assert!(
out.contains("[… 6 lines omitted …]"),
"dropped file reports its 6 lines: {out}"
);
assert!(
!out.contains("[… 1 lines omitted …]"),
"no bogus 1-line marker"
);
}
#[test]
fn removed_sql_comment_line_is_not_a_file_header() {
let diff = "--- a/schema.sql\n\
+++ b/schema.sql\n\
@@ -1,4 +1,4 @@\n\
CREATE TABLE t (\n\
--- drop the column\n\
+-- keep the column\n\
id INT\n\
);";
let files = parse(diff);
assert_eq!(
files.len(),
1,
"one file, the SQL comment did not start a second"
);
let body = &files[0].hunks[0].body;
assert!(
body.contains(&"--- drop the column"),
"the removed `-- ` comment stayed in the hunk body: {body:?}"
);
}
#[test]
fn small_diff_is_left_alone() {
let diff = "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new";
assert_eq!(compress(diff, &test_ctx(), &HashSet::new()), None);
}
#[test]
fn aggressive_drops_all_context() {
let diff = padded_diff();
let ctx = Ctx {
max_lines: 30,
template: true,
mode: ModeSetting::Aggressive,
};
let out = compress(&diff, &ctx, &HashSet::new()).expect("compresses");
assert!(out.contains("-let removed = 0;"), "removal kept");
assert!(out.contains("+let added = 1;"), "addition kept");
assert!(out.contains("@@ -1,30 +1,30 @@"), "hunk header kept");
assert!(
!out.contains(" unchanged context line 0"),
"all context dropped"
);
}
}