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#[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
70fn 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}