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