Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use serde_json::Value;
4
5use super::diff_filter::DiffIndex;
6use super::fingerprint::{composite_fingerprint, summary_fingerprint};
7use super::pr_comment::{
8    CiIssue, Provider, command_title, escape_md, issues_from_codeclimate_issues,
9};
10use super::severity;
11use crate::output_envelope::{
12    CodeClimateIssue, GitHubReviewComment, GitHubReviewSide, GitLabReviewComment,
13    GitLabReviewPosition, GitLabReviewPositionType, ReviewCheckConclusion, ReviewComment,
14    ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema,
15    ReviewEnvelopeSummary, ReviewProvider, default_marker_regex, default_marker_regex_flags,
16};
17use crate::report::emit_json;
18
19/// Conservative body-size floor across the two supported review providers.
20/// GitLab accepts ~1,000,000 chars per `Note#note` validation (see
21/// <https://docs.gitlab.com/administration/instance_limits/>) and GitHub
22/// empirically enforces a 65,536-character cap on PR review comments
23/// (undocumented but reproducible: a 65,537-char body returns
24/// `Body is too long (maximum is 65536 characters)`). We pick 65,536 BYTES
25/// here so the cap is safe under either vendor regardless of whether the
26/// limit is enforced in bytes or chars, and regardless of multi-byte UTF-8
27/// expansion. Hardcoded for now; if a real consumer needs it tunable, expose
28/// a `FALLOW_REVIEW_MAX_BODY_BYTES` env var.
29const MAX_COMMENT_BODY_BYTES: usize = 65_536;
30
31/// Marker prefix appended to every v2 review-comment body. Mirrored by
32/// [`crate::output_envelope::MARKER_REGEX_V2`]; both must change together
33/// because consumers extract the fingerprint by running the regex over a
34/// body whose marker line uses this prefix. The `:v2:` namespace prevents
35/// collision with v1 historical markers and reduces user-paste spoofing
36/// risk (typing `:v2:` by accident is unlikely).
37pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
38
39/// Closing of the v2 marker, after the fingerprint string.
40const MARKER_SUFFIX_V2: &str = " -->";
41
42/// Human-readable truncation breadcrumb appended to the body when the
43/// rendered content exceeds [`MAX_COMMENT_BODY_BYTES`]. The HTML comment is
44/// machine-detectable; the blockquote that follows is a human-readable
45/// breadcrumb that reads as fallow speaking (matching the existing
46/// `> Run \`fallow fix --files\` or delete this file.` convention from the
47/// unused-file suggestion block). Three signals total (typed
48/// `truncated: bool` on the comment, this HTML marker, and the blockquote
49/// text) so consumers don't need to choose a primary detection channel.
50const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
51
52#[must_use]
53pub fn render_review_envelope(
54    command: &str,
55    provider: Provider,
56    issues: &[CiIssue],
57) -> ReviewEnvelopeOutput {
58    render_review_envelope_with_diff(
59        command,
60        provider,
61        issues,
62        super::diff_filter::shared_diff_index(),
63    )
64}
65
66/// Render path the print site uses. Exposed so unit tests can pass a
67/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
68/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
69/// parallel runner).
70#[must_use]
71pub fn render_review_envelope_with_diff(
72    command: &str,
73    provider: Provider,
74    issues: &[CiIssue],
75    diff_index: Option<&DiffIndex>,
76) -> ReviewEnvelopeOutput {
77    let max = std::env::var("FALLOW_MAX_COMMENTS")
78        .ok()
79        .and_then(|v| v.parse::<usize>().ok())
80        .unwrap_or(50);
81    let gitlab_diff_refs = (provider == Provider::Gitlab)
82        .then(gitlab_diff_refs_from_env)
83        .flatten();
84    let include_guidance = review_guidance_enabled();
85
86    let grouped = group_by_path_line(issues, max);
87
88    let comments: Vec<ReviewComment> = grouped
89        .groups
90        .iter()
91        .map(|group| {
92            render_merged_comment(
93                provider,
94                group,
95                gitlab_diff_refs.as_ref(),
96                diff_index,
97                include_guidance,
98            )
99        })
100        .collect();
101    let body_truncated = comments.iter().any(review_comment_truncated);
102    if body_truncated {
103        crate::telemetry::note_report_truncation(
104            true,
105            crate::telemetry::TruncationReason::SizeLimit,
106        );
107    } else if grouped.truncated {
108        crate::telemetry::note_report_truncation(
109            true,
110            crate::telemetry::TruncationReason::CommentLimit,
111        );
112    } else {
113        crate::telemetry::note_report_truncation(
114            false,
115            crate::telemetry::TruncationReason::Unknown,
116        );
117    }
118
119    let summary_text = format!(
120        "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
121        command_title(command),
122        comments.len(),
123        if comments.len() == 1 { "" } else { "s" },
124        provider.name(),
125    );
126    let summary_fp = summary_fingerprint(&summary_text);
127    let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
128    let body = format!("{summary_text}{summary_marker}");
129    let summary = ReviewEnvelopeSummary {
130        body: body.clone(),
131        fingerprint: summary_fp,
132    };
133
134    match provider {
135        Provider::Github => ReviewEnvelopeOutput {
136            event: Some(ReviewEnvelopeEvent::Comment),
137            body,
138            summary,
139            comments,
140            marker_regex: default_marker_regex(),
141            marker_regex_flags: default_marker_regex_flags(),
142            meta: ReviewEnvelopeMeta {
143                schema: ReviewEnvelopeSchema::V2,
144                provider: ReviewProvider::Github,
145                check_conclusion: Some(github_check_conclusion(issues)),
146            },
147        },
148        Provider::Gitlab => ReviewEnvelopeOutput {
149            event: None,
150            body,
151            summary,
152            comments,
153            marker_regex: default_marker_regex(),
154            marker_regex_flags: default_marker_regex_flags(),
155            meta: ReviewEnvelopeMeta {
156                schema: ReviewEnvelopeSchema::V2,
157                provider: ReviewProvider::Gitlab,
158                check_conclusion: None,
159            },
160        },
161    }
162}
163
164#[must_use]
165pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
166    let issues = super::diff_filter::filter_issues_from_env(
167        super::pr_comment::issues_from_codeclimate(codeclimate),
168    );
169    print_review_envelope_from_ci_issues(command, provider, &issues)
170}
171
172#[must_use]
173pub fn print_review_envelope_from_codeclimate_issues(
174    command: &str,
175    provider: Provider,
176    codeclimate: &[CodeClimateIssue],
177) -> ExitCode {
178    let issues =
179        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
180    print_review_envelope_from_ci_issues(command, provider, &issues)
181}
182
183#[must_use]
184#[expect(
185    clippy::expect_used,
186    reason = "review envelope contains only infallibly serializable fields"
187)]
188fn print_review_envelope_from_ci_issues(
189    command: &str,
190    provider: Provider,
191    issues: &[CiIssue],
192) -> ExitCode {
193    let envelope = render_review_envelope(command, provider, issues);
194    let value = crate::output_envelope::serialize_root_output(
195        crate::output_envelope::FallowOutput::ReviewEnvelope(envelope),
196    )
197    .expect("ReviewEnvelopeOutput serializes infallibly");
198    emit_json(&value, "review envelope")
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
202#[expect(
203    clippy::struct_field_names,
204    reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
205)]
206struct GitlabDiffRefs {
207    base_sha: String,
208    start_sha: String,
209    head_sha: String,
210}
211
212fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
213    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
214        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
215    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
216    let head_sha =
217        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
218    Some(GitlabDiffRefs {
219        base_sha,
220        start_sha,
221        head_sha,
222    })
223}
224
225fn env_nonempty(name: &str) -> Option<String> {
226    std::env::var(name)
227        .ok()
228        .filter(|value| !value.trim().is_empty())
229}
230
231fn review_guidance_enabled() -> bool {
232    std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
233}
234
235fn env_truthy(value: &str) -> bool {
236    matches!(
237        value.trim().to_ascii_lowercase().as_str(),
238        "1" | "true" | "yes" | "on"
239    )
240}
241
242#[derive(Debug, PartialEq, Eq)]
243struct GroupedReviewIssues<'a> {
244    groups: Vec<Vec<&'a CiIssue>>,
245    truncated: bool,
246}
247
248/// Group consecutive same-(path, line) issues. Input is already sorted by
249/// `(path, line, fingerprint)` so a single linear pass collects runs.
250fn group_by_path_line(issues: &[CiIssue], max_groups: usize) -> GroupedReviewIssues<'_> {
251    if max_groups == 0 {
252        return GroupedReviewIssues {
253            groups: Vec::new(),
254            truncated: !issues.is_empty(),
255        };
256    }
257    let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
258    let mut current: Vec<&CiIssue> = Vec::new();
259    let mut current_key: Option<(&str, u64)> = None;
260    for issue in issues {
261        let key = (issue.path.as_str(), issue.line);
262        if Some(key) != current_key {
263            if !current.is_empty() {
264                groups.push(std::mem::take(&mut current));
265                if groups.len() == max_groups {
266                    return GroupedReviewIssues {
267                        groups,
268                        truncated: true,
269                    };
270                }
271            }
272            current_key = Some(key);
273        }
274        current.push(issue);
275    }
276    if !current.is_empty() && groups.len() < max_groups {
277        groups.push(current);
278    }
279    GroupedReviewIssues {
280        groups,
281        truncated: false,
282    }
283}
284
285fn review_comment_truncated(comment: &ReviewComment) -> bool {
286    match comment {
287        ReviewComment::GitHub(comment) => comment.truncated,
288        ReviewComment::GitLab(comment) => comment.truncated,
289    }
290}
291
292/// Render one comment from a group of 1+ issues that share the same
293/// `(path, line)`. Single-element groups produce the v1-shaped body
294/// (modulo the `:v2:` marker shape); multi-element groups stack each
295/// finding's `**label** \`rule\`: desc` paragraph under a
296/// `merged:<16-char hash>` composite fingerprint over sorted constituent
297/// fingerprints. The composite identity shifts whenever the set of
298/// constituents changes, so consumers' skip-if-fingerprint-exists logic
299/// correctly re-posts on content change.
300#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
301fn render_merged_comment(
302    provider: Provider,
303    group: &[&CiIssue],
304    gitlab_diff_refs: Option<&GitlabDiffRefs>,
305    diff_index: Option<&DiffIndex>,
306    include_guidance: bool,
307) -> ReviewComment {
308    assert!(!group.is_empty(), "group_by_path_line never yields empty");
309    let representative = group[0];
310    let fingerprint = if group.len() == 1 {
311        representative.fingerprint.clone()
312    } else {
313        let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
314        composite_fingerprint(&constituents)
315    };
316
317    use std::fmt::Write as _;
318    let mut content = String::new();
319    for (index, issue) in group.iter().enumerate() {
320        let label = review_label_from_codeclimate(&issue.severity);
321        if index > 0 {
322            content.push_str("\n\n");
323        }
324        write!(
325            content,
326            "**{}** `{}`: {}",
327            label,
328            escape_md(&issue.rule_id),
329            escape_md(&issue.description)
330        )
331        .expect("write to String is infallible");
332        if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
333            content.push_str(&suggestion);
334        }
335        if include_guidance && let Some(guidance) = review_guidance_block(issue) {
336            content.push_str(&guidance);
337        }
338    }
339
340    let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
341    let (body, truncated) = cap_body_with_marker(&content, &marker_line);
342
343    match provider {
344        Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
345            path: representative.path.clone(),
346            line: u32::try_from(representative.line).unwrap_or(u32::MAX),
347            side: GitHubReviewSide::Right,
348            body,
349            fingerprint,
350            truncated,
351        }),
352        Provider::Gitlab => {
353            let new_path = representative.path.clone();
354            let old_path = diff_index
355                .and_then(|di| di.old_path_for(&new_path))
356                .map_or_else(|| new_path.clone(), str::to_owned);
357            let position = GitLabReviewPosition {
358                base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
359                start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
360                head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
361                position_type: GitLabReviewPositionType::Text,
362                old_path,
363                new_path,
364                new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
365            };
366            ReviewComment::GitLab(GitLabReviewComment {
367                body,
368                position,
369                fingerprint,
370                truncated,
371            })
372        }
373    }
374}
375
376fn review_guidance_block(issue: &CiIssue) -> Option<String> {
377    let rule = crate::explain::rule_by_id(&issue.rule_id)?;
378    let guide = crate::explain::rule_guide(rule);
379    let docs_url = crate::explain::rule_docs_url(rule);
380
381    Some(format!(
382        "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
383        guide.how_to_fix
384    ))
385}
386
387/// Truncate `content` if appending `marker_line` would exceed
388/// [`MAX_COMMENT_BODY_BYTES`], preserving the marker at the tail and
389/// inserting a [`TRUNCATION_SUFFIX`] breadcrumb. Truncation walks back to
390/// the nearest UTF-8 char boundary so multi-byte characters straddling the
391/// cut are not chopped mid-codepoint. Returns `(final_body, truncated)`.
392fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
393    let intact_len = content.len() + marker_line.len();
394    if intact_len <= MAX_COMMENT_BODY_BYTES {
395        let mut out = String::with_capacity(intact_len);
396        out.push_str(content);
397        out.push_str(marker_line);
398        return (out, false);
399    }
400    let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
401    let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
402    let mut cut = budget.min(content.len());
403    while cut > 0 && !content.is_char_boundary(cut) {
404        cut -= 1;
405    }
406    let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
407    out.push_str(&content[..cut]);
408    out.push_str(TRUNCATION_SUFFIX);
409    out.push_str(marker_line);
410    (out, true)
411}
412
413fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
414    match severity_name {
415        "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
416        _ => severity::review_label(fallow_config::Severity::Warn),
417    }
418}
419
420fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
421    if issues
422        .iter()
423        .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
424    {
425        ReviewCheckConclusion::Failure
426    } else if issues.is_empty() {
427        ReviewCheckConclusion::Success
428    } else {
429        ReviewCheckConclusion::Neutral
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::output_envelope::MARKER_REGEX_V2;
437
438    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
439        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
440    }
441
442    fn comment_to_value(comment: &ReviewComment) -> Value {
443        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
444    }
445
446    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
447        CiIssue {
448            rule_id: rule.into(),
449            description: "desc".into(),
450            severity: sev.into(),
451            path: path.into(),
452            line,
453            fingerprint: fp.into(),
454        }
455    }
456
457    fn issue_with_desc(
458        rule: &str,
459        desc: impl Into<String>,
460        sev: &str,
461        path: &str,
462        line: u64,
463        fp: &str,
464    ) -> CiIssue {
465        CiIssue {
466            rule_id: rule.into(),
467            description: desc.into(),
468            severity: sev.into(),
469            path: path.into(),
470            line,
471            fingerprint: fp.into(),
472        }
473    }
474
475    #[test]
476    fn github_review_envelope_matches_api_shape() {
477        let issues = vec![issue(
478            "fallow/unused-file",
479            "minor",
480            "src/a.ts",
481            1,
482            "abc1234567890def",
483        )];
484        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
485        assert_eq!(envelope["event"], "COMMENT");
486        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
487        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
488        assert!(
489            envelope["comments"][0]["body"]
490                .as_str()
491                .unwrap()
492                .contains("fallow-fingerprint:v2:")
493        );
494    }
495
496    #[test]
497    fn github_comments_target_current_state_side() {
498        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
499        let comment = comment_to_value(&render_merged_comment(
500            Provider::Github,
501            &[&issue],
502            None,
503            None,
504            false,
505        ));
506        assert_eq!(comment["side"], "RIGHT");
507    }
508
509    #[test]
510    fn labels_major_issues_as_errors() {
511        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
512        let comment = comment_to_value(&render_merged_comment(
513            Provider::Github,
514            &[&issue],
515            None,
516            None,
517            false,
518        ));
519        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
520    }
521
522    #[test]
523    fn gitlab_comment_accepts_diff_refs() {
524        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
525        let refs = GitlabDiffRefs {
526            base_sha: "base".into(),
527            start_sha: "start".into(),
528            head_sha: "head".into(),
529        };
530        let comment = comment_to_value(&render_merged_comment(
531            Provider::Gitlab,
532            &[&issue],
533            Some(&refs),
534            None,
535            false,
536        ));
537        assert_eq!(comment["position"]["position_type"], "text");
538        assert_eq!(comment["position"]["base_sha"], "base");
539        assert_eq!(comment["position"]["start_sha"], "start");
540        assert_eq!(comment["position"]["head_sha"], "head");
541    }
542
543    #[test]
544    fn guidance_toggle_accepts_common_truthy_values() {
545        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
546            assert!(env_truthy(value), "{value:?} should enable guidance");
547        }
548        for value in ["", "0", "false", "no", "off", "enabled"] {
549            assert!(!env_truthy(value), "{value:?} should not enable guidance");
550        }
551    }
552
553    #[test]
554    fn guidance_disabled_omits_details_block() {
555        let issue = issue(
556            "fallow/high-complexity",
557            "major",
558            "src/a.ts",
559            10,
560            "abc1234567890def",
561        );
562        let comment = comment_to_value(&render_merged_comment(
563            Provider::Github,
564            &[&issue],
565            None,
566            None,
567            false,
568        ));
569        let body = comment["body"].as_str().unwrap();
570        assert!(!body.contains("<details><summary>What to do</summary>"));
571        assert!(!body.contains("For function findings"));
572    }
573
574    #[test]
575    fn guidance_enabled_appends_rule_guide_details() {
576        let issue = issue(
577            "fallow/high-complexity",
578            "major",
579            "src/a.ts",
580            10,
581            "abc1234567890def",
582        );
583        let comment = comment_to_value(&render_merged_comment(
584            Provider::Github,
585            &[&issue],
586            None,
587            None,
588            true,
589        ));
590        let body = comment["body"].as_str().unwrap();
591        assert!(body.contains("<details><summary>What to do</summary>"));
592        assert!(body.contains("For function findings"));
593        assert!(body.contains("[Read the rule docs]("));
594        assert!(
595            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
596            "guidance should render before the marker"
597        );
598    }
599
600    #[test]
601    fn guidance_attaches_to_each_merged_finding() {
602        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
603        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
604        let comment = comment_to_value(&render_merged_comment(
605            Provider::Github,
606            &[&complexity, &duplication],
607            None,
608            None,
609            true,
610        ));
611        let body = comment["body"].as_str().unwrap();
612        assert_eq!(
613            body.matches("<details><summary>What to do</summary>")
614                .count(),
615            2
616        );
617        assert!(body.contains("For function findings"));
618        assert!(body.contains("Extract the shared logic"));
619    }
620
621    #[test]
622    fn envelope_emits_marker_regex_field_at_root() {
623        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
624        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
625        let regex = env["marker_regex"].as_str().expect("marker_regex present");
626        assert_eq!(regex, MARKER_REGEX_V2);
627        assert!(regex.contains("[0-9a-f]{16}"));
628        assert!(regex.starts_with('^'));
629        assert!(regex.ends_with("\\s*$"));
630        assert!(!regex.contains("(?m)"));
631        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
632        let flags = env["marker_regex_flags"]
633            .as_str()
634            .expect("marker_regex_flags present");
635        assert_eq!(flags, "m");
636    }
637
638    #[test]
639    fn envelope_emits_summary_block_with_fingerprint() {
640        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
641        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
642        assert_eq!(env["summary"]["body"], env["body"]);
643        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
644        assert_eq!(summary_fp.len(), 16);
645        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
646        let body_str = env["body"].as_str().unwrap();
647        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
648        assert!(
649            body_str.contains(&marker_line),
650            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
651        );
652    }
653
654    #[test]
655    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
656        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
657        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
658        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
659        assert_eq!(
660            env["comments"].as_array().unwrap().len(),
661            1,
662            "two same-line findings must collapse to one comment"
663        );
664        let merged = &env["comments"][0];
665        let fp = merged["fingerprint"].as_str().unwrap();
666        assert!(
667            fp.starts_with("merged:"),
668            "merged comment fingerprint must start with merged:, got {fp}"
669        );
670        assert_eq!(fp.len(), 23);
671        let body = merged["body"].as_str().unwrap();
672        assert!(body.contains("fallow/unused-export"));
673        assert!(body.contains("fallow/duplicate-export"));
674        assert_eq!(
675            body.matches("fallow-fingerprint:v2:").count(),
676            1,
677            "merged body must carry exactly one fingerprint marker"
678        );
679        assert!(
680            merged.get("constituent_fingerprints").is_none(),
681            "v2 hashed-composite design does not emit constituent_fingerprints"
682        );
683    }
684
685    #[test]
686    fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
687        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
688        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
689        let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
690        let issues = vec![a, b, c];
691
692        let max_zero = group_by_path_line(&issues, 0);
693        assert!(max_zero.groups.is_empty());
694        assert!(max_zero.truncated);
695
696        let max_one = group_by_path_line(&issues, 1);
697        assert_eq!(max_one.groups.len(), 1);
698        assert!(max_one.truncated);
699        assert_eq!(max_one.groups[0].len(), 2);
700        assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
701        assert_eq!(max_one.groups[0][0].line, 42);
702
703        let max_two = group_by_path_line(&issues, 2);
704        assert_eq!(max_two.groups.len(), 2);
705        assert!(!max_two.truncated);
706        assert_eq!(max_two.groups[0].len(), 2);
707        assert_eq!(max_two.groups[1].len(), 1);
708        assert_eq!(
709            max_two.groups[0]
710                .iter()
711                .map(|issue| issue.fingerprint.as_str())
712                .collect::<Vec<_>>(),
713            ["fp_a", "fp_b"]
714        );
715    }
716
717    #[test]
718    fn single_finding_keeps_v1_fingerprint_shape() {
719        let issues = vec![issue(
720            "fallow/unused-file",
721            "minor",
722            "src/a.ts",
723            1,
724            "abc1234567890def",
725        )];
726        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
727        let comment = &env["comments"][0];
728        assert_eq!(comment["fingerprint"], "abc1234567890def");
729        assert!(
730            comment.get("constituent_fingerprints").is_none(),
731            "single-finding comment must NOT emit constituent_fingerprints"
732        );
733        assert!(
734            comment.get("truncated").is_none(),
735            "non-truncated comment must NOT emit truncated"
736        );
737    }
738
739    #[test]
740    fn composite_fingerprint_shifts_when_constituents_change() {
741        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
742        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
743        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
744        let run1 = to_value(&render_review_envelope(
745            "check",
746            Provider::Github,
747            &[a.clone(), b, c.clone()],
748        ));
749        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
750        assert_ne!(
751            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
752            "primary fingerprint must shift when a constituent drops"
753        );
754    }
755
756    #[test]
757    fn gitlab_old_path_pulls_from_diff_rename_map() {
758        let rename_diff = "\
759diff --git a/src/old.ts b/src/new.ts
760similarity index 90%
761rename from src/old.ts
762rename to src/new.ts
763--- a/src/old.ts
764+++ b/src/new.ts
765@@ -1,2 +1,3 @@
766 keep
767+added
768 still
769";
770        let diff_index = DiffIndex::from_unified_diff(rename_diff);
771        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
772        let envelope = to_value(&render_review_envelope_with_diff(
773            "check",
774            Provider::Gitlab,
775            &[issue],
776            Some(&diff_index),
777        ));
778        let position = &envelope["comments"][0]["position"];
779        assert_eq!(position["old_path"], "src/old.ts");
780        assert_eq!(position["new_path"], "src/new.ts");
781    }
782
783    #[test]
784    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
785        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
786        let envelope = to_value(&render_review_envelope_with_diff(
787            "check",
788            Provider::Gitlab,
789            &[issue],
790            None,
791        ));
792        let position = &envelope["comments"][0]["position"];
793        assert_eq!(position["old_path"], "src/edit.ts");
794        assert_eq!(position["new_path"], "src/edit.ts");
795    }
796
797    #[test]
798    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
799        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
800        let issue = CiIssue {
801            rule_id: "fallow/unused-export".into(),
802            description: huge_desc,
803            severity: "minor".into(),
804            path: "src/a.ts".into(),
805            line: 1,
806            fingerprint: "abc1234567890def".into(),
807        };
808        let comment = comment_to_value(&render_merged_comment(
809            Provider::Github,
810            &[&issue],
811            None,
812            None,
813            false,
814        ));
815        let body = comment["body"].as_str().unwrap();
816        assert!(
817            body.len() <= MAX_COMMENT_BODY_BYTES,
818            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
819            body.len()
820        );
821        assert!(
822            body.contains("fallow-fingerprint:v2:"),
823            "marker must be preserved under truncation"
824        );
825        assert!(body.contains("<!-- fallow-truncated -->"));
826        assert!(body.contains("> Body truncated by fallow."));
827        assert_eq!(comment["truncated"], true);
828        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
829    }
830
831    #[test]
832    fn oversized_guidance_body_truncates_and_preserves_marker() {
833        let issue = issue_with_desc(
834            "fallow/high-complexity",
835            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
836            "major",
837            "src/a.ts",
838            1,
839            "abc1234567890def",
840        );
841        let comment = comment_to_value(&render_merged_comment(
842            Provider::Github,
843            &[&issue],
844            None,
845            None,
846            true,
847        ));
848        let body = comment["body"].as_str().unwrap();
849        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
850        assert!(body.contains("<!-- fallow-truncated -->"));
851        assert!(body.contains("fallow-fingerprint:v2:"));
852        assert_eq!(comment["truncated"], true);
853    }
854
855    #[test]
856    fn multibyte_body_truncates_at_char_boundary() {
857        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
858        let issue = CiIssue {
859            rule_id: "fallow/unused-export".into(),
860            description: huge_desc,
861            severity: "minor".into(),
862            path: "src/a.ts".into(),
863            line: 1,
864            fingerprint: "abc1234567890def".into(),
865        };
866        let comment = comment_to_value(&render_merged_comment(
867            Provider::Github,
868            &[&issue],
869            None,
870            None,
871            false,
872        ));
873        let body = comment["body"].as_str().unwrap();
874        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
875        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
876        assert_eq!(comment["truncated"], true);
877    }
878}