Skip to main content

fallow_cli/report/ci/
review.rs

1use std::process::ExitCode;
2
3use fallow_output::CodeClimateIssue;
4use serde_json::Value;
5
6use super::diff_filter::DiffIndex;
7use crate::report::emit_json;
8use fallow_output::PrDecisionConclusion;
9use fallow_output::{
10    CiIssue, CiProvider as Provider, ReviewCheckConclusion, ReviewEnvelopeOutput,
11    ReviewEnvelopeRenderInput, ReviewEnvelopeTruncation, ReviewGitlabDiffRefs as GitlabDiffRefs,
12    ReviewId, issues_from_codeclimate_issues,
13};
14
15#[must_use]
16pub fn render_review_envelope(
17    command: &str,
18    provider: Provider,
19    issues: &[CiIssue],
20) -> ReviewEnvelopeOutput {
21    render_review_envelope_with_diff(
22        command,
23        provider,
24        issues,
25        super::diff_filter::shared_diff_index(),
26        None,
27        None,
28        None,
29    )
30}
31
32/// Render path the print site uses. Exposed so unit tests can pass a
33/// hand-crafted `DiffIndex` without poking the process-wide `SHARED_DIFF`
34/// cache (which is `OnceLock`-bounded and not reentrant under cargo test's
35/// parallel runner).
36#[must_use]
37#[expect(
38    clippy::too_many_arguments,
39    reason = "the private render seam keeps test-injected diff and optional gate state explicit"
40)]
41fn render_review_envelope_with_diff(
42    command: &str,
43    provider: Provider,
44    issues: &[CiIssue],
45    diff_index: Option<&DiffIndex>,
46    review_id: Option<&ReviewId>,
47    conclusion: Option<ReviewCheckConclusion>,
48    status_message: Option<&str>,
49) -> ReviewEnvelopeOutput {
50    let max = std::env::var("FALLOW_MAX_COMMENTS")
51        .ok()
52        .and_then(|v| v.parse::<usize>().ok())
53        .unwrap_or(50);
54    let gitlab_diff_refs = (provider == Provider::Gitlab)
55        .then(gitlab_diff_refs_from_env)
56        .flatten();
57    let include_guidance = review_guidance_enabled();
58
59    let input = ReviewEnvelopeRenderInput {
60        command,
61        provider,
62        issues,
63        diff_index,
64        path_prefix: crate::report::github::report_prefix(),
65        max_comments: max,
66        gitlab_diff_refs: gitlab_diff_refs.as_ref(),
67        include_guidance,
68        suggestion_block: &super::suggestion::suggestion_block,
69        guidance_block: &review_guidance_block,
70    };
71    let rendered = match (review_id, conclusion) {
72        (Some(review_id), Some(conclusion)) => {
73            fallow_output::render_scoped_review_envelope_with_conclusion(
74                &input,
75                review_id,
76                conclusion,
77                status_message,
78            )
79        }
80        (Some(review_id), None) => {
81            fallow_output::render_scoped_review_envelope(&input, review_id, status_message)
82        }
83        (None, Some(conclusion)) => fallow_output::render_review_envelope_with_conclusion(
84            &input,
85            conclusion,
86            status_message,
87        ),
88        (None, None) => fallow_output::render_review_envelope(&input, status_message),
89    };
90    note_review_truncation(rendered.truncation);
91    rendered.envelope
92}
93
94/// Record telemetry for body-size or comment-count truncation of the review.
95fn note_review_truncation(truncation: ReviewEnvelopeTruncation) {
96    if truncation.body {
97        crate::telemetry::note_report_truncation(
98            true,
99            crate::telemetry::TruncationReason::SizeLimit,
100        );
101    } else if truncation.comment_limit {
102        crate::telemetry::note_report_truncation(
103            true,
104            crate::telemetry::TruncationReason::CommentLimit,
105        );
106    } else {
107        crate::telemetry::note_report_truncation(
108            false,
109            crate::telemetry::TruncationReason::Unknown,
110        );
111    }
112}
113
114#[must_use]
115pub(crate) fn print_review_envelope(
116    command: &str,
117    provider: Provider,
118    codeclimate: &Value,
119    status_message: Option<&str>,
120) -> ExitCode {
121    let issues = super::diff_filter::filter_issues_from_env(
122        super::pr_comment::issues_from_codeclimate(codeclimate),
123    );
124    print_review_envelope_from_ci_issues(command, provider, &issues, None, status_message)
125}
126
127#[must_use]
128pub(crate) fn print_review_envelope_with_conclusion(
129    command: &str,
130    provider: Provider,
131    codeclimate: &Value,
132    conclusion: PrDecisionConclusion,
133    status_message: Option<&str>,
134) -> ExitCode {
135    let issues = super::diff_filter::filter_issues_from_env(
136        super::pr_comment::issues_from_codeclimate(codeclimate),
137    );
138    print_review_envelope_from_ci_issues(
139        command,
140        provider,
141        &issues,
142        Some(review_conclusion(conclusion)),
143        status_message,
144    )
145}
146
147#[must_use]
148pub(crate) fn print_review_envelope_from_codeclimate_issues(
149    command: &str,
150    provider: Provider,
151    codeclimate: &[CodeClimateIssue],
152    status_message: Option<&str>,
153) -> ExitCode {
154    let issues =
155        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
156    print_review_envelope_from_ci_issues(command, provider, &issues, None, status_message)
157}
158
159#[must_use]
160pub(crate) fn print_review_envelope_from_codeclimate_issues_with_conclusion(
161    command: &str,
162    provider: Provider,
163    codeclimate: &[CodeClimateIssue],
164    conclusion: PrDecisionConclusion,
165    status_message: Option<&str>,
166) -> ExitCode {
167    let issues =
168        super::diff_filter::filter_issues_from_env(issues_from_codeclimate_issues(codeclimate));
169    print_review_envelope_from_ci_issues(
170        command,
171        provider,
172        &issues,
173        Some(review_conclusion(conclusion)),
174        status_message,
175    )
176}
177
178#[must_use]
179fn print_review_envelope_from_ci_issues(
180    command: &str,
181    provider: Provider,
182    issues: &[CiIssue],
183    conclusion: Option<ReviewCheckConclusion>,
184    status_message: Option<&str>,
185) -> ExitCode {
186    let review_id = match review_id_from_env() {
187        Ok(review_id) => review_id,
188        Err(error) => {
189            eprintln!("Error: {error}");
190            return ExitCode::from(2);
191        }
192    };
193    let envelope = render_review_envelope_with_diff(
194        command,
195        provider,
196        issues,
197        super::diff_filter::shared_diff_index(),
198        review_id.as_ref(),
199        conclusion,
200        status_message,
201    );
202    let analysis_run_id = crate::output_runtime::telemetry_analysis_run_id();
203    let value = match review_id.as_ref() {
204        Some(review_id) => fallow_output::serialize_scoped_review_envelope_json_output(
205            &envelope,
206            review_id,
207            analysis_run_id.as_deref(),
208        ),
209        None => fallow_output::serialize_review_envelope_json_output(
210            envelope,
211            analysis_run_id.as_deref(),
212        )
213        .map_err(|error| error.to_string()),
214    };
215    let value = match value {
216        Ok(value) => value,
217        Err(error) => {
218            eprintln!("Error: failed to serialize review envelope: {error}");
219            return ExitCode::from(2);
220        }
221    };
222    emit_json(&value, "review envelope")
223}
224
225/// Translate a gate conclusion into the review/check vocabulary. Shared with
226/// the sticky comment renderer so both surfaces phrase one gate outcome the
227/// same way.
228pub(crate) const fn review_conclusion(conclusion: PrDecisionConclusion) -> ReviewCheckConclusion {
229    match conclusion {
230        PrDecisionConclusion::Success => ReviewCheckConclusion::Success,
231        PrDecisionConclusion::Failure => ReviewCheckConclusion::Failure,
232        PrDecisionConclusion::Neutral | PrDecisionConclusion::Skipped => {
233            ReviewCheckConclusion::Neutral
234        }
235    }
236}
237
238fn review_id_from_env() -> Result<Option<ReviewId>, String> {
239    let Some(value) = env_nonempty("FALLOW_REVIEW_ID") else {
240        return Ok(None);
241    };
242    ReviewId::parse(value)
243        .map(Some)
244        .map_err(|error| format!("invalid FALLOW_REVIEW_ID: {error}"))
245}
246
247fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
248    let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
249        .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
250    let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
251    let head_sha =
252        env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
253    Some(GitlabDiffRefs {
254        base_sha,
255        start_sha,
256        head_sha,
257    })
258}
259
260fn env_nonempty(name: &str) -> Option<String> {
261    std::env::var(name)
262        .ok()
263        .filter(|value| !value.trim().is_empty())
264}
265
266fn review_guidance_enabled() -> bool {
267    std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
268}
269
270fn env_truthy(value: &str) -> bool {
271    matches!(
272        value.trim().to_ascii_lowercase().as_str(),
273        "1" | "true" | "yes" | "on"
274    )
275}
276
277fn review_guidance_block(issue: &CiIssue) -> Option<String> {
278    let rule = crate::explain::rule_by_id(&issue.rule_id)?;
279    let guide = crate::explain::rule_guide(rule);
280    let docs_url = crate::explain::rule_docs_url(rule);
281
282    Some(format!(
283        "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
284        guide.how_to_fix
285    ))
286}
287
288#[cfg(test)]
289fn render_merged_comment(
290    provider: Provider,
291    group: &[&CiIssue],
292    gitlab_diff_refs: Option<&GitlabDiffRefs>,
293    diff_index: Option<&DiffIndex>,
294    include_guidance: bool,
295) -> fallow_output::ReviewComment {
296    fallow_output::render_review_comment_for_group(&fallow_output::ReviewCommentRenderInput {
297        provider,
298        group,
299        gitlab_diff_refs,
300        diff_index,
301        path_prefix: "",
302        include_guidance,
303        suggestion_block: &super::suggestion::suggestion_block,
304        guidance_block: &review_guidance_block,
305    })
306}
307
308#[cfg(test)]
309fn group_by_path_line(
310    issues: &[CiIssue],
311    max_groups: usize,
312) -> fallow_output::GroupedReviewIssues<'_> {
313    fallow_output::group_review_issues_by_path_line(issues, max_groups)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use fallow_output::{MARKER_PREFIX_V2, MARKER_SUFFIX_V2, MAX_COMMENT_BODY_BYTES};
320    use fallow_output::{MARKER_REGEX_V2, ReviewComment};
321
322    fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
323        serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
324    }
325
326    fn comment_to_value(comment: &ReviewComment) -> Value {
327        serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
328    }
329
330    fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
331        CiIssue {
332            rule_id: rule.into(),
333            description: "desc".into(),
334            severity: sev.into(),
335            path: path.into(),
336            line,
337            end_line: None,
338            other_locations: Vec::new(),
339            fingerprint: fp.into(),
340        }
341    }
342
343    fn issue_with_desc(
344        rule: &str,
345        desc: impl Into<String>,
346        sev: &str,
347        path: &str,
348        line: u64,
349        fp: &str,
350    ) -> CiIssue {
351        CiIssue {
352            rule_id: rule.into(),
353            description: desc.into(),
354            severity: sev.into(),
355            path: path.into(),
356            line,
357            end_line: None,
358            other_locations: Vec::new(),
359            fingerprint: fp.into(),
360        }
361    }
362
363    #[test]
364    fn scoped_renderer_uses_typed_meta_and_exact_markers() {
365        let issue = issue(
366            "fallow/unused-file",
367            "minor",
368            "src/a.ts",
369            1,
370            "abcdef0123456789",
371        );
372        let review_id = ReviewId::parse("frontend").unwrap();
373        let envelope = render_review_envelope_with_diff(
374            "check",
375            Provider::Github,
376            &[issue],
377            None,
378            Some(&review_id),
379            None,
380            None,
381        );
382        let envelope = fallow_output::serialize_scoped_review_envelope_json_output(
383            &envelope, &review_id, None,
384        )
385        .unwrap();
386
387        assert_eq!(envelope["meta"]["review_id"], "frontend");
388        assert!(
389            envelope["body"]
390                .as_str()
391                .unwrap()
392                .ends_with("<!-- fallow-review-id: frontend -->")
393        );
394        assert!(
395            envelope["comments"][0]["body"]
396                .as_str()
397                .unwrap()
398                .ends_with("<!-- fallow-review-id: frontend -->")
399        );
400    }
401
402    #[test]
403    fn github_review_envelope_matches_api_shape() {
404        let issues = vec![issue(
405            "fallow/unused-file",
406            "minor",
407            "src/a.ts",
408            1,
409            "abc1234567890def",
410        )];
411        let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
412        assert_eq!(envelope["event"], "COMMENT");
413        assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v3");
414        assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
415        assert!(
416            envelope["comments"][0]["body"]
417                .as_str()
418                .unwrap()
419                .contains("fallow-fingerprint:v2:")
420        );
421    }
422
423    #[test]
424    fn review_summary_body_points_at_the_inline_comments() {
425        let issues = vec![issue(
426            "fallow/unused-file",
427            "major",
428            "src/a.ts",
429            1,
430            "abc1234567890def",
431        )];
432        let envelope = to_value(&render_review_envelope(
433            "combined",
434            Provider::Github,
435            &issues,
436        ));
437        let body = envelope["body"].as_str().expect("body is string");
438
439        assert!(!body.contains("Quality gate"), "{body}");
440        assert!(
441            body.contains(
442                "1 inline comment on the changed lines. Open the Files changed tab to review."
443            ),
444            "{body}"
445        );
446        assert!(body.contains("<!-- fallow-review -->"), "{body}");
447        assert_eq!(
448            envelope["meta"]["check_conclusion"], "failure",
449            "{envelope}"
450        );
451    }
452
453    #[test]
454    fn github_comments_target_current_state_side() {
455        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
456        let comment = comment_to_value(&render_merged_comment(
457            Provider::Github,
458            &[&issue],
459            None,
460            None,
461            false,
462        ));
463        assert_eq!(comment["side"], "RIGHT");
464    }
465
466    #[test]
467    fn labels_major_issues_as_errors() {
468        let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
469        let comment = comment_to_value(&render_merged_comment(
470            Provider::Github,
471            &[&issue],
472            None,
473            None,
474            false,
475        ));
476        assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
477    }
478
479    #[test]
480    fn gitlab_comment_accepts_diff_refs() {
481        let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
482        let refs = GitlabDiffRefs {
483            base_sha: "base".into(),
484            start_sha: "start".into(),
485            head_sha: "head".into(),
486        };
487        let comment = comment_to_value(&render_merged_comment(
488            Provider::Gitlab,
489            &[&issue],
490            Some(&refs),
491            None,
492            false,
493        ));
494        assert_eq!(comment["position"]["position_type"], "text");
495        assert_eq!(comment["position"]["base_sha"], "base");
496        assert_eq!(comment["position"]["start_sha"], "start");
497        assert_eq!(comment["position"]["head_sha"], "head");
498    }
499
500    #[test]
501    fn guidance_toggle_accepts_common_truthy_values() {
502        for value in ["1", "true", "TRUE", "yes", "on", " On "] {
503            assert!(env_truthy(value), "{value:?} should enable guidance");
504        }
505        for value in ["", "0", "false", "no", "off", "enabled"] {
506            assert!(!env_truthy(value), "{value:?} should not enable guidance");
507        }
508    }
509
510    #[test]
511    fn guidance_disabled_omits_details_block() {
512        let issue = issue(
513            "fallow/high-complexity",
514            "major",
515            "src/a.ts",
516            10,
517            "abc1234567890def",
518        );
519        let comment = comment_to_value(&render_merged_comment(
520            Provider::Github,
521            &[&issue],
522            None,
523            None,
524            false,
525        ));
526        let body = comment["body"].as_str().unwrap();
527        assert!(!body.contains("<details><summary>What to do</summary>"));
528        assert!(!body.contains("For function findings"));
529    }
530
531    #[test]
532    fn guidance_enabled_appends_rule_guide_details() {
533        let issue = issue(
534            "fallow/high-complexity",
535            "major",
536            "src/a.ts",
537            10,
538            "abc1234567890def",
539        );
540        let comment = comment_to_value(&render_merged_comment(
541            Provider::Github,
542            &[&issue],
543            None,
544            None,
545            true,
546        ));
547        let body = comment["body"].as_str().unwrap();
548        assert!(body.contains("<details><summary>What to do</summary>"));
549        assert!(body.contains("For function findings"));
550        assert!(body.contains("[Read the rule docs]("));
551        assert!(
552            body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
553            "guidance should render before the marker"
554        );
555    }
556
557    #[test]
558    fn guidance_attaches_to_each_merged_finding() {
559        let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
560        let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
561        let comment = comment_to_value(&render_merged_comment(
562            Provider::Github,
563            &[&complexity, &duplication],
564            None,
565            None,
566            true,
567        ));
568        let body = comment["body"].as_str().unwrap();
569        assert_eq!(
570            body.matches("<details><summary>What to do</summary>")
571                .count(),
572            2
573        );
574        assert!(body.contains("For function findings"));
575        assert!(body.contains("Extract the shared logic"));
576    }
577
578    #[test]
579    fn envelope_emits_marker_regex_field_at_root() {
580        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
581        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
582        let regex = env["marker_regex"].as_str().expect("marker_regex present");
583        assert_eq!(regex, MARKER_REGEX_V2);
584        assert!(regex.contains("[0-9a-f]{16}"));
585        assert!(regex.starts_with('^'));
586        assert!(regex.ends_with("\\s*$"));
587        assert!(!regex.contains("(?m)"));
588        assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
589        let flags = env["marker_regex_flags"]
590            .as_str()
591            .expect("marker_regex_flags present");
592        assert_eq!(flags, "m");
593    }
594
595    #[test]
596    fn envelope_emits_summary_block_with_fingerprint() {
597        let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
598        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
599        assert_eq!(env["summary"]["body"], env["body"]);
600        let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
601        assert_eq!(summary_fp.len(), 16);
602        assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
603        let body_str = env["body"].as_str().unwrap();
604        let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
605        assert!(
606            body_str.contains(&marker_line),
607            "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
608        );
609    }
610
611    #[test]
612    fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
613        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
614        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
615        let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
616        assert_eq!(
617            env["comments"].as_array().unwrap().len(),
618            1,
619            "two same-line findings must collapse to one comment"
620        );
621        let merged = &env["comments"][0];
622        let fp = merged["fingerprint"].as_str().unwrap();
623        assert!(
624            fp.starts_with("merged:"),
625            "merged comment fingerprint must start with merged:, got {fp}"
626        );
627        assert_eq!(fp.len(), 23);
628        let body = merged["body"].as_str().unwrap();
629        assert!(body.contains("fallow/unused-export"));
630        assert!(body.contains("fallow/duplicate-export"));
631        assert_eq!(
632            body.matches("fallow-fingerprint:v2:").count(),
633            1,
634            "merged body must carry exactly one fingerprint marker"
635        );
636        assert!(
637            merged.get("constituent_fingerprints").is_none(),
638            "v2 hashed-composite design does not emit constituent_fingerprints"
639        );
640    }
641
642    #[test]
643    fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
644        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
645        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
646        let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
647        let issues = vec![a, b, c];
648
649        let max_zero = group_by_path_line(&issues, 0);
650        assert!(max_zero.groups.is_empty());
651        assert!(max_zero.truncated);
652
653        let max_one = group_by_path_line(&issues, 1);
654        assert_eq!(max_one.groups.len(), 1);
655        assert!(max_one.truncated);
656        assert_eq!(max_one.groups[0].len(), 2);
657        assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
658        assert_eq!(max_one.groups[0][0].line, 42);
659
660        let max_two = group_by_path_line(&issues, 2);
661        assert_eq!(max_two.groups.len(), 2);
662        assert!(!max_two.truncated);
663        assert_eq!(max_two.groups[0].len(), 2);
664        assert_eq!(max_two.groups[1].len(), 1);
665        assert_eq!(
666            max_two.groups[0]
667                .iter()
668                .map(|issue| issue.fingerprint.as_str())
669                .collect::<Vec<_>>(),
670            ["fp_a", "fp_b"]
671        );
672    }
673
674    #[test]
675    fn single_finding_keeps_v1_fingerprint_shape() {
676        let issues = vec![issue(
677            "fallow/unused-file",
678            "minor",
679            "src/a.ts",
680            1,
681            "abc1234567890def",
682        )];
683        let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
684        let comment = &env["comments"][0];
685        assert_eq!(comment["fingerprint"], "abc1234567890def");
686        assert!(
687            comment.get("constituent_fingerprints").is_none(),
688            "single-finding comment must NOT emit constituent_fingerprints"
689        );
690        assert!(
691            comment.get("truncated").is_none(),
692            "non-truncated comment must NOT emit truncated"
693        );
694    }
695
696    #[test]
697    fn composite_fingerprint_shifts_when_constituents_change() {
698        let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
699        let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
700        let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
701        let run1 = to_value(&render_review_envelope(
702            "check",
703            Provider::Github,
704            &[a.clone(), b, c.clone()],
705        ));
706        let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
707        assert_ne!(
708            run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
709            "primary fingerprint must shift when a constituent drops"
710        );
711    }
712
713    #[test]
714    fn gitlab_old_path_pulls_from_diff_rename_map() {
715        let rename_diff = "\
716diff --git a/src/old.ts b/src/new.ts
717similarity index 90%
718rename from src/old.ts
719rename to src/new.ts
720--- a/src/old.ts
721+++ b/src/new.ts
722@@ -1,2 +1,3 @@
723 keep
724+added
725 still
726";
727        let diff_index = DiffIndex::from_unified_diff(rename_diff);
728        let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
729        let envelope = to_value(&render_review_envelope_with_diff(
730            "check",
731            Provider::Gitlab,
732            &[issue],
733            Some(&diff_index),
734            None,
735            None,
736            None,
737        ));
738        let position = &envelope["comments"][0]["position"];
739        assert_eq!(position["old_path"], "src/old.ts");
740        assert_eq!(position["new_path"], "src/new.ts");
741    }
742
743    #[test]
744    fn gitlab_old_path_falls_back_to_new_path_without_rename() {
745        let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
746        let envelope = to_value(&render_review_envelope_with_diff(
747            "check",
748            Provider::Gitlab,
749            &[issue],
750            None,
751            None,
752            None,
753            None,
754        ));
755        let position = &envelope["comments"][0]["position"];
756        assert_eq!(position["old_path"], "src/edit.ts");
757        assert_eq!(position["new_path"], "src/edit.ts");
758    }
759
760    #[test]
761    fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
762        let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
763        let issue = CiIssue {
764            rule_id: "fallow/unused-export".into(),
765            description: huge_desc,
766            severity: "minor".into(),
767            path: "src/a.ts".into(),
768            line: 1,
769            end_line: None,
770            other_locations: Vec::new(),
771            fingerprint: "abc1234567890def".into(),
772        };
773        let comment = comment_to_value(&render_merged_comment(
774            Provider::Github,
775            &[&issue],
776            None,
777            None,
778            false,
779        ));
780        let body = comment["body"].as_str().unwrap();
781        assert!(
782            body.len() <= MAX_COMMENT_BODY_BYTES,
783            "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
784            body.len()
785        );
786        assert!(
787            body.contains("fallow-fingerprint:v2:"),
788            "marker must be preserved under truncation"
789        );
790        assert!(body.contains("<!-- fallow-truncated -->"));
791        assert!(body.contains("> Body truncated by fallow."));
792        assert_eq!(comment["truncated"], true);
793        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
794    }
795
796    #[test]
797    fn oversized_guidance_body_truncates_and_preserves_marker() {
798        let issue = issue_with_desc(
799            "fallow/high-complexity",
800            "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
801            "major",
802            "src/a.ts",
803            1,
804            "abc1234567890def",
805        );
806        let comment = comment_to_value(&render_merged_comment(
807            Provider::Github,
808            &[&issue],
809            None,
810            None,
811            true,
812        ));
813        let body = comment["body"].as_str().unwrap();
814        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
815        assert!(body.contains("<!-- fallow-truncated -->"));
816        assert!(body.contains("fallow-fingerprint:v2:"));
817        assert_eq!(comment["truncated"], true);
818    }
819
820    #[test]
821    fn multibyte_body_truncates_at_char_boundary() {
822        let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
823        let issue = CiIssue {
824            rule_id: "fallow/unused-export".into(),
825            description: huge_desc,
826            severity: "minor".into(),
827            path: "src/a.ts".into(),
828            line: 1,
829            end_line: None,
830            other_locations: Vec::new(),
831            fingerprint: "abc1234567890def".into(),
832        };
833        let comment = comment_to_value(&render_merged_comment(
834            Provider::Github,
835            &[&issue],
836            None,
837            None,
838            false,
839        ));
840        let body = comment["body"].as_str().unwrap();
841        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
842        assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
843        assert_eq!(comment["truncated"], true);
844    }
845}