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