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 fingerprint: fp.into(),
334 }
335 }
336
337 fn issue_with_desc(
338 rule: &str,
339 desc: impl Into<String>,
340 sev: &str,
341 path: &str,
342 line: u64,
343 fp: &str,
344 ) -> CiIssue {
345 CiIssue {
346 rule_id: rule.into(),
347 description: desc.into(),
348 severity: sev.into(),
349 path: path.into(),
350 line,
351 fingerprint: fp.into(),
352 }
353 }
354
355 #[test]
356 fn scoped_renderer_uses_typed_meta_and_exact_markers() {
357 let issue = issue(
358 "fallow/unused-file",
359 "minor",
360 "src/a.ts",
361 1,
362 "abcdef0123456789",
363 );
364 let review_id = ReviewId::parse("frontend").unwrap();
365 let envelope = render_review_envelope_with_diff(
366 "check",
367 Provider::Github,
368 &[issue],
369 None,
370 Some(&review_id),
371 None,
372 None,
373 );
374 let envelope = fallow_output::serialize_scoped_review_envelope_json_output(
375 &envelope,
376 &review_id,
377 crate::output_runtime::current_root_envelope_mode(),
378 None,
379 )
380 .unwrap();
381
382 assert_eq!(envelope["meta"]["review_id"], "frontend");
383 assert!(
384 envelope["body"]
385 .as_str()
386 .unwrap()
387 .ends_with("<!-- fallow-review-id: frontend -->")
388 );
389 assert!(
390 envelope["comments"][0]["body"]
391 .as_str()
392 .unwrap()
393 .ends_with("<!-- fallow-review-id: frontend -->")
394 );
395 }
396
397 #[test]
398 fn github_review_envelope_matches_api_shape() {
399 let issues = vec![issue(
400 "fallow/unused-file",
401 "minor",
402 "src/a.ts",
403 1,
404 "abc1234567890def",
405 )];
406 let envelope = to_value(&render_review_envelope("check", Provider::Github, &issues));
407 assert_eq!(envelope["event"], "COMMENT");
408 assert_eq!(envelope["meta"]["schema"], "fallow-review-envelope/v3");
409 assert_eq!(envelope["comments"][0]["path"], "src/a.ts");
410 assert!(
411 envelope["comments"][0]["body"]
412 .as_str()
413 .unwrap()
414 .contains("fallow-fingerprint:v2:")
415 );
416 }
417
418 #[test]
419 fn review_summary_body_leads_with_decision() {
420 let issues = vec![issue(
421 "fallow/unused-file",
422 "major",
423 "src/a.ts",
424 1,
425 "abc1234567890def",
426 )];
427 let envelope = to_value(&render_review_envelope(
428 "combined",
429 Provider::Github,
430 &issues,
431 ));
432 let body = envelope["body"].as_str().expect("body is string");
433
434 assert!(body.contains("Quality gate failed"), "{body}");
435 assert!(body.contains("1 inline finding selected"), "{body}");
436 assert!(body.contains("<!-- fallow-review -->"), "{body}");
437 }
438
439 #[test]
440 fn github_comments_target_current_state_side() {
441 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
442 let comment = comment_to_value(&render_merged_comment(
443 Provider::Github,
444 &[&issue],
445 None,
446 None,
447 false,
448 ));
449 assert_eq!(comment["side"], "RIGHT");
450 }
451
452 #[test]
453 fn labels_major_issues_as_errors() {
454 let issue = issue("fallow/unused-file", "major", "src/a.ts", 1, "abc");
455 let comment = comment_to_value(&render_merged_comment(
456 Provider::Github,
457 &[&issue],
458 None,
459 None,
460 false,
461 ));
462 assert!(comment["body"].as_str().unwrap().starts_with("**error**"));
463 }
464
465 #[test]
466 fn gitlab_comment_accepts_diff_refs() {
467 let issue = issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc");
468 let refs = GitlabDiffRefs {
469 base_sha: "base".into(),
470 start_sha: "start".into(),
471 head_sha: "head".into(),
472 };
473 let comment = comment_to_value(&render_merged_comment(
474 Provider::Gitlab,
475 &[&issue],
476 Some(&refs),
477 None,
478 false,
479 ));
480 assert_eq!(comment["position"]["position_type"], "text");
481 assert_eq!(comment["position"]["base_sha"], "base");
482 assert_eq!(comment["position"]["start_sha"], "start");
483 assert_eq!(comment["position"]["head_sha"], "head");
484 }
485
486 #[test]
487 fn guidance_toggle_accepts_common_truthy_values() {
488 for value in ["1", "true", "TRUE", "yes", "on", " On "] {
489 assert!(env_truthy(value), "{value:?} should enable guidance");
490 }
491 for value in ["", "0", "false", "no", "off", "enabled"] {
492 assert!(!env_truthy(value), "{value:?} should not enable guidance");
493 }
494 }
495
496 #[test]
497 fn guidance_disabled_omits_details_block() {
498 let issue = issue(
499 "fallow/high-complexity",
500 "major",
501 "src/a.ts",
502 10,
503 "abc1234567890def",
504 );
505 let comment = comment_to_value(&render_merged_comment(
506 Provider::Github,
507 &[&issue],
508 None,
509 None,
510 false,
511 ));
512 let body = comment["body"].as_str().unwrap();
513 assert!(!body.contains("<details><summary>What to do</summary>"));
514 assert!(!body.contains("For function findings"));
515 }
516
517 #[test]
518 fn guidance_enabled_appends_rule_guide_details() {
519 let issue = issue(
520 "fallow/high-complexity",
521 "major",
522 "src/a.ts",
523 10,
524 "abc1234567890def",
525 );
526 let comment = comment_to_value(&render_merged_comment(
527 Provider::Github,
528 &[&issue],
529 None,
530 None,
531 true,
532 ));
533 let body = comment["body"].as_str().unwrap();
534 assert!(body.contains("<details><summary>What to do</summary>"));
535 assert!(body.contains("For function findings"));
536 assert!(body.contains("[Read the rule docs]("));
537 assert!(
538 body.find("</details>").unwrap() < body.find("fallow-fingerprint:v2:").unwrap(),
539 "guidance should render before the marker"
540 );
541 }
542
543 #[test]
544 fn guidance_attaches_to_each_merged_finding() {
545 let complexity = issue("fallow/high-complexity", "major", "src/foo.ts", 42, "fp_a");
546 let duplication = issue("fallow/code-duplication", "minor", "src/foo.ts", 42, "fp_b");
547 let comment = comment_to_value(&render_merged_comment(
548 Provider::Github,
549 &[&complexity, &duplication],
550 None,
551 None,
552 true,
553 ));
554 let body = comment["body"].as_str().unwrap();
555 assert_eq!(
556 body.matches("<details><summary>What to do</summary>")
557 .count(),
558 2
559 );
560 assert!(body.contains("For function findings"));
561 assert!(body.contains("Extract the shared logic"));
562 }
563
564 #[test]
565 fn envelope_emits_marker_regex_field_at_root() {
566 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
567 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
568 let regex = env["marker_regex"].as_str().expect("marker_regex present");
569 assert_eq!(regex, MARKER_REGEX_V2);
570 assert!(regex.contains("[0-9a-f]{16}"));
571 assert!(regex.starts_with('^'));
572 assert!(regex.ends_with("\\s*$"));
573 assert!(!regex.contains("(?m)"));
574 assert!(regex.contains("((?:[a-z]+:)?[0-9a-f]{16})"));
575 let flags = env["marker_regex_flags"]
576 .as_str()
577 .expect("marker_regex_flags present");
578 assert_eq!(flags, "m");
579 }
580
581 #[test]
582 fn envelope_emits_summary_block_with_fingerprint() {
583 let issues = vec![issue("fallow/unused-file", "minor", "src/a.ts", 1, "abc")];
584 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
585 assert_eq!(env["summary"]["body"], env["body"]);
586 let summary_fp = env["summary"]["fingerprint"].as_str().expect("fingerprint");
587 assert_eq!(summary_fp.len(), 16);
588 assert!(summary_fp.chars().all(|c| c.is_ascii_hexdigit()));
589 let body_str = env["body"].as_str().unwrap();
590 let marker_line = format!("{MARKER_PREFIX_V2}{summary_fp}{MARKER_SUFFIX_V2}");
591 assert!(
592 body_str.contains(&marker_line),
593 "body must carry summary marker:\nbody={body_str}\nmarker={marker_line}"
594 );
595 }
596
597 #[test]
598 fn same_line_findings_merge_into_one_comment_with_composite_fingerprint() {
599 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
600 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
601 let env = to_value(&render_review_envelope("check", Provider::Github, &[a, b]));
602 assert_eq!(
603 env["comments"].as_array().unwrap().len(),
604 1,
605 "two same-line findings must collapse to one comment"
606 );
607 let merged = &env["comments"][0];
608 let fp = merged["fingerprint"].as_str().unwrap();
609 assert!(
610 fp.starts_with("merged:"),
611 "merged comment fingerprint must start with merged:, got {fp}"
612 );
613 assert_eq!(fp.len(), 23);
614 let body = merged["body"].as_str().unwrap();
615 assert!(body.contains("fallow/unused-export"));
616 assert!(body.contains("fallow/duplicate-export"));
617 assert_eq!(
618 body.matches("fallow-fingerprint:v2:").count(),
619 1,
620 "merged body must carry exactly one fingerprint marker"
621 );
622 assert!(
623 merged.get("constituent_fingerprints").is_none(),
624 "v2 hashed-composite design does not emit constituent_fingerprints"
625 );
626 }
627
628 #[test]
629 fn group_by_path_line_respects_max_groups_without_splitting_same_line_findings() {
630 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
631 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
632 let c = issue("fallow/unused-type", "minor", "src/z.ts", 7, "fp_c");
633 let issues = vec![a, b, c];
634
635 let max_zero = group_by_path_line(&issues, 0);
636 assert!(max_zero.groups.is_empty());
637 assert!(max_zero.truncated);
638
639 let max_one = group_by_path_line(&issues, 1);
640 assert_eq!(max_one.groups.len(), 1);
641 assert!(max_one.truncated);
642 assert_eq!(max_one.groups[0].len(), 2);
643 assert_eq!(max_one.groups[0][0].path, "src/foo.ts");
644 assert_eq!(max_one.groups[0][0].line, 42);
645
646 let max_two = group_by_path_line(&issues, 2);
647 assert_eq!(max_two.groups.len(), 2);
648 assert!(!max_two.truncated);
649 assert_eq!(max_two.groups[0].len(), 2);
650 assert_eq!(max_two.groups[1].len(), 1);
651 assert_eq!(
652 max_two.groups[0]
653 .iter()
654 .map(|issue| issue.fingerprint.as_str())
655 .collect::<Vec<_>>(),
656 ["fp_a", "fp_b"]
657 );
658 }
659
660 #[test]
661 fn single_finding_keeps_v1_fingerprint_shape() {
662 let issues = vec![issue(
663 "fallow/unused-file",
664 "minor",
665 "src/a.ts",
666 1,
667 "abc1234567890def",
668 )];
669 let env = to_value(&render_review_envelope("check", Provider::Github, &issues));
670 let comment = &env["comments"][0];
671 assert_eq!(comment["fingerprint"], "abc1234567890def");
672 assert!(
673 comment.get("constituent_fingerprints").is_none(),
674 "single-finding comment must NOT emit constituent_fingerprints"
675 );
676 assert!(
677 comment.get("truncated").is_none(),
678 "non-truncated comment must NOT emit truncated"
679 );
680 }
681
682 #[test]
683 fn composite_fingerprint_shifts_when_constituents_change() {
684 let a = issue("fallow/unused-export", "minor", "src/foo.ts", 42, "fp_a");
685 let b = issue("fallow/duplicate-export", "minor", "src/foo.ts", 42, "fp_b");
686 let c = issue("fallow/unused-type", "minor", "src/foo.ts", 42, "fp_c");
687 let run1 = to_value(&render_review_envelope(
688 "check",
689 Provider::Github,
690 &[a.clone(), b, c.clone()],
691 ));
692 let run2_drop_b = to_value(&render_review_envelope("check", Provider::Github, &[a, c]));
693 assert_ne!(
694 run1["comments"][0]["fingerprint"], run2_drop_b["comments"][0]["fingerprint"],
695 "primary fingerprint must shift when a constituent drops"
696 );
697 }
698
699 #[test]
700 fn gitlab_old_path_pulls_from_diff_rename_map() {
701 let rename_diff = "\
702diff --git a/src/old.ts b/src/new.ts
703similarity index 90%
704rename from src/old.ts
705rename to src/new.ts
706--- a/src/old.ts
707+++ b/src/new.ts
708@@ -1,2 +1,3 @@
709 keep
710+added
711 still
712";
713 let diff_index = DiffIndex::from_unified_diff(rename_diff);
714 let issue = issue("fallow/unused-export", "minor", "src/new.ts", 2, "abc");
715 let envelope = to_value(&render_review_envelope_with_diff(
716 "check",
717 Provider::Gitlab,
718 &[issue],
719 Some(&diff_index),
720 None,
721 None,
722 None,
723 ));
724 let position = &envelope["comments"][0]["position"];
725 assert_eq!(position["old_path"], "src/old.ts");
726 assert_eq!(position["new_path"], "src/new.ts");
727 }
728
729 #[test]
730 fn gitlab_old_path_falls_back_to_new_path_without_rename() {
731 let issue = issue("fallow/unused-export", "minor", "src/edit.ts", 5, "abc");
732 let envelope = to_value(&render_review_envelope_with_diff(
733 "check",
734 Provider::Gitlab,
735 &[issue],
736 None,
737 None,
738 None,
739 None,
740 ));
741 let position = &envelope["comments"][0]["position"];
742 assert_eq!(position["old_path"], "src/edit.ts");
743 assert_eq!(position["new_path"], "src/edit.ts");
744 }
745
746 #[test]
747 fn oversized_body_truncates_at_char_boundary_and_preserves_marker() {
748 let huge_desc = "x".repeat(MAX_COMMENT_BODY_BYTES * 2);
749 let issue = CiIssue {
750 rule_id: "fallow/unused-export".into(),
751 description: huge_desc,
752 severity: "minor".into(),
753 path: "src/a.ts".into(),
754 line: 1,
755 fingerprint: "abc1234567890def".into(),
756 };
757 let comment = comment_to_value(&render_merged_comment(
758 Provider::Github,
759 &[&issue],
760 None,
761 None,
762 false,
763 ));
764 let body = comment["body"].as_str().unwrap();
765 assert!(
766 body.len() <= MAX_COMMENT_BODY_BYTES,
767 "body len {} must not exceed cap {MAX_COMMENT_BODY_BYTES}",
768 body.len()
769 );
770 assert!(
771 body.contains("fallow-fingerprint:v2:"),
772 "marker must be preserved under truncation"
773 );
774 assert!(body.contains("<!-- fallow-truncated -->"));
775 assert!(body.contains("> Body truncated by fallow."));
776 assert_eq!(comment["truncated"], true);
777 assert!(std::str::from_utf8(body.as_bytes()).is_ok());
778 }
779
780 #[test]
781 fn oversized_guidance_body_truncates_and_preserves_marker() {
782 let issue = issue_with_desc(
783 "fallow/high-complexity",
784 "x".repeat(MAX_COMMENT_BODY_BYTES * 2),
785 "major",
786 "src/a.ts",
787 1,
788 "abc1234567890def",
789 );
790 let comment = comment_to_value(&render_merged_comment(
791 Provider::Github,
792 &[&issue],
793 None,
794 None,
795 true,
796 ));
797 let body = comment["body"].as_str().unwrap();
798 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
799 assert!(body.contains("<!-- fallow-truncated -->"));
800 assert!(body.contains("fallow-fingerprint:v2:"));
801 assert_eq!(comment["truncated"], true);
802 }
803
804 #[test]
805 fn multibyte_body_truncates_at_char_boundary() {
806 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());
824 assert!(body.len() <= MAX_COMMENT_BODY_BYTES);
825 assert_eq!(comment["truncated"], true);
826 }
827}