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