use std::collections::{HashMap, HashSet};
use globset::{Glob, GlobSet, GlobSetBuilder};
pub fn parse_valid_lines(diff: &str) -> HashMap<String, HashSet<u64>> {
let mut map: HashMap<String, HashSet<u64>> = HashMap::new();
let mut cur_path: Option<String> = None;
let mut new_line: u64 = 0;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
let p = rest.trim();
let p = p.strip_prefix("b/").unwrap_or(p);
cur_path = if p == "/dev/null" {
None
} else {
Some(p.to_string())
};
} else if line.starts_with("@@") {
if let Some(plus) = line.split('+').nth(1) {
let num: String = plus.chars().take_while(|c| c.is_ascii_digit()).collect();
new_line = num.parse().unwrap_or(0);
}
} else if let Some(path) = &cur_path {
match line.chars().next() {
Some('+') => {
map.entry(path.clone()).or_default().insert(new_line);
new_line += 1;
}
Some(' ') => {
map.entry(path.clone()).or_default().insert(new_line);
new_line += 1;
}
_ => {}
}
}
}
map
}
fn build_globset(patterns: &[String]) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
for p in patterns {
builder.add(Glob::new(p).ok()?);
}
builder.build().ok()
}
fn git_header_path(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let idx = rest.find(" b/")?;
let p = rest[idx + 3..].trim();
(!p.is_empty()).then(|| p.to_string())
}
fn section_path(section: &[&str], has_git: bool) -> Option<String> {
if has_git {
if let Some(p) = section.first().and_then(|l| git_header_path(l)) {
return Some(p);
}
}
for l in section {
if let Some(rest) = l.strip_prefix("+++ ") {
let p = rest.trim();
let p = p.strip_prefix("b/").unwrap_or(p);
return (p != "/dev/null").then(|| p.to_string());
}
}
None
}
pub fn split_diff_sections(diff: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = diff.lines().collect();
let has_git = lines.iter().any(|l| l.starts_with("diff --git "));
let starts: Vec<usize> = if has_git {
lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("diff --git "))
.map(|(i, _)| i)
.collect()
} else {
let mut s = Vec::new();
for (i, l) in lines.iter().enumerate() {
if l.starts_with("+++ ") {
if i > 0 && lines[i - 1].starts_with("--- ") {
s.push(i - 1);
} else {
s.push(i);
}
}
}
s
};
if starts.is_empty() {
return vec![(String::new(), diff.to_string())];
}
let emit = |slice: &[&str]| -> String {
let mut t = slice.join("\n");
if !t.is_empty() {
t.push('\n');
}
t
};
let mut sections: Vec<(String, String)> = Vec::new();
if starts[0] > 0 {
sections.push((String::new(), emit(&lines[..starts[0]])));
}
for (idx, &start) in starts.iter().enumerate() {
let end = starts.get(idx + 1).copied().unwrap_or(lines.len());
let section = &lines[start..end];
let path = section_path(section, has_git).unwrap_or_default();
sections.push((path, emit(section)));
}
sections
}
fn file_priority(path: &str) -> u8 {
let p = path.to_ascii_lowercase();
if p.contains("test")
|| p.contains("spec")
|| p.contains("__tests__")
|| p.contains(".snap")
|| p.contains("fixtures")
{
return 1;
}
if p.ends_with(".md")
|| p.ends_with(".txt")
|| p.starts_with("docs/")
|| p.contains("/docs/")
|| p.ends_with(".lock")
|| (p.ends_with(".json") && !p.contains('/'))
{
return 0;
}
2
}
pub fn pack_diff(diff: &str, max_chars: usize) -> (String, Vec<String>) {
if diff.chars().count() <= max_chars {
return (diff.to_string(), Vec::new());
}
let sections = split_diff_sections(diff);
let mut order: Vec<usize> = (0..sections.len()).collect();
order.sort_by(|&a, &b| {
let (pa, pb) = (file_priority(§ions[a].0), file_priority(§ions[b].0));
pb.cmp(&pa).then_with(|| {
sections[a]
.1
.chars()
.count()
.cmp(§ions[b].1.chars().count())
})
});
let mut keep = vec![false; sections.len()];
let mut used = 0usize;
for &i in &order {
let len = sections[i].1.chars().count();
if used + len <= max_chars {
keep[i] = true;
used += len;
}
}
if !keep.iter().any(|&k| k) {
if let Some(&best) = order.first() {
keep[best] = true;
}
}
let mut out = String::new();
let mut dropped: Vec<String> = Vec::new();
for (i, (path, text)) in sections.iter().enumerate() {
if keep[i] {
out.push_str(text);
} else if !path.is_empty() {
dropped.push(path.clone());
}
}
(out, dropped)
}
pub fn filter_diff_by_globs(
diff: &str,
include: &[String],
exclude: &[String],
) -> (String, Vec<String>) {
let (include_set, exclude_set) = match (build_globset(include), build_globset(exclude)) {
(Some(i), Some(e)) => (i, e),
_ => return (diff.to_string(), Vec::new()),
};
let sections = split_diff_sections(diff);
let mut out = String::new();
let mut dropped: Vec<String> = Vec::new();
for (path, text) in §ions {
let keep = path.is_empty()
|| ((include.is_empty() || include_set.is_match(path)) && !exclude_set.is_match(path));
if keep {
out.push_str(text);
} else {
dropped.push(path.clone());
}
}
if dropped.is_empty() {
return (diff.to_string(), Vec::new());
}
(out, dropped)
}
#[cfg(test)]
mod tests {
use super::{filter_diff_by_globs, pack_diff, parse_valid_lines, split_diff_sections};
#[test]
fn anchors_added_and_context_lines() {
let d = "diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\n@@ -1,2 +1,3 @@\n ctx1\n+added\n ctx2\n";
let m = parse_valid_lines(d);
let s = &m["a.rs"];
assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(s.contains(&3)); }
#[test]
fn removed_lines_do_not_advance_new_side() {
let d = "+++ b/x.rs\n@@ -1,3 +1,2 @@\n keep1\n-removed\n keep2\n";
let m = parse_valid_lines(d);
let s = &m["x.rs"];
assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(!s.contains(&3));
}
#[test]
fn handles_multiple_files() {
let d = "+++ b/a.rs\n@@ -0,0 +1 @@\n+one\n+++ b/b.rs\n@@ -0,0 +1 @@\n+two\n";
let m = parse_valid_lines(d);
assert!(m["a.rs"].contains(&1));
assert!(m["b.rs"].contains(&1));
}
#[test]
fn skips_dev_null_deletions() {
let d = "+++ /dev/null\n@@ -1,1 +0,0 @@\n-gone\n";
let m = parse_valid_lines(d);
assert!(m.is_empty());
}
#[test]
fn drops_lockfile_section_keeps_source_section() {
let d = "diff --git a/package-lock.json b/package-lock.json\n\
index abc..def 100644\n\
--- a/package-lock.json\n\
+++ b/package-lock.json\n\
@@ -1 +1 @@\n\
-old\n\
+new\n\
diff --git a/src/x.ts b/src/x.ts\n\
index 111..222 100644\n\
--- a/src/x.ts\n\
+++ b/src/x.ts\n\
@@ -1 +1 @@\n\
-a\n\
+b\n";
let exclude = vec!["**/package-lock.json".to_string()];
let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
assert_eq!(dropped, vec!["package-lock.json".to_string()]);
assert!(kept.contains("src/x.ts"));
assert!(!kept.contains("package-lock.json"));
}
#[test]
fn fallback_splits_on_plusplusplus_when_no_git_headers() {
let d = "--- a/foo.js\n\
+++ b/foo.js\n\
@@ -1 +1 @@\n\
-x\n\
+y\n\
--- a/bar.min.js\n\
+++ b/bar.min.js\n\
@@ -1 +1 @@\n\
-a\n\
+b\n";
let exclude = vec!["**/*.min.js".to_string()];
let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
assert_eq!(dropped, vec!["bar.min.js".to_string()]);
assert!(kept.contains("foo.js"));
assert!(!kept.contains("bar.min.js"));
}
#[test]
fn split_sections_roundtrips_multi_file_diff() {
let d = "diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+a\n\
diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+docs\n";
let secs = split_diff_sections(d);
assert_eq!(secs.len(), 2);
assert_eq!(secs[0].0, "src/a.rs");
assert_eq!(secs[1].0, "README.md");
let joined: String = secs.iter().map(|(_, t)| t.as_str()).collect();
assert_eq!(joined, d);
}
#[test]
fn pack_under_budget_returns_unchanged() {
let d = "diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+a\n";
let (packed, dropped) = pack_diff(d, 10_000);
assert_eq!(packed, d);
assert!(dropped.is_empty());
}
#[test]
fn pack_drops_low_priority_large_file_keeps_small_source() {
let big_docs = "x".repeat(500);
let d = format!(
"diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+{big_docs}\n\
diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+small\n"
);
let (packed, dropped) = pack_diff(&d, 120);
assert_eq!(dropped, vec!["README.md".to_string()]);
assert!(packed.contains("src/a.rs"));
assert!(!packed.contains("README.md"));
}
#[test]
fn pack_preserves_original_file_order() {
let d = "diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+alpha\n\
diff --git a/src/a.spec.rs b/src/a.spec.rs\n\
+++ b/src/a.spec.rs\n\
@@ -1 +1 @@\n\
+beta\n\
diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+gammagammagammagammagammagamma\n";
let (packed, dropped) = pack_diff(d, 160);
assert_eq!(dropped, vec!["README.md".to_string()]);
let a = packed.find("src/a.rs").expect("source kept");
let b = packed.find("src/a.spec.rs").expect("spec kept");
assert!(a < b, "kept sections should be in original diff order");
}
#[test]
fn pack_keeps_single_oversized_file_rather_than_empty() {
let big = "y".repeat(400);
let d = format!("diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+{big}\n");
let (packed, dropped) = pack_diff(&d, 50);
assert!(!packed.is_empty());
assert!(packed.contains("src/a.rs"));
assert!(dropped.is_empty());
}
}