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