1use std::process::ExitCode;
2
3use serde_json::Value;
4
5use super::diff_filter::DiffIndex;
6use super::fingerprint::{composite_fingerprint, summary_fingerprint};
7use super::pr_comment::{CiIssue, Provider, command_title, escape_md};
8use super::severity;
9use crate::output_envelope::{
10 GitHubReviewComment, GitHubReviewSide, GitLabReviewComment, GitLabReviewPosition,
11 GitLabReviewPositionType, ReviewCheckConclusion, ReviewComment, ReviewEnvelopeEvent,
12 ReviewEnvelopeMeta, ReviewEnvelopeOutput, ReviewEnvelopeSchema, ReviewEnvelopeSummary,
13 ReviewProvider, default_marker_regex, default_marker_regex_flags,
14};
15use crate::report::emit_json;
16
17const MAX_COMMENT_BODY_BYTES: usize = 65_536;
28
29pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
36
37const MARKER_SUFFIX_V2: &str = " -->";
39
40const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
49
50#[must_use]
51pub fn render_review_envelope(
52 command: &str,
53 provider: Provider,
54 issues: &[CiIssue],
55) -> ReviewEnvelopeOutput {
56 render_review_envelope_with_diff(
57 command,
58 provider,
59 issues,
60 super::diff_filter::shared_diff_index(),
61 )
62}
63
64#[must_use]
69pub fn render_review_envelope_with_diff(
70 command: &str,
71 provider: Provider,
72 issues: &[CiIssue],
73 diff_index: Option<&DiffIndex>,
74) -> ReviewEnvelopeOutput {
75 let max = std::env::var("FALLOW_MAX_COMMENTS")
76 .ok()
77 .and_then(|v| v.parse::<usize>().ok())
78 .unwrap_or(50);
79 let gitlab_diff_refs = (provider == Provider::Gitlab)
80 .then(gitlab_diff_refs_from_env)
81 .flatten();
82 let include_guidance = review_guidance_enabled();
83
84 let merged_groups = group_by_path_line(issues);
87
88 let comments: Vec<ReviewComment> = merged_groups
92 .iter()
93 .take(max)
94 .map(|group| {
95 render_merged_comment(
96 provider,
97 group,
98 gitlab_diff_refs.as_ref(),
99 diff_index,
100 include_guidance,
101 )
102 })
103 .collect();
104
105 let summary_text = format!(
106 "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
107 command_title(command),
108 comments.len(),
109 if comments.len() == 1 { "" } else { "s" },
110 provider.name(),
111 );
112 let summary_fp = summary_fingerprint(&summary_text);
113 let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
114 let body = format!("{summary_text}{summary_marker}");
115 let summary = ReviewEnvelopeSummary {
116 body: body.clone(),
117 fingerprint: summary_fp,
118 };
119
120 match provider {
121 Provider::Github => ReviewEnvelopeOutput {
122 event: Some(ReviewEnvelopeEvent::Comment),
123 body,
124 summary,
125 comments,
126 marker_regex: default_marker_regex(),
127 marker_regex_flags: default_marker_regex_flags(),
128 meta: ReviewEnvelopeMeta {
129 schema: ReviewEnvelopeSchema::V2,
130 provider: ReviewProvider::Github,
131 check_conclusion: Some(github_check_conclusion(issues)),
132 },
133 },
134 Provider::Gitlab => ReviewEnvelopeOutput {
135 event: None,
136 body,
137 summary,
138 comments,
139 marker_regex: default_marker_regex(),
140 marker_regex_flags: default_marker_regex_flags(),
141 meta: ReviewEnvelopeMeta {
142 schema: ReviewEnvelopeSchema::V2,
143 provider: ReviewProvider::Gitlab,
144 check_conclusion: None,
145 },
146 },
147 }
148}
149
150#[must_use]
151pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
152 let issues = super::diff_filter::filter_issues_from_env(
153 super::pr_comment::issues_from_codeclimate(codeclimate),
154 );
155 let envelope = render_review_envelope(command, provider, &issues);
156 let value =
157 serde_json::to_value(&envelope).expect("ReviewEnvelopeOutput serializes infallibly");
158 emit_json(&value, "review envelope")
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
162#[expect(
163 clippy::struct_field_names,
164 reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
165)]
166struct GitlabDiffRefs {
167 base_sha: String,
168 start_sha: String,
169 head_sha: String,
170}
171
172fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
173 let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
174 .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
175 let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
176 let head_sha =
177 env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
178 Some(GitlabDiffRefs {
179 base_sha,
180 start_sha,
181 head_sha,
182 })
183}
184
185fn env_nonempty(name: &str) -> Option<String> {
186 std::env::var(name)
187 .ok()
188 .filter(|value| !value.trim().is_empty())
189}
190
191fn review_guidance_enabled() -> bool {
192 std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
193}
194
195fn env_truthy(value: &str) -> bool {
196 matches!(
197 value.trim().to_ascii_lowercase().as_str(),
198 "1" | "true" | "yes" | "on"
199 )
200}
201
202fn group_by_path_line(issues: &[CiIssue]) -> Vec<Vec<&CiIssue>> {
205 let mut groups: Vec<Vec<&CiIssue>> = Vec::new();
206 let mut current: Vec<&CiIssue> = Vec::new();
207 let mut current_key: Option<(&str, u64)> = None;
208 for issue in issues {
209 let key = (issue.path.as_str(), issue.line);
210 if Some(key) != current_key {
211 if !current.is_empty() {
212 groups.push(std::mem::take(&mut current));
213 }
214 current_key = Some(key);
215 }
216 current.push(issue);
217 }
218 if !current.is_empty() {
219 groups.push(current);
220 }
221 groups
222}
223
224fn render_merged_comment(
233 provider: Provider,
234 group: &[&CiIssue],
235 gitlab_diff_refs: Option<&GitlabDiffRefs>,
236 diff_index: Option<&DiffIndex>,
237 include_guidance: bool,
238) -> ReviewComment {
239 assert!(!group.is_empty(), "group_by_path_line never yields empty");
240 let representative = group[0];
241 let fingerprint = if group.len() == 1 {
242 representative.fingerprint.clone()
243 } else {
244 let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
245 composite_fingerprint(&constituents)
246 };
247
248 use std::fmt::Write as _;
251 let mut content = String::new();
252 for (index, issue) in group.iter().enumerate() {
253 let label = review_label_from_codeclimate(&issue.severity);
254 if index > 0 {
255 content.push_str("\n\n");
256 }
257 write!(
258 content,
259 "**{}** `{}`: {}",
260 label,
261 escape_md(&issue.rule_id),
262 escape_md(&issue.description)
263 )
264 .expect("write to String is infallible");
265 if let Some(suggestion) = super::suggestion::suggestion_block(provider, issue) {
266 content.push_str(&suggestion);
267 }
268 if include_guidance && let Some(guidance) = review_guidance_block(issue) {
269 content.push_str(&guidance);
270 }
271 }
272
273 let marker_line = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
274 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
275
276 match provider {
277 Provider::Github => ReviewComment::GitHub(GitHubReviewComment {
280 path: representative.path.clone(),
281 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
288 side: GitHubReviewSide::Right,
289 body,
290 fingerprint,
291 truncated,
292 }),
293 Provider::Gitlab => {
294 let new_path = representative.path.clone();
301 let old_path = diff_index
302 .and_then(|di| di.old_path_for(&new_path))
303 .map_or_else(|| new_path.clone(), str::to_owned);
304 let position = GitLabReviewPosition {
305 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
306 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
307 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
308 position_type: GitLabReviewPositionType::Text,
309 old_path,
310 new_path,
311 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
314 };
315 ReviewComment::GitLab(GitLabReviewComment {
316 body,
317 position,
318 fingerprint,
319 truncated,
320 })
321 }
322 }
323}
324
325fn review_guidance_block(issue: &CiIssue) -> Option<String> {
326 let rule = crate::explain::rule_by_id(&issue.rule_id)?;
327 let guide = crate::explain::rule_guide(rule);
328 let docs_url = crate::explain::rule_docs_url(rule);
329
330 Some(format!(
331 "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
332 guide.how_to_fix
333 ))
334}
335
336fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
342 let intact_len = content.len() + marker_line.len();
343 if intact_len <= MAX_COMMENT_BODY_BYTES {
344 let mut out = String::with_capacity(intact_len);
345 out.push_str(content);
346 out.push_str(marker_line);
347 return (out, false);
348 }
349 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
355 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
356 let mut cut = budget.min(content.len());
357 while cut > 0 && !content.is_char_boundary(cut) {
358 cut -= 1;
359 }
360 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
361 out.push_str(&content[..cut]);
362 out.push_str(TRUNCATION_SUFFIX);
363 out.push_str(marker_line);
364 (out, true)
365}
366
367fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
368 match severity_name {
369 "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
370 _ => severity::review_label(fallow_config::Severity::Warn),
371 }
372}
373
374fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
375 if issues
376 .iter()
377 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
378 {
379 ReviewCheckConclusion::Failure
380 } else if issues.is_empty() {
381 ReviewCheckConclusion::Success
382 } else {
383 ReviewCheckConclusion::Neutral
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::output_envelope::MARKER_REGEX_V2;
391
392 fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
393 serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
394 }
395
396 fn comment_to_value(comment: &ReviewComment) -> Value {
397 serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
398 }
399
400 fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
401 CiIssue {
402 rule_id: rule.into(),
403 description: "desc".into(),
404 severity: sev.into(),
405 path: path.into(),
406 line,
407 fingerprint: fp.into(),
408 }
409 }
410
411 fn issue_with_desc(
412 rule: &str,
413 desc: impl Into<String>,
414 sev: &str,
415 path: &str,
416 line: u64,
417 fp: &str,
418 ) -> CiIssue {
419 CiIssue {
420 rule_id: rule.into(),
421 description: desc.into(),
422 severity: sev.into(),
423 path: path.into(),
424 line,
425 fingerprint: fp.into(),
426 }
427 }
428
429 #[test]
430 fn github_review_envelope_matches_api_shape() {
431 let issues = vec![issue(
432 "fallow/unused-file",
433 "minor",
434 "src/a.ts",
435 1,
436 "abc1234567890def",
437 )];
438 let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
439 assert_eq!(envelope["event"], "COMMENT");
440 assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v2");
441 assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
442 assert!(
443 envelope["comments"][0]["body"]
444 .as_str()
445 .unwrap()
446 .contains("fallow-fingerprint:v2:")
447 );
448 }
449
450 #[test]
451 fn github_comments_target_current_state_side() {
452 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
453 let comment = comment_to_value(&render_merged_comment(
454 Provider::Github,
455 &[&issue],
456 None,
457 None,
458 false,
459 ));
460 assert_eq!(comment["side"], "RIGHT");
461 }
462
463 #[test]
464 fn labels_major_issues_as_errors() {
465 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
466 let comment = comment_to_value(&render_merged_comment(
467 Provider::Github,
468 &[&issue],
469 None,
470 None,
471 false,
472 ));
473 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
474 }
475
476 #[test]
477 fn gitlab_comment_accepts_diff_refs() {
478 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
479 let refs = GitlabDiffRefs {
480 base_sha: "base".into(),
481 start_sha: "start".into(),
482 head_sha: "head".into(),
483 };
484 let comment = comment_to_value(&render_merged_comment(
485 Provider::Gitlab,
486 &[&issue],
487 Some(&refs),
488 None,
489 false,
490 ));
491 assert_eq!(comment["position"]["position_type"], "text");
492 assert_eq!(comment["position"]["base_sha"], "base");
493 assert_eq!(comment["position"]["start_sha"], "start");
494 assert_eq!(comment["position"]["head_sha"], "head");
495 }
496
497 #[test]
498 fn guidance_toggle_accepts_common_truthy_values() {
499 for value in ["1", "true", "TRUE", "yes", "on", " On "] {
500 assert!(env_truthy(value), "{value:?} should enable guidance");
501 }
502 for value in ["", "0", "false", "no", "off", "enabled"] {
503 assert!(!env_truthy(value), "{value:?} should not enable guidance");
504 }
505 }
506
507 #[test]
508 fn guidance_disabled_omits_details_block() {
509 let issue = issue(
510 "fallow/high-complexity",
511 "major",
512 "src/a.ts",
513 10,
514 "abc1234567890def",
515 );
516 let comment = comment_to_value(&render_merged_comment(
517 Provider::Github,
518 &[&issue],
519 None,
520 None,
521 false,
522 ));
523 let body = comment["body"].as_str().unwrap();
524 assert!(!body.contains("<details><summary>What to do</summary>"));
525 assert!(!body.contains("For function findings"));
526 }
527
528 #[test]
529 fn guidance_enabled_appends_rule_guide_details() {
530 let issue = issue(
531 "fallow/high-complexity",
532 "major",
533 "src/a.ts",
534 10,
535 "abc1234567890def",
536 );
537 let comment = comment_to_value(&render_merged_comment(
538 Provider::Github,
539 &[&issue],
540 None,
541 None,
542 true,
543 ));
544 let body = comment["body"].as_str().unwrap();
545 assert!(body.contains("<details><summary>What to do</summary>"));
546 assert!(body.contains("For function findings"));
547 assert!(body.contains("[Read the rule docs]("));
548 assert!(
549 body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
550 "guidance should render before the marker"
551 );
552 }
553
554 #[test]
555 fn guidance_attaches_to_each_merged_finding() {
556 let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
557 let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
558 let comment = comment_to_value(&render_merged_comment(
559 Provider::Github,
560 &[&complexity, &duplication],
561 None,
562 None,
563 true,
564 ));
565 let body = comment["body"].as_str().unwrap();
566 assert_eq!(
567 body.matches("<details><summary>What to do</summary>")
568 .count(),
569 2
570 );
571 assert!(body.contains("For function findings"));
572 assert!(body.contains("Extract the shared logic"));
573 }
574
575 #[test]
576 fn envelope_emits_marker_regex_field_at_root() {
577 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
578 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
579 let regex = env["marker_regex"].as_str().expect("marker_regex present");
580 assert_eq!(regex, MARKER_REGEX_V2);
581 assert!(regex.contains("[0-9a-f]{16}"));
584 assert!(regex.starts_with('^'));
588 assert!(regex.ends_with("\\s*$"));
589 assert!(!regex.contains("(?m)"));
592 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
596 let flags = env["marker_regex_flags"]
598 .as_str()
599 .expect("marker_regex_flags present");
600 assert_eq!(flags, "m");
601 }
602
603 #[test]
604 fn envelope_emits_summary_block_with_fingerprint() {
605 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
606 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
607 assert_eq!(env["summary"]["body"], env["body"]);
608 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
609 assert_eq!(summary_fp.len(), 16);
610 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
611 let body_str = env["body"].as_str().unwrap();
613 let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
614 assert!(
615 body_str.contains(&marker_line),
616 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
617 );
618 }
619
620 #[test]
621 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
622 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
623 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
624 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
625 assert_eq!(
626 env["comments"].as_array().unwrap().len(),
627 1,
628 "two same-line findings must collapse to one comment"
629 );
630 let merged = &env["comments"][0];
631 let fp = merged["fingerprint"].as_str().unwrap();
632 assert!(
633 fp.starts_with("merged:"),
634 "merged comment fingerprint must start with merged:, got {fp}"
635 );
636 assert_eq!(fp.len(), 23);
638 let body = merged["body"].as_str().unwrap();
640 assert!(body.contains("fallow/unused-export"));
641 assert!(body.contains("fallow/duplicate-export"));
642 assert_eq!(
643 body.matches("fallow-fingerprint:v2:").count(),
644 1,
645 "merged body must carry exactly one fingerprint marker"
646 );
647 assert!(
650 merged.get("constituent_fingerprints").is_none(),
651 "v2 hashed-composite design does not emit constituent_fingerprints"
652 );
653 }
654
655 #[test]
656 fn single_finding_keeps_v1_fingerprint_shape() {
657 let issues = vec![issue(
658 "fallow/unused-file",
659 "minor",
660 "src/a.ts",
661 1,
662 "abc1234567890def",
663 )];
664 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
665 let comment = &env["comments"][0];
666 assert_eq!(comment["fingerprint"], "abc1234567890def");
667 assert!(
668 comment.get("constituent_fingerprints").is_none(),
669 "single-finding comment must NOT emit constituent_fingerprints"
670 );
671 assert!(
672 comment.get("truncated").is_none(),
673 "non-truncated comment must NOT emit truncated"
674 );
675 }
676
677 #[test]
678 fn composite_fingerprint_shifts_when_constituents_change() {
679 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
683 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
684 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
685 let run1 = to_value(&render_review_envelope(
686 "check",
687 Provider::Github,
688 &[a.clone(), b, c.clone()],
689 ));
690 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
691 assert_ne!(
692 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
693 "primary fingerprint must shift when a constituent drops"
694 );
695 }
696
697 #[test]
698 fn gitlab_old_path_pulls_from_diff_rename_map() {
699 let rename_diff = "\
700diff --git a/src/old.ts b/src/new.ts
701similarity index 90%
702rename from src/old.ts
703rename to src/new.ts
704--- a/src/old.ts
705+++ b/src/new.ts
706@@ -1,2 +1,3 @@
707 keep
708+added
709 still
710";
711 let diff_index = DiffIndex::from_unified_diff(rename_diff);
712 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
713 let envelope = to_value(&render_review_envelope_with_diff(
714 "check",
715 Provider::Gitlab,
716 &[issue],
717 Some(&diff_index),
718 ));
719 let position = &envelope["comments"][0]["position"];
720 assert_eq!(position["old_path"], "src/old.ts");
721 assert_eq!(position["new_path"], "src/new.ts");
722 }
723
724 #[test]
725 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
726 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
727 let envelope = to_value(&render_review_envelope_with_diff(
728 "check",
729 Provider::Gitlab,
730 &[issue],
731 None,
732 ));
733 let position = &envelope["comments"][0]["position"];
734 assert_eq!(position["old_path"], "src/edit.ts");
735 assert_eq!(position["new_path"], "src/edit.ts");
736 }
737
738 #[test]
739 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
740 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
742 let issue = CiIssue {
743 rule_id: "fallow/unused-export".into(),
744 description: huge_desc,
745 severity: "minor".into(),
746 path: "src/a.ts".into(),
747 line: 1,
748 fingerprint: "abc1234567890def".into(),
749 };
750 let comment = comment_to_value(&render_merged_comment(
751 Provider::Github,
752 &[&issue],
753 None,
754 None,
755 false,
756 ));
757 let body = comment["body"].as_str().unwrap();
758 assert!(
759 body.len() <= MAX_COMMENT_BODY_BYTES,
760 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
761 body.len()
762 );
763 assert!(
765 body.contains("fallow-fingerprint:v2:"),
766 "marker must be preserved under truncation"
767 );
768 assert!(body.contains("<!-- fallow-truncated -->"));
771 assert!(body.contains("> Body truncated by fallow."));
772 assert_eq!(comment["truncated"], true);
774 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
776 }
777
778 #[test]
779 fn oversized_guidance_body_truncates_and_preserves_marker() {
780 let issue = issue_with_desc(
781 "fallow/high-complexity",
782 "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
783 "major",
784 "src/a.ts",
785 1,
786 "abc1234567890def",
787 );
788 let comment = comment_to_value(&render_merged_comment(
789 Provider::Github,
790 &[&issue],
791 None,
792 None,
793 true,
794 ));
795 let body = comment["body"].as_str().unwrap();
796 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
797 assert!(body.contains("<!-- fallow-truncated -->"));
798 assert!(body.contains("fallow-fingerprint:v2:"));
799 assert_eq!(comment["truncated"], true);
800 }
801
802 #[test]
803 fn multibyte_body_truncates_at_char_boundary() {
804 let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
807 let issue = CiIssue {
808 rule_id: "fallow/unused-export".into(),
809 description: huge_desc,
810 severity: "minor".into(),
811 path: "src/a.ts".into(),
812 line: 1,
813 fingerprint: "abc1234567890def".into(),
814 };
815 let comment = comment_to_value(&render_merged_comment(
816 Provider::Github,
817 &[&issue],
818 None,
819 None,
820 false,
821 ));
822 let body = comment["body"].as_str().unwrap();
823 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
828 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
829 assert_eq!(comment["truncated"], true);
830 }
831}