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