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