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