Skip to main content

pr_review_core/
diff.rs

1//! Minimal unified-diff parser. Produces the set of line numbers (on the new
2//! side of each file) that appear in the diff, so we only anchor inline comments
3//! to lines the provider will accept — anchoring outside the diff is rejected
4//! (GitHub 422 / Bitbucket 400).
5
6use std::collections::{HashMap, HashSet};
7
8use globset::{Glob, GlobSet, GlobSetBuilder};
9
10/// Map each new-side file path to the set of line numbers present in the diff
11/// (added or context lines). Removed lines don't advance the new-side counter.
12///
13/// # Examples
14/// ```
15/// # use pr_review_core::diff::parse_valid_lines;
16/// let d = "+++ b/a.rs\n@@ -1,2 +1,3 @@\n ctx\n+added\n ctx2\n";
17/// let m = parse_valid_lines(d);
18/// assert!(m["a.rs"].contains(&2)); // the "+added" line is line 2 on the new side
19/// ```
20pub 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
28/// Walk a unified diff's new side, invoking `f(path, line_number, text)` for each
29/// added or context line (the leading `+`/space stripped), in order. This is the
30/// single place the `+++`/`@@`/line-marker state machine lives, so
31/// [`parse_valid_lines`] and [`diff_line_texts`] can't drift apart. Removed (`-`)
32/// lines don't advance the new-side counter; `/dev/null` targets are skipped.
33fn 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            // @@ -old,n +new,m @@  — grab the start of the new-side range.
44            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            // '-' removed line: new side doesn't advance. Other markers ignored.
54        }
55    }
56}
57
58/// Map each new-side file path to `{ line number -> line text }` (added or context
59/// lines, the leading `+`/space stripped), for the new version of the file. The
60/// content companion to [`parse_valid_lines`], used to confirm a re-anchor by
61/// matching a finding to what's actually on a nearby diff line.
62pub 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
72/// Build a [`GlobSet`] from patterns, returning `None` if any pattern is invalid
73/// (so callers can fail-open and skip filtering rather than lose the review).
74fn 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
82/// Extract the new-side (`b/`) path from a `diff --git a/PATH b/PATH` header line.
83fn 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
90/// Derive a section's new-side path, preferring the `diff --git` header (when the
91/// diff has git headers) and falling back to the `+++ b/PATH` line. Returns `None`
92/// for `/dev/null` (deletions) or when no path can be found.
93fn 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
109/// Split a unified diff into per-file sections, returning `(path, section_text)`
110/// for each. Sections start at `diff --git ` lines (primary) or, when the diff
111/// carries no git headers, at the `--- ` line preceding each `+++ ` marker (or
112/// the `+++ ` line itself). This is the same splitting the glob filter and the
113/// size packer both build on.
114///
115/// The derived `path` is the new-side (`b/`) path; it is the empty string for a
116/// section whose path can't be determined (a `/dev/null` deletion or a leading
117/// preamble). `section_text` reproduces the section's lines with a trailing
118/// newline, so concatenating every section reconstructs the diff.
119///
120/// If the diff can't be parsed into any section, a single entry
121/// `("".to_string(), diff.to_string())` is returned so callers degrade
122/// gracefully rather than losing the diff.
123///
124/// # Examples
125/// ```
126/// # use pr_review_core::diff::split_diff_sections;
127/// let d = "diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+y\n";
128/// let secs = split_diff_sections(d);
129/// assert_eq!(secs.len(), 1);
130/// assert_eq!(secs[0].0, "src/a.rs");
131/// ```
132pub 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    // Section start indices: `diff --git` lines, or (fallback) the `--- ` line
137    // preceding each `+++ ` marker — or the `+++ ` line itself if none precedes.
138    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    // Nothing parseable -> single fallback entry so callers never lose the diff.
160    if starts.is_empty() {
161        return vec![(String::new(), diff.to_string())];
162    }
163
164    // Reproduce a slice of lines as text with a trailing newline; concatenating
165    // every section's text reconstructs the original diff.
166    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    // Any preamble before the first section is carried as an empty-path section.
177    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
191/// Priority score for a file path, used to decide which whole files to keep when
192/// a diff is too large: source code = 2, tests/specs/fixtures/snapshots = 1,
193/// docs/config/other = 0. An empty (preamble/unknown) path scores 2 so it's
194/// preserved. Deliberately a simple, documented heuristic.
195fn file_priority(path: &str) -> u8 {
196    let p = path.to_ascii_lowercase();
197    // Tests, specs, fixtures, snapshots — useful context but lower value.
198    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    // Docs, config, and other low-signal files.
207    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    // Everything else (including source and unknown/preamble) is highest value.
217    2
218}
219
220/// Fit `diff` within `max_chars` by keeping whole file sections, dropping the
221/// lowest-priority files first. Returns `(packed_diff, dropped_paths)`. If the
222/// diff already fits, returns it unchanged with an empty dropped list.
223///
224/// Sections are ranked by [`file_priority`] (DESC) then by char-length (ASC, so
225/// more small high-value files fit), then greedily accumulated while the running
226/// total stays within budget. Kept sections are re-emitted in their ORIGINAL
227/// diff order so the packed diff still reads top-to-bottom. If not even the
228/// smallest single section fits, the best one is kept anyway (never returns
229/// empty for a non-empty diff — a downstream safety clamp trims the hard cap).
230///
231/// # Examples
232/// ```
233/// # use pr_review_core::diff::pack_diff;
234/// let d = "diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+y\n";
235/// let (packed, dropped) = pack_diff(d, 10_000);
236/// assert_eq!(packed, d);
237/// assert!(dropped.is_empty());
238/// ```
239pub fn pack_diff(diff: &str, max_chars: usize) -> (String, Vec<String>) {
240    pack_impl(diff, max_chars, false)
241}
242
243/// [`pack_diff`] that keeps **related files together**: a source and its test, or
244/// i18n siblings, pack as one unit and stay adjacent, instead of being scattered
245/// by priority. Falls back to keeping a unit's highest-priority members when the
246/// whole unit can't fit, so a big source is never dropped just because its test
247/// pushed the bundle over budget.
248pub fn pack_diff_bundled(diff: &str, max_chars: usize) -> (String, Vec<String>) {
249    pack_impl(diff, max_chars, true)
250}
251
252/// Known i18n locale codes — the allowlist that gates locale stripping in
253/// [`bundle_key`], so a real name segment like `util.io` isn't mistaken for one.
254fn 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
286/// Group key for bundling: a file's directory plus its normalized base name
287/// (final extension, a `test`/`spec` marker, and an i18n locale stripped). So
288/// `src/foo.ts`, `src/foo.test.ts`, and `src/foo.spec.ts` all share `src/foo`, and
289/// `i18n/msg.en.json` / `i18n/msg.zh.json` share `i18n/msg`. Unrelated files fall
290/// on distinct keys and bundle alone.
291fn 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    // Drop the final extension.
298    if let Some(i) = f.rfind('.') {
299        if i > 0 {
300            f.truncate(i);
301        }
302    }
303    // Drop a co-located test/spec marker (`foo.test`, `foo_spec`, …).
304    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    // Python `test_foo.py` pairs with `foo.py`.
312    if let Some(s) = f.strip_prefix("test_") {
313        f = s.to_string();
314    }
315    // Drop a trailing i18n locale (`msg.en`, `msg_zh`) — allowlisted.
316    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
324/// Pack a too-large diff to `max_chars`, keeping whole files (see [`pack_diff`]).
325/// When `bundle`, related files are grouped into units that pack together and stay
326/// adjacent (see [`pack_diff_bundled`]); otherwise each file is its own unit and
327/// the behaviour is the historical per-file packing.
328fn 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    // Units = section-index groups. Without bundling each section is its own unit.
336    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            // Preamble / empty-path sections never bundle.
341            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(&sections[i].0))
364            .max()
365            .unwrap_or(0)
366    };
367
368    // Rank units: priority DESC, then total length ASC (more, smaller, high-value).
369    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    // Greedily keep whole units within budget; if a whole unit can't fit, keep its
377    // highest-priority members that still do (don't drop a big source over its test).
378    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(&sections[b].0)
392                    .cmp(&file_priority(&sections[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    // Fail-safe: never return empty — keep the best-ranked unit's first section.
405    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    // Emit unit-by-unit in first-appearance order so bundle members stay adjacent;
412    // within a unit, sections in diff order. (Non-bundle units are singletons in
413    // index order → the original ordering, unchanged.)
414    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) = &sections[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
433/// Drop per-file sections of a unified diff that don't pass the include/exclude
434/// glob filters — removing lockfiles, generated, vendored, and minified files
435/// before the diff is sent to the LLM (saves tokens and noise).
436///
437/// A file section is KEPT iff `(include is empty OR include matches path) AND NOT
438/// exclude matches path`. Sections whose path can't be derived are kept
439/// (fail-open). If the diff can't be parsed into sections at all, the original
440/// diff is returned unchanged with an empty dropped list — the review is never
441/// lost to a parse failure.
442///
443/// Returns `(kept_diff, dropped_paths)`.
444///
445/// # Examples
446/// ```
447/// # use pr_review_core::diff::filter_diff_by_globs;
448/// let d = "diff --git a/Cargo.lock b/Cargo.lock\n+++ b/Cargo.lock\n@@ -1 +1 @@\n+x\n\
449///          diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+y\n";
450/// let (kept, dropped) = filter_diff_by_globs(d, &[], &["**/Cargo.lock".to_string()]);
451/// assert_eq!(dropped, vec!["Cargo.lock".to_string()]);
452/// assert!(kept.contains("src/a.rs") && !kept.contains("Cargo.lock"));
453/// ```
454pub fn filter_diff_by_globs(
455    diff: &str,
456    include: &[String],
457    exclude: &[String],
458) -> (String, Vec<String>) {
459    // Bad glob in either set -> fail-open (skip filtering, keep the whole diff).
460    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 &sections {
470        // Empty path = preamble / undetermined section -> fail-open, keep it.
471        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    // Nothing dropped -> return the original untouched (preserve exact bytes).
481    if dropped.is_empty() {
482        return (diff.to_string(), Vec::new());
483    }
484
485    (out, dropped)
486}
487
488/// Whether a single `path` passes the include/exclude glob filters — the per-path
489/// equivalent of [`filter_diff_by_globs`], for callers that hold one path rather
490/// than a diff (e.g. the `/review-file` command). Fails **open**: an invalid glob
491/// in either set means "allow" rather than silently blocking the review.
492#[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/// A deterministic "diff-hygiene" issue: a property of the *change set* (not the
502/// code) worth flagging without an LLM — a binary swept into a commit, an oversized
503/// generated file. Reviewer-coverage class D: cheaper, zero-variance, and it cannot
504/// hallucinate, so it's strictly better than asking the model.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct HygieneIssue {
507    /// New-side path of the offending file.
508    pub file: String,
509    /// `"MEDIUM"` or `"LOW"` — mapped straight onto a finding's severity.
510    pub severity: &'static str,
511    /// Human-facing explanation, already in the `<problem>. Fix: <fix>` shape.
512    pub body: String,
513}
514
515/// A newly-added text file with at least this many added lines is worth a nudge
516/// (likely generated/vendored output that shouldn't be committed).
517const LARGE_ADDED_LINES: usize = 1000;
518
519/// Whether an added binary is a routine image/font asset — the kind teams commit
520/// deliberately (a logo, an avatar, a webfont). Suppressed from the diff-hygiene
521/// check so it doesn't flag routine product work; archives, executables, databases,
522/// and unknown binaries (the actual repo-bloat hazards, e.g. a swept-in `.zip`) still
523/// fire. Size and PR-body gates would sharpen this further but need the clone / PR
524/// metadata; extension is the signal available from the diff alone.
525fn 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
534/// Path segments that conventionally hold third-party source. Committing code in
535/// bulk under one of these is the *intended act* of vendoring, not a hygiene defect,
536/// so class-D checks are suppressed inside them.
537///
538/// Matched as whole path segments at any depth, so `frontend/node_modules/x` and
539/// `node_modules/x` both count. `external` is included because the convention is
540/// common enough to be worth the trade, even though some repos use it for
541/// first-party code — the cost of a wrong suppression is one lost hygiene bonus,
542/// while the cost of a wrong finding is a false positive that moves the
543/// recommendation. Override with `vendored` in `.prbot.toml` when it misfires.
544pub const DEFAULT_VENDORED_DIRS: &[&str] = &[
545    "thirdparty",
546    "third_party",
547    "vendor",
548    "vendored",
549    "external",
550    "node_modules",
551];
552
553/// Whether `path` lies inside vendored third-party code.
554///
555/// With no configured globs this is a whole-segment match against
556/// [`DEFAULT_VENDORED_DIRS`]; with globs it defers to them entirely, so a repo can
557/// both widen and narrow the default. Fails **closed** on an invalid glob (treats
558/// the path as NOT vendored), because suppressing findings is the riskier direction.
559#[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/// Scan a raw unified diff for deterministic diff-hygiene issues (class D). Targets
570/// *added* files only — the change-set hazards a normal diff view hides. No model,
571/// no clone; runs on the raw diff so a file filtered out of the review is still seen.
572///
573/// Vendored paths are skipped — see [`diff_hygiene_with`], which this delegates to
574/// with the default vendored directories.
575#[must_use]
576pub fn diff_hygiene(diff: &str) -> Vec<HygieneIssue> {
577    diff_hygiene_with(diff, &[])
578}
579
580/// [`diff_hygiene`] with explicit vendored globs (from `.prbot.toml`); an empty
581/// slice means [`DEFAULT_VENDORED_DIRS`].
582///
583/// Vendoring is why this split exists. A PR that adds 265 files of upstream source
584/// under `thirdparty/` is doing the thing it says on the tin: every class-D signal
585/// fires (bulk added files, files over the line threshold, sometimes binaries), and
586/// every one of those findings is wrong — the suggested remedy, "`.gitignore` or
587/// exclude it", is impossible for source you must compile. Suppressing the class
588/// inside vendored paths is the whole fix; the reviewer's judgment about vendored
589/// code belongs in the prompt, not here.
590#[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; // preamble or a /dev/null deletion — no added file to judge
596        }
597        if is_vendored(&path, vendored_globs) {
598            continue; // committing third-party source is the intent, not a defect
599        }
600        // Only *added* files: a `new file mode` header is git's unambiguous marker.
601        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            // A routine image/font asset (logo, avatar, webfont) is deliberate product
606            // work, not repo bloat — don't flag it. Archives/executables/DBs/unknown
607            // binaries still fire (that's the swept-in-`.zip` signal from #97).
608            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        // A committed product asset (avatar/logo/webfont) is deliberate, not bloat —
659        // it must not flag (regression: nomnaviet#106, a 7 KB .webp in public/).
660        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        // ...but a non-asset binary (archive/executable) in the same spot still fires.
666        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        // No `new file mode` → a modification, not an add; class D targets adds.
682        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(&section);
698        assert_eq!(issues.len(), 1);
699        assert_eq!(issues[0].severity, "LOW");
700        assert!(issues[0].body.contains("1500 lines"));
701    }
702
703    /// VinaText#20: a PR vendoring Scintilla/Lexilla under `thirdparty/` drew seven
704    /// LOW "adds N lines in a single new file … `.gitignore` or exclude it" findings.
705    /// Every line count was exact and every conclusion was wrong — you cannot
706    /// gitignore source you must compile. Bulk-committing upstream code is the
707    /// intended act of a vendoring PR.
708    #[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(&section).is_empty());
718
719        // A binary swept into a vendored tree is equally the vendoring act.
720        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        // ...but the same file OUTSIDE a vendored path still fires.
725        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        // Whole segments only — a file merely *named* like one is first-party code.
736        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        // Configuring the list turns the conventional names OFF — the repo decides.
746        assert!(!is_vendored("thirdparty/a.c", &globs));
747        // Invalid glob → fails CLOSED (not vendored), since suppressing is riskier.
748        assert!(!is_vendored("thirdparty/a.c", &["[".to_string()]));
749    }
750
751    #[test]
752    fn path_matches_globs_respects_include_and_exclude() {
753        // Empty include = everything allowed, subject to exclude.
754        assert!(path_matches_globs("src/lib.rs", &[], &[]));
755        // Excluded path is rejected.
756        assert!(!path_matches_globs(
757            "secrets/prod.env",
758            &[],
759            &["secrets/**".to_string()]
760        ));
761        // With an include set, only matching paths pass.
762        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        // Exclude wins over include.
769        assert!(!path_matches_globs(
770            "src/gen.rs",
771            &["src/**".to_string()],
772            &["**/gen.rs".to_string()]
773        ));
774        // Invalid glob -> fail open (allow).
775        assert!(path_matches_globs("anything", &["[".to_string()], &[]));
776    }
777
778    /// A minimal one-hunk section for `path`, adding `body` lines (to control size).
779    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        // Co-located source + test + spec share one key.
786        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        // Go and Python test conventions.
790        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        // i18n siblings (allowlisted locales).
793        let m = bundle_key("i18n/msg.en.json");
794        assert_eq!(bundle_key("i18n/msg.zh.json"), m);
795        // Unrelated files, and a non-locale 2-letter segment, stay distinct.
796        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        // Diff order interleaves the pair with an unrelated file: foo, other, foo.test.
803        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        // Budget fits the small foo+foo.test bundle but not the large `other`.
809        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        // The test section sits immediately after the source — bundled, adjacent.
818        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        // Without bundling, the same over-budget diff keeps original section order.
827        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)); // ctx1  -> new line 1
850        assert!(s.contains(&2)); // +added -> new line 2
851        assert!(s.contains(&3)); // ctx2  -> new line 3
852    }
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)); // keep1 -> 1
860        assert!(s.contains(&2)); // keep2 -> 2 (the removed line didn't advance)
861        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        // Concatenating the section texts reconstructs the diff.
936        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        // A big low-priority docs file and a small source file. Budget only fits
951        // the small source section, so README.md must be dropped.
952        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        // Small source file first, small test file second. A tiny docs file is
972        // dropped. Kept sections must come back in original (source, test) order
973        // even though the ranking would sort the test section differently.
974        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        // One file, larger than the budget: must still be returned (safety clamp
996        // downstream trims it) — never empty, nothing reported as dropped.
997        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}