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);
85
86 let comments: Vec<ReviewComment> = merged_groups
87 .iter()
88 .take(max)
89 .map(|group| {
90 render_merged_comment(
91 provider,
92 group,
93 gitlab_diff_refs.as_ref(),
94 diff_index,
95 include_guidance,
96 )
97 })
98 .collect();
99
100 let summary_text = format!(
101 "### Fallow {}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
102 command_title(command),
103 comments.len(),
104 if comments.len() == 1 { "" } else { "s" },
105 provider.name(),
106 );
107 let summary_fp = summary_fingerprint(&summary_text);
108 let summary_marker = format!("\n\n{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
109 let body = format!("{summary_text}{summary_marker}");
110 let summary = ReviewEnvelopeSummary {
111 body: body.clone(),
112 fingerprint: summary_fp,
113 };
114
115 match provider {
116 Provider::Github => ReviewEnvelopeOutput {
117 event: Some(ReviewEnvelopeEvent::Comment),
118 body,
119 summary,
120 comments,
121 marker_regex: default_marker_regex(),
122 marker_regex_flags: default_marker_regex_flags(),
123 meta: ReviewEnvelopeMeta {
124 schema: ReviewEnvelopeSchema::V2,
125 provider: ReviewProvider::Github,
126 check_conclusion: Some(github_check_conclusion(issues)),
127 },
128 },
129 Provider::Gitlab => ReviewEnvelopeOutput {
130 event: None,
131 body,
132 summary,
133 comments,
134 marker_regex: default_marker_regex(),
135 marker_regex_flags: default_marker_regex_flags(),
136 meta: ReviewEnvelopeMeta {
137 schema: ReviewEnvelopeSchema::V2,
138 provider: ReviewProvider::Gitlab,
139 check_conclusion: None,
140 },
141 },
142 }
143}
144
145#[must_use]
146#[expect(
147 clippy::expect_used,
148 reason = "review envelope contains only infallibly serializable fields"
149)]
150pub fn print_review_envelope(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
151 let issues = super::diff_filter::filter_issues_from_env(
152 super::pr_comment::issues_from_codeclimate(codeclimate),
153 );
154 let envelope = render_review_envelope(command, provider, &issues);
155 let value = crate::output_envelope::serialize_root_output(
156 crate::output_envelope::FallowOutput::ReviewEnvelope(envelope),
157 )
158 .expect("ReviewEnvelopeOutput serializes infallibly");
159 emit_json(&value, "review envelope")
160}
161
162#[derive(Clone, Debug, PartialEq, Eq)]
163#[expect(
164 clippy::struct_field_names,
165 reason = "GitLab API names these diff refs base_sha/start_sha/head_sha"
166)]
167struct GitlabDiffRefs {
168 base_sha: String,
169 start_sha: String,
170 head_sha: String,
171}
172
173fn gitlab_diff_refs_from_env() -> Option<GitlabDiffRefs> {
174 let base_sha = env_nonempty("FALLOW_GITLAB_BASE_SHA")
175 .or_else(|| env_nonempty("CI_MERGE_REQUEST_DIFF_BASE_SHA"))?;
176 let start_sha = env_nonempty("FALLOW_GITLAB_START_SHA").unwrap_or_else(|| base_sha.clone());
177 let head_sha =
178 env_nonempty("FALLOW_GITLAB_HEAD_SHA").or_else(|| env_nonempty("CI_COMMIT_SHA"))?;
179 Some(GitlabDiffRefs {
180 base_sha,
181 start_sha,
182 head_sha,
183 })
184}
185
186fn env_nonempty(name: &str) -> Option<String> {
187 std::env::var(name)
188 .ok()
189 .filter(|value| !value.trim().is_empty())
190}
191
192fn review_guidance_enabled() -> bool {
193 std::env::var("FALLOW_REVIEW_GUIDANCE").is_ok_and(|value| env_truthy(&value))
194}
195
196fn env_truthy(value: &str) -> bool {
197 matches!(
198 value.trim().to_ascii_lowercase().as_str(),
199 "1" | "true" | "yes" | "on"
200 )
201}
202
203fn group_by_path_line(issues: &[CiIssue]) -> Vec<Vec<&CiIssue>> {
206 let mut groups: Vec<Vec<&CiIssue>> = Vec::new();
207 let mut current: Vec<&CiIssue> = Vec::new();
208 let mut current_key: Option<(&str, u64)> = None;
209 for issue in issues {
210 let key = (issue.path.as_str(), issue.line);
211 if Some(key) != current_key {
212 if !current.is_empty() {
213 groups.push(std::mem::take(&mut current));
214 }
215 current_key = Some(key);
216 }
217 current.push(issue);
218 }
219 if !current.is_empty() {
220 groups.push(current);
221 }
222 groups
223}
224
225#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
234fn render_merged_comment(
235 provider: Provider,
236 group: &[&CiIssue],
237 gitlab_diff_refs: Option<&GitlabDiffRefs>,
238 diff_index: Option<&DiffIndex>,
239 include_guidance: bool,
240) -> ReviewComment {
241 assert!(!group.is_empty(), "group_by_path_line never yields empty");
242 let representative = group[0];
243 let fingerprint = if group.len() == 1 {
244 representative.fingerprint.clone()
245 } else {
246 let constituents: Vec<&str> = group.iter().map(|i| i.fingerprint.as_str()).collect();
247 composite_fingerprint(&constituents)
248 };
249
250 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 {
278 path: representative.path.clone(),
279 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
280 side: GitHubReviewSide::Right,
281 body,
282 fingerprint,
283 truncated,
284 }),
285 Provider::Gitlab => {
286 let new_path = representative.path.clone();
287 let old_path = diff_index
288 .and_then(|di| di.old_path_for(&new_path))
289 .map_or_else(|| new_path.clone(), str::to_owned);
290 let position = GitLabReviewPosition {
291 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
292 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
293 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
294 position_type: GitLabReviewPositionType::Text,
295 old_path,
296 new_path,
297 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
298 };
299 ReviewComment::GitLab(GitLabReviewComment {
300 body,
301 position,
302 fingerprint,
303 truncated,
304 })
305 }
306 }
307}
308
309fn review_guidance_block(issue: &CiIssue) -> Option<String> {
310 let rule = crate::explain::rule_by_id(&issue.rule_id)?;
311 let guide = crate::explain::rule_guide(rule);
312 let docs_url = crate::explain::rule_docs_url(rule);
313
314 Some(format!(
315 "\n\n<details><summary>What to do</summary>\n\n{}\n\n[Read the rule docs]({docs_url})\n\n</details>",
316 guide.how_to_fix
317 ))
318}
319
320fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
326 let intact_len = content.len() + marker_line.len();
327 if intact_len <= MAX_COMMENT_BODY_BYTES {
328 let mut out = String::with_capacity(intact_len);
329 out.push_str(content);
330 out.push_str(marker_line);
331 return (out, false);
332 }
333 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
334 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
335 let mut cut = budget.min(content.len());
336 while cut > 0 && !content.is_char_boundary(cut) {
337 cut -= 1;
338 }
339 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
340 out.push_str(&content[..cut]);
341 out.push_str(TRUNCATION_SUFFIX);
342 out.push_str(marker_line);
343 (out, true)
344}
345
346fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
347 match severity_name {
348 "major" | "critical" | "blocker" => severity::review_label(fallow_config::Severity::Error),
349 _ => severity::review_label(fallow_config::Severity::Warn),
350 }
351}
352
353fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
354 if issues
355 .iter()
356 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
357 {
358 ReviewCheckConclusion::Failure
359 } else if issues.is_empty() {
360 ReviewCheckConclusion::Success
361 } else {
362 ReviewCheckConclusion::Neutral
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::output_envelope::MARKER_REGEX_V2;
370
371 fn to_value(envelope: &ReviewEnvelopeOutput) -> Value {
372 serde_json::to_value(envelope).expect("ReviewEnvelopeOutput serializes infallibly")
373 }
374
375 fn comment_to_value(comment: &ReviewComment) -> Value {
376 serde_json::to_value(comment).expect("ReviewComment serializes infallibly")
377 }
378
379 fn issue(rule: &str, sev: &str, path: &str, line: u64, fp: &str) -> CiIssue {
380 CiIssue {
381 rule_id: rule.into(),
382 description: "desc".into(),
383 severity: sev.into(),
384 path: path.into(),
385 line,
386 fingerprint: fp.into(),
387 }
388 }
389
390 fn issue_with_desc(
391 rule: &str,
392 desc: impl Into<String>,
393 sev: &str,
394 path: &str,
395 line: u64,
396 fp: &str,
397 ) -> CiIssue {
398 CiIssue {
399 rule_id: rule.into(),
400 description: desc.into(),
401 severity: sev.into(),
402 path: path.into(),
403 line,
404 fingerprint: fp.into(),
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/v2");
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 github_comments_target_current_state_side() {
431 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
432 let comment = comment_to_value(&render_merged_comment(
433 Provider::Github,
434 &[&issue],
435 None,
436 None,
437 false,
438 ));
439 assert_eq!(comment["side"], "RIGHT");
440 }
441
442 #[test]
443 fn labels_major_issues_as_errors() {
444 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
445 let comment = comment_to_value(&render_merged_comment(
446 Provider::Github,
447 &[&issue],
448 None,
449 None,
450 false,
451 ));
452 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
453 }
454
455 #[test]
456 fn gitlab_comment_accepts_diff_refs() {
457 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
458 let refs = GitlabDiffRefs {
459 base_sha: "base".into(),
460 start_sha: "start".into(),
461 head_sha: "head".into(),
462 };
463 let comment = comment_to_value(&render_merged_comment(
464 Provider::Gitlab,
465 &[&issue],
466 Some(&refs),
467 None,
468 false,
469 ));
470 assert_eq!(comment["position"]["position_type"], "text");
471 assert_eq!(comment["position"]["base_sha"], "base");
472 assert_eq!(comment["position"]["start_sha"], "start");
473 assert_eq!(comment["position"]["head_sha"], "head");
474 }
475
476 #[test]
477 fn guidance_toggle_accepts_common_truthy_values() {
478 for value in ["1", "true", "TRUE", "yes", "on", " On "] {
479 assert!(env_truthy(value), "{value:?} should enable guidance");
480 }
481 for value in ["", "0", "false", "no", "off", "enabled"] {
482 assert!(!env_truthy(value), "{value:?} should not enable guidance");
483 }
484 }
485
486 #[test]
487 fn guidance_disabled_omits_details_block() {
488 let issue = issue(
489 "fallow/high-complexity",
490 "major",
491 "src/a.ts",
492 10,
493 "abc1234567890def",
494 );
495 let comment = comment_to_value(&render_merged_comment(
496 Provider::Github,
497 &[&issue],
498 None,
499 None,
500 false,
501 ));
502 let body = comment["body"].as_str().unwrap();
503 assert!(!body.contains("<details><summary>What to do</summary>"));
504 assert!(!body.contains("For function findings"));
505 }
506
507 #[test]
508 fn guidance_enabled_appends_rule_guide_details() {
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 true,
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 assert!(body.contains("[Read the rule docs]("));
527 assert!(
528 body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
529 "guidance should render before the marker"
530 );
531 }
532
533 #[test]
534 fn guidance_attaches_to_each_merged_finding() {
535 let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
536 let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
537 let comment = comment_to_value(&render_merged_comment(
538 Provider::Github,
539 &[&complexity, &duplication],
540 None,
541 None,
542 true,
543 ));
544 let body = comment["body"].as_str().unwrap();
545 assert_eq!(
546 body.matches("<details><summary>What to do</summary>")
547 .count(),
548 2
549 );
550 assert!(body.contains("For function findings"));
551 assert!(body.contains("Extract the shared logic"));
552 }
553
554 #[test]
555 fn envelope_emits_marker_regex_field_at_root() {
556 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
557 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
558 let regex = env["marker_regex"].as_str().expect("marker_regex present");
559 assert_eq!(regex, MARKER_REGEX_V2);
560 assert!(regex.contains("[0-9a-f]{16}"));
561 assert!(regex.starts_with('^'));
562 assert!(regex.ends_with("\\s*$"));
563 assert!(!regex.contains("(?m)"));
564 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
565 let flags = env["marker_regex_flags"]
566 .as_str()
567 .expect("marker_regex_flags present");
568 assert_eq!(flags, "m");
569 }
570
571 #[test]
572 fn envelope_emits_summary_block_with_fingerprint() {
573 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
574 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
575 assert_eq!(env["summary"]["body"], env["body"]);
576 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
577 assert_eq!(summary_fp.len(), 16);
578 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
579 let body_str = env["body"].as_str().unwrap();
580 let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
581 assert!(
582 body_str.contains(&marker_line),
583 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
584 );
585 }
586
587 #[test]
588 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
589 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
590 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
591 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
592 assert_eq!(
593 env["comments"].as_array().unwrap().len(),
594 1,
595 "two same-line findings must collapse to one comment"
596 );
597 let merged = &env["comments"][0];
598 let fp = merged["fingerprint"].as_str().unwrap();
599 assert!(
600 fp.starts_with("merged:"),
601 "merged comment fingerprint must start with merged:, got {fp}"
602 );
603 assert_eq!(fp.len(), 23);
604 let body = merged["body"].as_str().unwrap();
605 assert!(body.contains("fallow/unused-export"));
606 assert!(body.contains("fallow/duplicate-export"));
607 assert_eq!(
608 body.matches("fallow-fingerprint:v2:").count(),
609 1,
610 "merged body must carry exactly one fingerprint marker"
611 );
612 assert!(
613 merged.get("constituent_fingerprints").is_none(),
614 "v2 hashed-composite design does not emit constituent_fingerprints"
615 );
616 }
617
618 #[test]
619 fn single_finding_keeps_v1_fingerprint_shape() {
620 let issues = vec![issue(
621 "fallow/unused-file",
622 "minor",
623 "src/a.ts",
624 1,
625 "abc1234567890def",
626 )];
627 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
628 let comment = &env["comments"][0];
629 assert_eq!(comment["fingerprint"], "abc1234567890def");
630 assert!(
631 comment.get("constituent_fingerprints").is_none(),
632 "single-finding comment must NOT emit constituent_fingerprints"
633 );
634 assert!(
635 comment.get("truncated").is_none(),
636 "non-truncated comment must NOT emit truncated"
637 );
638 }
639
640 #[test]
641 fn composite_fingerprint_shifts_when_constituents_change() {
642 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
643 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
644 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
645 let run1 = to_value(&render_review_envelope(
646 "check",
647 Provider::Github,
648 &[a.clone(), b, c.clone()],
649 ));
650 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
651 assert_ne!(
652 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
653 "primary fingerprint must shift when a constituent drops"
654 );
655 }
656
657 #[test]
658 fn gitlab_old_path_pulls_from_diff_rename_map() {
659 let rename_diff = "\
660diff --git a/src/old.ts b/src/new.ts
661similarity index 90%
662rename from src/old.ts
663rename to src/new.ts
664--- a/src/old.ts
665+++ b/src/new.ts
666@@ -1,2 +1,3 @@
667 keep
668+added
669 still
670";
671 let diff_index = DiffIndex::from_unified_diff(rename_diff);
672 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
673 let envelope = to_value(&render_review_envelope_with_diff(
674 "check",
675 Provider::Gitlab,
676 &[issue],
677 Some(&diff_index),
678 ));
679 let position = &envelope["comments"][0]["position"];
680 assert_eq!(position["old_path"], "src/old.ts");
681 assert_eq!(position["new_path"], "src/new.ts");
682 }
683
684 #[test]
685 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
686 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
687 let envelope = to_value(&render_review_envelope_with_diff(
688 "check",
689 Provider::Gitlab,
690 &[issue],
691 None,
692 ));
693 let position = &envelope["comments"][0]["position"];
694 assert_eq!(position["old_path"], "src/edit.ts");
695 assert_eq!(position["new_path"], "src/edit.ts");
696 }
697
698 #[test]
699 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
700 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
701 let issue = CiIssue {
702 rule_id: "fallow/unused-export".into(),
703 description: huge_desc,
704 severity: "minor".into(),
705 path: "src/a.ts".into(),
706 line: 1,
707 fingerprint: "abc1234567890def".into(),
708 };
709 let comment = comment_to_value(&render_merged_comment(
710 Provider::Github,
711 &[&issue],
712 None,
713 None,
714 false,
715 ));
716 let body = comment["body"].as_str().unwrap();
717 assert!(
718 body.len() <= MAX_COMMENT_BODY_BYTES,
719 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
720 body.len()
721 );
722 assert!(
723 body.contains("fallow-fingerprint:v2:"),
724 "marker must be preserved under truncation"
725 );
726 assert!(body.contains("<!-- fallow-truncated -->"));
727 assert!(body.contains("> Body truncated by fallow."));
728 assert_eq!(comment["truncated"], true);
729 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
730 }
731
732 #[test]
733 fn oversized_guidance_body_truncates_and_preserves_marker() {
734 let issue = issue_with_desc(
735 "fallow/high-complexity",
736 "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
737 "major",
738 "src/a.ts",
739 1,
740 "abc1234567890def",
741 );
742 let comment = comment_to_value(&render_merged_comment(
743 Provider::Github,
744 &[&issue],
745 None,
746 None,
747 true,
748 ));
749 let body = comment["body"].as_str().unwrap();
750 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
751 assert!(body.contains("<!-- fallow-truncated -->"));
752 assert!(body.contains("fallow-fingerprint:v2:"));
753 assert_eq!(comment["truncated"], true);
754 }
755
756 #[test]
757 fn multibyte_body_truncates_at_char_boundary() {
758 let huge_desc: String = "あ".repeat(MAX_COMMENT_BODY_BYTES);
759 let issue = CiIssue {
760 rule_id: "fallow/unused-export".into(),
761 description: huge_desc,
762 severity: "minor".into(),
763 path: "src/a.ts".into(),
764 line: 1,
765 fingerprint: "abc1234567890def".into(),
766 };
767 let comment = comment_to_value(&render_merged_comment(
768 Provider::Github,
769 &[&issue],
770 None,
771 None,
772 false,
773 ));
774 let body = comment["body"].as_str().unwrap();
775 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
776 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
777 assert_eq!(comment["truncated"], true);
778 }
779}