1use std::collections::{HashMap, HashSet};
7
8use globset::{Glob, GlobSet, GlobSetBuilder};
9
10pub fn parse_valid_lines(diff: &str) -> HashMap<String, HashSet<u64>> {
21 let mut map: HashMap<String, HashSet<u64>> = HashMap::new();
22 for_each_new_side_line(diff, |path, new_line, _text| {
23 map.entry(path.to_string()).or_default().insert(new_line);
24 });
25 map
26}
27
28fn for_each_new_side_line(diff: &str, mut f: impl FnMut(&str, u64, &str)) {
34 let mut cur_path: Option<String> = None;
35 let mut new_line: u64 = 0;
36
37 for line in diff.lines() {
38 if let Some(rest) = line.strip_prefix("+++ ") {
39 let p = rest.trim();
40 let p = p.strip_prefix("b/").unwrap_or(p);
41 cur_path = (p != "/dev/null").then(|| p.to_string());
42 } else if line.starts_with("@@") {
43 if let Some(plus) = line.split('+').nth(1) {
45 let num: String = plus.chars().take_while(|c| c.is_ascii_digit()).collect();
46 new_line = num.parse().unwrap_or(0);
47 }
48 } else if let Some(path) = &cur_path {
49 if let Some(text) = line.strip_prefix('+').or_else(|| line.strip_prefix(' ')) {
50 f(path, new_line, text);
51 new_line += 1;
52 }
53 }
55 }
56}
57
58pub fn diff_line_texts(diff: &str) -> HashMap<String, HashMap<u64, String>> {
63 let mut map: HashMap<String, HashMap<u64, String>> = HashMap::new();
64 for_each_new_side_line(diff, |path, new_line, text| {
65 map.entry(path.to_string())
66 .or_default()
67 .insert(new_line, text.to_string());
68 });
69 map
70}
71
72fn build_globset(patterns: &[String]) -> Option<GlobSet> {
75 let mut builder = GlobSetBuilder::new();
76 for p in patterns {
77 builder.add(Glob::new(p).ok()?);
78 }
79 builder.build().ok()
80}
81
82fn git_header_path(line: &str) -> Option<String> {
84 let rest = line.strip_prefix("diff --git ")?;
85 let idx = rest.find(" b/")?;
86 let p = rest[idx + 3..].trim();
87 (!p.is_empty()).then(|| p.to_string())
88}
89
90fn section_path(section: &[&str], has_git: bool) -> Option<String> {
94 if has_git {
95 if let Some(p) = section.first().and_then(|l| git_header_path(l)) {
96 return Some(p);
97 }
98 }
99 for l in section {
100 if let Some(rest) = l.strip_prefix("+++ ") {
101 let p = rest.trim();
102 let p = p.strip_prefix("b/").unwrap_or(p);
103 return (p != "/dev/null").then(|| p.to_string());
104 }
105 }
106 None
107}
108
109pub fn split_diff_sections(diff: &str) -> Vec<(String, String)> {
133 let lines: Vec<&str> = diff.lines().collect();
134 let has_git = lines.iter().any(|l| l.starts_with("diff --git "));
135
136 let starts: Vec<usize> = if has_git {
139 lines
140 .iter()
141 .enumerate()
142 .filter(|(_, l)| l.starts_with("diff --git "))
143 .map(|(i, _)| i)
144 .collect()
145 } else {
146 let mut s = Vec::new();
147 for (i, l) in lines.iter().enumerate() {
148 if l.starts_with("+++ ") {
149 if i > 0 && lines[i - 1].starts_with("--- ") {
150 s.push(i - 1);
151 } else {
152 s.push(i);
153 }
154 }
155 }
156 s
157 };
158
159 if starts.is_empty() {
161 return vec![(String::new(), diff.to_string())];
162 }
163
164 let emit = |slice: &[&str]| -> String {
167 let mut t = slice.join("\n");
168 if !t.is_empty() {
169 t.push('\n');
170 }
171 t
172 };
173
174 let mut sections: Vec<(String, String)> = Vec::new();
175
176 if starts[0] > 0 {
178 sections.push((String::new(), emit(&lines[..starts[0]])));
179 }
180
181 for (idx, &start) in starts.iter().enumerate() {
182 let end = starts.get(idx + 1).copied().unwrap_or(lines.len());
183 let section = &lines[start..end];
184 let path = section_path(section, has_git).unwrap_or_default();
185 sections.push((path, emit(section)));
186 }
187
188 sections
189}
190
191fn file_priority(path: &str) -> u8 {
196 let p = path.to_ascii_lowercase();
197 if p.contains("test")
199 || p.contains("spec")
200 || p.contains("__tests__")
201 || p.contains(".snap")
202 || p.contains("fixtures")
203 {
204 return 1;
205 }
206 if p.ends_with(".md")
208 || p.ends_with(".txt")
209 || p.starts_with("docs/")
210 || p.contains("/docs/")
211 || p.ends_with(".lock")
212 || (p.ends_with(".json") && !p.contains('/'))
213 {
214 return 0;
215 }
216 2
218}
219
220pub fn pack_diff(diff: &str, max_chars: usize) -> (String, Vec<String>) {
240 pack_impl(diff, max_chars, false)
241}
242
243pub fn pack_diff_bundled(diff: &str, max_chars: usize) -> (String, Vec<String>) {
249 pack_impl(diff, max_chars, true)
250}
251
252fn is_locale(s: &str) -> bool {
255 matches!(
256 s,
257 "en" | "zh"
258 | "ja"
259 | "ko"
260 | "fr"
261 | "de"
262 | "es"
263 | "it"
264 | "pt"
265 | "ru"
266 | "vi"
267 | "th"
268 | "ar"
269 | "hi"
270 | "id"
271 | "nl"
272 | "pl"
273 | "tr"
274 | "uk"
275 | "cs"
276 | "sv"
277 | "da"
278 | "fi"
279 | "no"
280 | "ro"
281 | "hu"
282 | "el"
283 )
284}
285
286fn bundle_key(path: &str) -> String {
292 let (dir, file) = match path.rfind('/') {
293 Some(i) => (&path[..i], &path[i + 1..]),
294 None => ("", path),
295 };
296 let mut f = file.to_ascii_lowercase();
297 if let Some(i) = f.rfind('.') {
299 if i > 0 {
300 f.truncate(i);
301 }
302 }
303 for suf in ["-test", "-spec", "_test", "_spec", ".test", ".spec"] {
305 if let Some(s) = f.strip_suffix(suf) {
306 let n = s.len();
307 f.truncate(n);
308 break;
309 }
310 }
311 if let Some(s) = f.strip_prefix("test_") {
313 f = s.to_string();
314 }
315 if let Some(i) = f.rfind(['.', '_']) {
317 if is_locale(&f[i + 1..]) {
318 f.truncate(i);
319 }
320 }
321 format!("{dir}/{f}")
322}
323
324fn pack_impl(diff: &str, max_chars: usize, bundle: bool) -> (String, Vec<String>) {
329 if diff.chars().count() <= max_chars {
330 return (diff.to_string(), Vec::new());
331 }
332
333 let sections = split_diff_sections(diff);
334
335 let units: Vec<Vec<usize>> = if bundle {
337 let mut keys: Vec<String> = Vec::new();
338 let mut groups: Vec<Vec<usize>> = Vec::new();
339 for (i, (path, _)) in sections.iter().enumerate() {
340 let key = if path.is_empty() {
342 format!("\0{i}")
343 } else {
344 bundle_key(path)
345 };
346 match keys.iter().position(|k| k == &key) {
347 Some(p) => groups[p].push(i),
348 None => {
349 keys.push(key);
350 groups.push(vec![i]);
351 }
352 }
353 }
354 groups
355 } else {
356 (0..sections.len()).map(|i| vec![i]).collect()
357 };
358
359 let len_of = |i: usize| sections[i].1.chars().count();
360 let unit_len = |u: &[usize]| -> usize { u.iter().map(|&i| len_of(i)).sum() };
361 let unit_priority = |u: &[usize]| -> u8 {
362 u.iter()
363 .map(|&i| file_priority(§ions[i].0))
364 .max()
365 .unwrap_or(0)
366 };
367
368 let mut order: Vec<usize> = (0..units.len()).collect();
370 order.sort_by(|&a, &b| {
371 unit_priority(&units[b])
372 .cmp(&unit_priority(&units[a]))
373 .then_with(|| unit_len(&units[a]).cmp(&unit_len(&units[b])))
374 });
375
376 let mut keep = vec![false; sections.len()];
379 let mut used = 0usize;
380 for &ui in &order {
381 let u = &units[ui];
382 let ul = unit_len(u);
383 if used + ul <= max_chars {
384 for &i in u {
385 keep[i] = true;
386 }
387 used += ul;
388 } else if u.len() > 1 {
389 let mut members = u.clone();
390 members.sort_by(|&a, &b| {
391 file_priority(§ions[b].0)
392 .cmp(&file_priority(§ions[a].0))
393 .then_with(|| len_of(a).cmp(&len_of(b)))
394 });
395 for &i in &members {
396 if used + len_of(i) <= max_chars {
397 keep[i] = true;
398 used += len_of(i);
399 }
400 }
401 }
402 }
403
404 if !keep.iter().any(|&k| k) {
406 if let Some(&i) = order.first().and_then(|&ui| units[ui].first()) {
407 keep[i] = true;
408 }
409 }
410
411 let mut unit_order: Vec<usize> = (0..units.len()).collect();
415 unit_order.sort_by_key(|&ui| *units[ui].iter().min().unwrap());
416
417 let mut out = String::new();
418 let mut dropped: Vec<String> = Vec::new();
419 for &ui in &unit_order {
420 for &i in &units[ui] {
421 let (path, text) = §ions[i];
422 if keep[i] {
423 out.push_str(text);
424 } else if !path.is_empty() {
425 dropped.push(path.clone());
426 }
427 }
428 }
429
430 (out, dropped)
431}
432
433pub fn filter_diff_by_globs(
455 diff: &str,
456 include: &[String],
457 exclude: &[String],
458) -> (String, Vec<String>) {
459 let (include_set, exclude_set) = match (build_globset(include), build_globset(exclude)) {
461 (Some(i), Some(e)) => (i, e),
462 _ => return (diff.to_string(), Vec::new()),
463 };
464
465 let sections = split_diff_sections(diff);
466
467 let mut out = String::new();
468 let mut dropped: Vec<String> = Vec::new();
469 for (path, text) in §ions {
470 let keep = path.is_empty()
472 || ((include.is_empty() || include_set.is_match(path)) && !exclude_set.is_match(path));
473 if keep {
474 out.push_str(text);
475 } else {
476 dropped.push(path.clone());
477 }
478 }
479
480 if dropped.is_empty() {
482 return (diff.to_string(), Vec::new());
483 }
484
485 (out, dropped)
486}
487
488#[must_use]
493pub fn path_matches_globs(path: &str, include: &[String], exclude: &[String]) -> bool {
494 let (include_set, exclude_set) = match (build_globset(include), build_globset(exclude)) {
495 (Some(i), Some(e)) => (i, e),
496 _ => return true,
497 };
498 (include.is_empty() || include_set.is_match(path)) && !exclude_set.is_match(path)
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct HygieneIssue {
507 pub file: String,
509 pub severity: &'static str,
511 pub body: String,
513}
514
515const LARGE_ADDED_LINES: usize = 1000;
518
519fn is_routine_asset(path: &str) -> bool {
526 let p = path.to_ascii_lowercase();
527 const ASSET_EXT: &[&str] = &[
528 ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".avif", ".bmp", ".tiff", ".woff",
529 ".woff2", ".ttf", ".otf", ".eot",
530 ];
531 ASSET_EXT.iter().any(|e| p.ends_with(e))
532}
533
534pub const DEFAULT_VENDORED_DIRS: &[&str] = &[
545 "thirdparty",
546 "third_party",
547 "vendor",
548 "vendored",
549 "external",
550 "node_modules",
551];
552
553#[must_use]
560pub fn is_vendored(path: &str, vendored_globs: &[String]) -> bool {
561 if vendored_globs.is_empty() {
562 return path
563 .split('/')
564 .any(|seg| DEFAULT_VENDORED_DIRS.contains(&seg));
565 }
566 build_globset(vendored_globs).is_some_and(|set| set.is_match(path))
567}
568
569#[must_use]
576pub fn diff_hygiene(diff: &str) -> Vec<HygieneIssue> {
577 diff_hygiene_with(diff, &[])
578}
579
580#[must_use]
591pub fn diff_hygiene_with(diff: &str, vendored_globs: &[String]) -> Vec<HygieneIssue> {
592 let mut issues = Vec::new();
593 for (path, section) in split_diff_sections(diff) {
594 if path.is_empty() {
595 continue; }
597 if is_vendored(&path, vendored_globs) {
598 continue; }
600 if !section.lines().any(|l| l.starts_with("new file mode")) {
602 continue;
603 }
604 if section.lines().any(|l| l.starts_with("Binary files ")) {
605 if !is_routine_asset(&path) {
609 issues.push(HygieneIssue {
610 file: path.clone(),
611 severity: "MEDIUM",
612 body: format!(
613 "A binary file `{path}` was added in this change — binaries bloat the repo permanently and are invisible in a normal diff view. Fix: drop it from the commit (`.gitignore`, Git LFS, or a release asset) unless it is intentional."
614 ),
615 });
616 }
617 continue;
618 }
619 let added = section
620 .lines()
621 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
622 .count();
623 if added >= LARGE_ADDED_LINES {
624 issues.push(HygieneIssue {
625 file: path.clone(),
626 severity: "LOW",
627 body: format!(
628 "`{path}` adds {added} lines in a single new file. If this is generated or vendored output it shouldn't be committed. Fix: confirm it's hand-written; otherwise `.gitignore` or exclude it."
629 ),
630 });
631 }
632 }
633 issues
634}
635
636#[cfg(test)]
637mod tests {
638 use super::{
639 bundle_key, diff_hygiene, diff_line_texts, filter_diff_by_globs, is_vendored, pack_diff,
640 pack_diff_bundled, parse_valid_lines, path_matches_globs, split_diff_sections,
641 };
642
643 #[test]
644 fn hygiene_flags_an_added_binary_file() {
645 let diff = "diff --git a/assets/ime.zip b/assets/ime.zip\n\
646 new file mode 100644\n\
647 index 0000000..abc1234\n\
648 Binary files /dev/null and b/assets/ime.zip differ\n";
649 let issues = diff_hygiene(diff);
650 assert_eq!(issues.len(), 1);
651 assert_eq!(issues[0].file, "assets/ime.zip");
652 assert_eq!(issues[0].severity, "MEDIUM");
653 assert!(issues[0].body.contains("binary"));
654 }
655
656 #[test]
657 fn hygiene_ignores_a_routine_image_asset() {
658 let diff = "diff --git a/apps/web/public/assistant/be-na.webp b/apps/web/public/assistant/be-na.webp\n\
661 new file mode 100644\n\
662 index 0000000..abc1234\n\
663 Binary files /dev/null and b/apps/web/public/assistant/be-na.webp differ\n";
664 assert!(diff_hygiene(diff).is_empty());
665 let zip = "diff --git a/apps/web/public/data.zip b/apps/web/public/data.zip\n\
667 new file mode 100644\nBinary files /dev/null and b/apps/web/public/data.zip differ\n";
668 assert_eq!(diff_hygiene(zip).len(), 1);
669 }
670
671 #[test]
672 fn hygiene_ignores_a_normal_added_source_file() {
673 let diff = "diff --git a/src/a.rs b/src/a.rs\n\
674 new file mode 100644\n\
675 --- /dev/null\n+++ b/src/a.rs\n@@ -0,0 +1,2 @@\n+fn a() {}\n+// ok\n";
676 assert!(diff_hygiene(diff).is_empty());
677 }
678
679 #[test]
680 fn hygiene_ignores_a_modified_binary() {
681 let diff = "diff --git a/logo.png b/logo.png\n\
683 index 111..222 100644\n\
684 Binary files a/logo.png and b/logo.png differ\n";
685 assert!(diff_hygiene(diff).is_empty());
686 }
687
688 #[test]
689 fn hygiene_flags_a_large_added_file() {
690 let mut section = String::from(
691 "diff --git a/gen/bundle.js b/gen/bundle.js\n\
692 new file mode 100644\n--- /dev/null\n+++ b/gen/bundle.js\n@@ -0,0 +1,1500 @@\n",
693 );
694 for i in 0..1500 {
695 section.push_str(&format!("+line{i}\n"));
696 }
697 let issues = diff_hygiene(§ion);
698 assert_eq!(issues.len(), 1);
699 assert_eq!(issues[0].severity, "LOW");
700 assert!(issues[0].body.contains("1500 lines"));
701 }
702
703 #[test]
709 fn hygiene_skips_vendored_paths() {
710 let mut section = String::from(
711 "diff --git a/thirdparty/lexilla/lexers/LexRuby.cxx b/thirdparty/lexilla/lexers/LexRuby.cxx\n\
712 new file mode 100644\n--- /dev/null\n+++ b/thirdparty/lexilla/lexers/LexRuby.cxx\n@@ -0,0 +1,2192 @@\n",
713 );
714 for i in 0..2192 {
715 section.push_str(&format!("+line{i}\n"));
716 }
717 assert!(diff_hygiene(§ion).is_empty());
718
719 let bin = "diff --git a/vendor/libs/x.zip b/vendor/libs/x.zip\n\
721 new file mode 100644\nBinary files /dev/null and b/vendor/libs/x.zip differ\n";
722 assert!(diff_hygiene(bin).is_empty());
723
724 let bin_outside = "diff --git a/assets/x.zip b/assets/x.zip\n\
726 new file mode 100644\nBinary files /dev/null and b/assets/x.zip differ\n";
727 assert_eq!(diff_hygiene(bin_outside).len(), 1);
728 }
729
730 #[test]
731 fn vendored_dirs_match_at_any_depth_and_only_whole_segments() {
732 assert!(is_vendored("thirdparty/lexilla/LexD.cxx", &[]));
733 assert!(is_vendored("frontend/node_modules/react/index.js", &[]));
734 assert!(is_vendored("vendor/x.go", &[]));
735 assert!(!is_vendored("src/vendor_client.rs", &[]));
737 assert!(!is_vendored("src/thirdparty.rs", &[]));
738 assert!(!is_vendored("src/lib.rs", &[]));
739 }
740
741 #[test]
742 fn configured_vendored_globs_replace_the_defaults() {
743 let globs = vec!["libs/upstream/**".to_string()];
744 assert!(is_vendored("libs/upstream/a.c", &globs));
745 assert!(!is_vendored("thirdparty/a.c", &globs));
747 assert!(!is_vendored("thirdparty/a.c", &["[".to_string()]));
749 }
750
751 #[test]
752 fn path_matches_globs_respects_include_and_exclude() {
753 assert!(path_matches_globs("src/lib.rs", &[], &[]));
755 assert!(!path_matches_globs(
757 "secrets/prod.env",
758 &[],
759 &["secrets/**".to_string()]
760 ));
761 assert!(path_matches_globs("src/a.rs", &["src/**".to_string()], &[]));
763 assert!(!path_matches_globs(
764 "docs/x.md",
765 &["src/**".to_string()],
766 &[]
767 ));
768 assert!(!path_matches_globs(
770 "src/gen.rs",
771 &["src/**".to_string()],
772 &["**/gen.rs".to_string()]
773 ));
774 assert!(path_matches_globs("anything", &["[".to_string()], &[]));
776 }
777
778 fn section(path: &str, body: &str) -> String {
780 format!("diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n+{body}\n")
781 }
782
783 #[test]
784 fn bundle_key_groups_source_test_spec_and_i18n() {
785 let k = bundle_key("src/foo.ts");
787 assert_eq!(bundle_key("src/foo.test.ts"), k);
788 assert_eq!(bundle_key("src/foo.spec.tsx"), k);
789 assert_eq!(bundle_key("pkg/user_test.go"), bundle_key("pkg/user.go"));
791 assert_eq!(bundle_key("app/test_users.py"), bundle_key("app/users.py"));
792 let m = bundle_key("i18n/msg.en.json");
794 assert_eq!(bundle_key("i18n/msg.zh.json"), m);
795 assert_ne!(bundle_key("src/foo.ts"), bundle_key("src/bar.ts"));
797 assert_ne!(bundle_key("src/util.io.ts"), bundle_key("src/util.ts"));
798 }
799
800 #[test]
801 fn bundled_pack_keeps_source_and_test_adjacent() {
802 let foo = section("src/foo.ts", "aa");
804 let other = section("src/other.ts", &"x".repeat(400));
805 let foot = section("src/foo.test.ts", "bb");
806 let diff = format!("{foo}{other}{foot}");
807
808 let budget = foo.chars().count() + foot.chars().count() + 5;
810 let (packed, dropped) = pack_diff_bundled(&diff, budget);
811
812 assert_eq!(
813 dropped,
814 vec!["src/other.ts".to_string()],
815 "big unrelated file dropped"
816 );
817 let s = packed.find("src/foo.ts").unwrap();
819 let t = packed.find("src/foo.test.ts").unwrap();
820 assert!(s < t, "source before its test");
821 assert!(!packed.contains("src/other.ts"), "other not emitted");
822 }
823
824 #[test]
825 fn unbundled_pack_matches_historical_order() {
826 let a = section("src/a.ts", "aa");
828 let big = section("src/big.ts", &"y".repeat(400));
829 let diff = format!("{a}{big}");
830 let (packed, dropped) = pack_diff(&diff, a.chars().count() + 5);
831 assert!(packed.contains("src/a.ts"));
832 assert_eq!(dropped, vec!["src/big.ts".to_string()]);
833 }
834
835 #[test]
836 fn diff_line_texts_captures_new_side_content() {
837 let d = "+++ b/a.ts\n@@ -1,2 +1,3 @@\n ctx\n+added line\n ctx2\n";
838 let m = &diff_line_texts(d)["a.ts"];
839 assert_eq!(m[&1], "ctx");
840 assert_eq!(m[&2], "added line");
841 assert_eq!(m[&3], "ctx2");
842 }
843
844 #[test]
845 fn anchors_added_and_context_lines() {
846 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";
847 let m = parse_valid_lines(d);
848 let s = &m["a.rs"];
849 assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(s.contains(&3)); }
853
854 #[test]
855 fn removed_lines_do_not_advance_new_side() {
856 let d = "+++ b/x.rs\n@@ -1,3 +1,2 @@\n keep1\n-removed\n keep2\n";
857 let m = parse_valid_lines(d);
858 let s = &m["x.rs"];
859 assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(!s.contains(&3));
862 }
863
864 #[test]
865 fn handles_multiple_files() {
866 let d = "+++ b/a.rs\n@@ -0,0 +1 @@\n+one\n+++ b/b.rs\n@@ -0,0 +1 @@\n+two\n";
867 let m = parse_valid_lines(d);
868 assert!(m["a.rs"].contains(&1));
869 assert!(m["b.rs"].contains(&1));
870 }
871
872 #[test]
873 fn skips_dev_null_deletions() {
874 let d = "+++ /dev/null\n@@ -1,1 +0,0 @@\n-gone\n";
875 let m = parse_valid_lines(d);
876 assert!(m.is_empty());
877 }
878
879 #[test]
880 fn drops_lockfile_section_keeps_source_section() {
881 let d = "diff --git a/package-lock.json b/package-lock.json\n\
882 index abc..def 100644\n\
883 --- a/package-lock.json\n\
884 +++ b/package-lock.json\n\
885 @@ -1 +1 @@\n\
886 -old\n\
887 +new\n\
888 diff --git a/src/x.ts b/src/x.ts\n\
889 index 111..222 100644\n\
890 --- a/src/x.ts\n\
891 +++ b/src/x.ts\n\
892 @@ -1 +1 @@\n\
893 -a\n\
894 +b\n";
895 let exclude = vec!["**/package-lock.json".to_string()];
896 let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
897 assert_eq!(dropped, vec!["package-lock.json".to_string()]);
898 assert!(kept.contains("src/x.ts"));
899 assert!(!kept.contains("package-lock.json"));
900 }
901
902 #[test]
903 fn fallback_splits_on_plusplusplus_when_no_git_headers() {
904 let d = "--- a/foo.js\n\
905 +++ b/foo.js\n\
906 @@ -1 +1 @@\n\
907 -x\n\
908 +y\n\
909 --- a/bar.min.js\n\
910 +++ b/bar.min.js\n\
911 @@ -1 +1 @@\n\
912 -a\n\
913 +b\n";
914 let exclude = vec!["**/*.min.js".to_string()];
915 let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
916 assert_eq!(dropped, vec!["bar.min.js".to_string()]);
917 assert!(kept.contains("foo.js"));
918 assert!(!kept.contains("bar.min.js"));
919 }
920
921 #[test]
922 fn split_sections_roundtrips_multi_file_diff() {
923 let d = "diff --git a/src/a.rs b/src/a.rs\n\
924 +++ b/src/a.rs\n\
925 @@ -1 +1 @@\n\
926 +a\n\
927 diff --git a/README.md b/README.md\n\
928 +++ b/README.md\n\
929 @@ -1 +1 @@\n\
930 +docs\n";
931 let secs = split_diff_sections(d);
932 assert_eq!(secs.len(), 2);
933 assert_eq!(secs[0].0, "src/a.rs");
934 assert_eq!(secs[1].0, "README.md");
935 let joined: String = secs.iter().map(|(_, t)| t.as_str()).collect();
937 assert_eq!(joined, d);
938 }
939
940 #[test]
941 fn pack_under_budget_returns_unchanged() {
942 let d = "diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+a\n";
943 let (packed, dropped) = pack_diff(d, 10_000);
944 assert_eq!(packed, d);
945 assert!(dropped.is_empty());
946 }
947
948 #[test]
949 fn pack_drops_low_priority_large_file_keeps_small_source() {
950 let big_docs = "x".repeat(500);
953 let d = format!(
954 "diff --git a/README.md b/README.md\n\
955 +++ b/README.md\n\
956 @@ -1 +1 @@\n\
957 +{big_docs}\n\
958 diff --git a/src/a.rs b/src/a.rs\n\
959 +++ b/src/a.rs\n\
960 @@ -1 +1 @@\n\
961 +small\n"
962 );
963 let (packed, dropped) = pack_diff(&d, 120);
964 assert_eq!(dropped, vec!["README.md".to_string()]);
965 assert!(packed.contains("src/a.rs"));
966 assert!(!packed.contains("README.md"));
967 }
968
969 #[test]
970 fn pack_preserves_original_file_order() {
971 let d = "diff --git a/src/a.rs b/src/a.rs\n\
975 +++ b/src/a.rs\n\
976 @@ -1 +1 @@\n\
977 +alpha\n\
978 diff --git a/src/a.spec.rs b/src/a.spec.rs\n\
979 +++ b/src/a.spec.rs\n\
980 @@ -1 +1 @@\n\
981 +beta\n\
982 diff --git a/README.md b/README.md\n\
983 +++ b/README.md\n\
984 @@ -1 +1 @@\n\
985 +gammagammagammagammagammagamma\n";
986 let (packed, dropped) = pack_diff(d, 160);
987 assert_eq!(dropped, vec!["README.md".to_string()]);
988 let a = packed.find("src/a.rs").expect("source kept");
989 let b = packed.find("src/a.spec.rs").expect("spec kept");
990 assert!(a < b, "kept sections should be in original diff order");
991 }
992
993 #[test]
994 fn pack_keeps_single_oversized_file_rather_than_empty() {
995 let big = "y".repeat(400);
998 let d = format!("diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+{big}\n");
999 let (packed, dropped) = pack_diff(&d, 50);
1000 assert!(!packed.is_empty());
1001 assert!(packed.contains("src/a.rs"));
1002 assert!(dropped.is_empty());
1003 }
1004}