1use std::borrow::Cow;
4use std::fmt::Write as _;
5
6use crate::{
7 CodeClimateIssue, CodeClimateSeverity, DiffIndex, GitHubReviewComment, GitHubReviewSide,
8 GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType, ReviewCheckConclusion,
9 ReviewComment, ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput,
10 ReviewEnvelopeSchema, ReviewEnvelopeSummary, ReviewId, ReviewProvider, default_marker_regex,
11 default_marker_regex_flags, review_id_marker,
12};
13use serde_json::Value;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum CiProvider {
18 Github,
20 Gitlab,
22}
23
24impl CiProvider {
25 #[must_use]
27 pub const fn name(self) -> &'static str {
28 match self {
29 Self::Github => "GitHub",
30 Self::Gitlab => "GitLab",
31 }
32 }
33}
34
35#[must_use]
43pub fn apply_path_prefix(prefix: &str, path: &str) -> String {
44 if prefix.is_empty() {
45 return path.to_owned();
46 }
47 format!("{prefix}/{path}")
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct CiIssue {
53 pub rule_id: String,
55 pub description: String,
57 pub severity: String,
59 pub path: String,
61 pub line: u64,
63 pub end_line: Option<u64>,
65 pub other_locations: Vec<CiLocation>,
67 pub fingerprint: String,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
73pub struct CiLocation {
74 pub path: String,
76 pub line: u64,
78 pub end_line: u64,
80}
81
82pub struct PrCommentRenderInput<'a> {
84 pub command: &'a str,
86 pub provider: CiProvider,
88 pub issues: &'a [CiIssue],
90 pub marker_id: String,
92 pub max_comments: usize,
94 pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ReviewGitlabDiffRefs {
101 pub base_sha: String,
103 pub start_sha: String,
105 pub head_sha: String,
107}
108
109#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111pub struct ReviewEnvelopeTruncation {
112 pub body: bool,
114 pub comment_limit: bool,
116}
117
118#[derive(Debug)]
120pub struct ReviewEnvelopeRenderResult {
121 pub envelope: ReviewEnvelopeOutput,
123 pub truncation: ReviewEnvelopeTruncation,
125}
126
127pub struct ReviewEnvelopeRenderInput<'a> {
129 pub command: &'a str,
131 pub provider: CiProvider,
133 pub issues: &'a [CiIssue],
135 pub diff_index: Option<&'a DiffIndex>,
137 pub path_prefix: &'a str,
139 pub max_comments: usize,
141 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
143 pub include_guidance: bool,
145 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
148 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
150}
151
152pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
154
155pub const MARKER_SUFFIX_V2: &str = " -->";
157
158pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
161const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
162
163#[must_use]
166pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
167 let mut issues = value
168 .as_array()
169 .into_iter()
170 .flatten()
171 .filter_map(issue_from_codeclimate)
172 .collect::<Vec<_>>();
173 sort_ci_issues(&mut issues);
174 issues
175}
176
177#[must_use]
180pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
181 let mut issues = issues
182 .iter()
183 .map(issue_from_codeclimate_issue)
184 .collect::<Vec<_>>();
185 sort_ci_issues(&mut issues);
186 issues
187}
188
189fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
190 let path = value.pointer("/location/path")?.as_str()?.to_string();
191 let line = value
192 .pointer("/location/lines/begin")
193 .and_then(Value::as_u64)
194 .unwrap_or(1);
195 let end_line = value.pointer("/location/lines/end").and_then(Value::as_u64);
196 let mut other_locations = value
197 .get("other_locations")
198 .and_then(Value::as_array)
199 .into_iter()
200 .flatten()
201 .filter_map(|location| {
202 let line = location.pointer("/lines/begin")?.as_u64()?;
203 let end_line = location
204 .pointer("/lines/end")
205 .and_then(Value::as_u64)
206 .filter(|end| *end >= line)
207 .unwrap_or(line);
208 Some(CiLocation {
209 path: location.get("path")?.as_str()?.to_owned(),
210 line,
211 end_line,
212 })
213 })
214 .collect::<Vec<_>>();
215 other_locations.sort();
216 Some(CiIssue {
217 rule_id: value
218 .get("check_name")
219 .and_then(Value::as_str)
220 .unwrap_or("fallow/finding")
221 .to_string(),
222 description: value
223 .get("description")
224 .and_then(Value::as_str)
225 .unwrap_or("Fallow finding")
226 .to_string(),
227 severity: value
228 .get("severity")
229 .and_then(Value::as_str)
230 .unwrap_or("minor")
231 .to_string(),
232 fingerprint: value
233 .get("fingerprint")
234 .and_then(Value::as_str)
235 .unwrap_or("")
236 .to_string(),
237 path,
238 line,
239 end_line,
240 other_locations,
241 })
242}
243
244fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
245 let mut other_locations = issue
246 .other_locations
247 .iter()
248 .map(|location| CiLocation {
249 path: location.path.clone(),
250 line: u64::from(location.lines.begin),
251 end_line: u64::from(
252 location
253 .lines
254 .end
255 .filter(|end| *end >= location.lines.begin)
256 .unwrap_or(location.lines.begin),
257 ),
258 })
259 .collect::<Vec<_>>();
260 other_locations.sort();
261 CiIssue {
262 rule_id: issue.check_name.clone(),
263 description: issue.description.clone(),
264 severity: codeclimate_severity_label(issue.severity).to_owned(),
265 path: issue.location.path.clone(),
266 line: u64::from(issue.location.lines.begin),
267 end_line: issue.location.lines.end.map(u64::from),
268 other_locations,
269 fingerprint: issue.fingerprint.clone(),
270 }
271}
272
273const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
274 match severity {
275 CodeClimateSeverity::Info => "info",
276 CodeClimateSeverity::Minor => "minor",
277 CodeClimateSeverity::Major => "major",
278 CodeClimateSeverity::Critical => "critical",
279 CodeClimateSeverity::Blocker => "blocker",
280 }
281}
282
283fn sort_ci_issues(issues: &mut [CiIssue]) {
284 issues
285 .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
286}
287
288fn fingerprint_hash(parts: &[&str]) -> String {
289 crate::codeclimate_fingerprint_hash(parts)
290}
291
292#[must_use]
295#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
296pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
297 let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
298 let title = command_title(input.command);
299 let count = input.issues.len();
300 let noun = if count == 1 { "finding" } else { "findings" };
301
302 let mut out = String::new();
303 out.push_str(&marker);
304 out.push('\n');
305 write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
306 if count == 0 {
307 writeln!(
308 &mut out,
309 "No {provider} PR/MR findings.",
310 provider = input.provider.name()
311 )
312 .expect("write to string");
313 } else {
314 write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
315 let groups = group_by_category(input.issues, input.category_for_rule);
316 if let [(_, group_issues)] = groups.as_slice() {
317 render_findings_table(&mut out, group_issues, input.max_comments, "Details");
318 } else {
319 for (category, group_issues) in &groups {
320 let summary_label = summary_label(category, group_issues.len(), input.max_comments);
321 render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
322 }
323 }
324 }
325 out.push_str("\nGenerated by fallow.");
326 out
327}
328
329pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
332 "fallow/unused-catalog-entry",
333 "fallow/empty-catalog-group",
334 "fallow/unresolved-catalog-reference",
335 "fallow/unused-dependency-override",
336 "fallow/misconfigured-dependency-override",
337 "fallow/unused-dependency",
338 "fallow/unused-dev-dependency",
339 "fallow/unused-optional-dependency",
340 "fallow/type-only-dependency",
341 "fallow/test-only-dependency",
342 "fallow/dev-dependency-in-production",
343];
344
345#[must_use]
348pub fn is_project_level_rule(rule_id: &str) -> bool {
349 PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
350}
351
352const CATEGORY_ORDER: [&str; 6] = [
353 "Dead code",
354 "Dependencies",
355 "Duplication",
356 "Health",
357 "Architecture",
358 "Suppressions",
359];
360
361fn group_by_category<'a>(
362 issues: &'a [CiIssue],
363 category_for_rule: &dyn Fn(&str) -> &'static str,
364) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
365 let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
366 std::collections::BTreeMap::new();
367 for issue in issues {
368 let category = category_for_rule(&issue.rule_id);
369 buckets.entry(category).or_default().push(issue);
370 }
371 let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
372 for category in CATEGORY_ORDER {
373 if let Some(items) = buckets.remove(category) {
374 ordered.push((category, items));
375 }
376 }
377 for (category, items) in buckets {
378 ordered.push((category, items));
379 }
380 ordered
381}
382
383#[must_use]
386pub fn summary_label(category: &str, total: usize, max: usize) -> String {
387 if total > max {
388 format!("{category} ({total}, showing {max})")
389 } else {
390 format!("{category} ({total})")
391 }
392}
393
394#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
395fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
396 writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
397 out.push_str("| Severity | Rule | Location | Description |\n");
398 out.push_str("| --- | --- | --- | --- |\n");
399 for issue in issues.iter().take(max) {
400 writeln!(
401 out,
402 "| {} | `{}` | `{}`:{} | {} |",
403 escape_md(&issue.severity),
404 escape_md(&issue.rule_id),
405 escape_md(&issue.path),
406 issue.line,
407 escape_md(&issue.description),
408 )
409 .expect("write to string");
410 }
411 if issues.len() > max {
412 writeln!(
413 out,
414 "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
415 issues.len(),
416 )
417 .expect("write to string");
418 }
419 out.push_str("\n</details>\n\n");
420}
421
422#[must_use]
425pub fn command_title(command: &str) -> &'static str {
426 match command {
427 "dead-code" | "check" => "dead-code report",
428 "dupes" => "duplication report",
429 "health" => "health report",
430 "audit" => "audit report",
431 "" | "combined" => "combined report",
432 _ => "report",
433 }
434}
435
436#[must_use]
438pub fn escape_md(value: &str) -> String {
439 let value = value.trim();
440 let mut chars = value.chars().peekable();
443 let mut out = String::with_capacity(value.len());
444 while let Some(ch) = chars.next() {
445 let ch = match ch {
446 '\r' => {
447 if chars.peek() == Some(&'\n') {
448 chars.next();
449 }
450 ' '
451 }
452 '\n' => ' ',
453 _ => ch,
454 };
455 if matches!(
456 ch,
457 '\\' | '`'
458 | '*'
459 | '_'
460 | '['
461 | ']'
462 | '('
463 | ')'
464 | '!'
465 | '<'
466 | '>'
467 | '#'
468 | '|'
469 | '~'
470 | '&'
471 ) {
472 out.push('\\');
473 }
474 out.push(ch);
475 }
476 out
477}
478
479#[must_use]
483pub fn markdown_code_span(value: &str) -> String {
484 let longest_run = value
485 .split(|c| c != '`')
486 .map(str::len)
487 .max()
488 .unwrap_or_default();
489 let fence = "`".repeat(longest_run + 1);
490 let needs_padding = value.starts_with('`')
491 || value.ends_with('`')
492 || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
493 if needs_padding {
494 format!("{fence} {value} {fence}")
495 } else {
496 format!("{fence}{value}{fence}")
497 }
498}
499
500#[must_use]
504pub fn markdown_table_code_span(value: &str) -> String {
505 let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
506 markdown_code_span(&collapsed.replace('|', "\\|"))
507}
508
509#[must_use]
512pub fn markdown_table_text(value: &str) -> String {
513 value
514 .replace("\r\n", " ")
515 .replace(['\n', '\r'], " ")
516 .replace('|', "\\|")
517}
518
519#[must_use]
521pub fn render_review_envelope(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
522 render_review_envelope_with_id(input, None, None, None)
523}
524
525#[must_use]
527pub fn render_review_envelope_with_conclusion(
528 input: &ReviewEnvelopeRenderInput<'_>,
529 conclusion: ReviewCheckConclusion,
530 status_message: Option<&str>,
531) -> ReviewEnvelopeRenderResult {
532 render_review_envelope_with_id(input, None, Some(conclusion), status_message)
533}
534
535#[must_use]
537pub fn render_scoped_review_envelope(
538 input: &ReviewEnvelopeRenderInput<'_>,
539 review_id: &ReviewId,
540) -> ReviewEnvelopeRenderResult {
541 render_review_envelope_with_id(input, Some(review_id), None, None)
542}
543
544#[must_use]
546pub fn render_scoped_review_envelope_with_conclusion(
547 input: &ReviewEnvelopeRenderInput<'_>,
548 review_id: &ReviewId,
549 conclusion: ReviewCheckConclusion,
550 status_message: Option<&str>,
551) -> ReviewEnvelopeRenderResult {
552 render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
553}
554
555fn render_review_envelope_with_id(
556 input: &ReviewEnvelopeRenderInput<'_>,
557 review_id: Option<&ReviewId>,
558 conclusion: Option<ReviewCheckConclusion>,
559 status_message: Option<&str>,
560) -> ReviewEnvelopeRenderResult {
561 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
562
563 let comments: Vec<ReviewComment> = grouped
564 .groups
565 .iter()
566 .map(|group| {
567 render_review_comment_for_group_with_id(
568 &ReviewCommentRenderInput {
569 provider: input.provider,
570 group,
571 gitlab_diff_refs: input.gitlab_diff_refs,
572 diff_index: input.diff_index,
573 path_prefix: input.path_prefix,
574 include_guidance: input.include_guidance,
575 suggestion_block: input.suggestion_block,
576 guidance_block: input.guidance_block,
577 },
578 review_id,
579 )
580 })
581 .collect();
582
583 let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
584 let summary_text = review_summary_text(
585 input.command,
586 input.provider,
587 comments.len(),
588 conclusion,
589 status_message,
590 );
591 let summary_fp = summary_fingerprint(&summary_text);
592 let summary_marker = review_markers(&summary_fp, review_id);
593 let body = format!("{summary_text}{summary_marker}");
594 let summary = ReviewEnvelopeSummary {
595 body: body.clone(),
596 fingerprint: summary_fp,
597 };
598
599 let truncation = ReviewEnvelopeTruncation {
600 body: comments.iter().any(review_comment_truncated),
601 comment_limit: grouped.truncated,
602 };
603
604 ReviewEnvelopeRenderResult {
605 envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
606 truncation,
607 }
608}
609
610fn review_summary_text(
611 command: &str,
612 provider: CiProvider,
613 comment_count: usize,
614 conclusion: ReviewCheckConclusion,
615 status_message: Option<&str>,
616) -> String {
617 let verdict = review_summary_verdict(conclusion);
618 let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
619 format!(
620 "### Fallow {}\n\n**{}**{}\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
621 command_title(command),
622 verdict,
623 status,
624 comment_count,
625 if comment_count == 1 { "" } else { "s" },
626 provider.name(),
627 )
628}
629
630fn review_summary_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
631 match conclusion {
632 ReviewCheckConclusion::Failure => "Quality gate failed",
633 ReviewCheckConclusion::Neutral => "Review needed",
634 ReviewCheckConclusion::Success => "Quality gate passed",
635 }
636}
637
638#[derive(Debug, PartialEq, Eq)]
641pub struct GroupedReviewIssues<'a> {
642 pub groups: Vec<Vec<&'a CiIssue>>,
644 pub truncated: bool,
646}
647
648#[must_use]
651pub fn group_review_issues_by_path_line(
652 issues: &[CiIssue],
653 max_groups: usize,
654) -> GroupedReviewIssues<'_> {
655 if max_groups == 0 {
656 return GroupedReviewIssues {
657 groups: Vec::new(),
658 truncated: !issues.is_empty(),
659 };
660 }
661 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
662 let mut current: Vec<&CiIssue> = Vec::new();
663 let mut current_key: Option<(&str, u64)> = None;
664 for issue in issues {
665 let key = (issue.path.as_str(), issue.line);
666 if Some(key) != current_key {
667 if !current.is_empty() {
668 groups.push(std::mem::take(&mut current));
669 if groups.len() == max_groups {
670 return GroupedReviewIssues {
671 groups,
672 truncated: true,
673 };
674 }
675 }
676 current_key = Some(key);
677 }
678 current.push(issue);
679 }
680 if !current.is_empty() && groups.len() < max_groups {
681 groups.push(current);
682 }
683 GroupedReviewIssues {
684 groups,
685 truncated: false,
686 }
687}
688
689fn review_comment_truncated(comment: &ReviewComment) -> bool {
690 match comment {
691 ReviewComment::GitHub(comment) => comment.truncated,
692 ReviewComment::GitLab(comment) => comment.truncated,
693 }
694}
695
696pub struct ReviewCommentRenderInput<'a, 'group> {
698 pub provider: CiProvider,
700 pub group: &'a [&'group CiIssue],
702 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
704 pub diff_index: Option<&'a DiffIndex>,
706 pub path_prefix: &'a str,
708 pub include_guidance: bool,
710 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
713 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
715}
716
717#[must_use]
719pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
720 render_review_comment_for_group_with_id(input, None)
721}
722
723fn render_review_comment_for_group_with_id(
724 input: &ReviewCommentRenderInput<'_, '_>,
725 review_id: Option<&ReviewId>,
726) -> ReviewComment {
727 assert!(
728 !input.group.is_empty(),
729 "group_review_issues_by_path_line never yields empty"
730 );
731 let representative = input.group[0];
732 let fingerprint = if input.group.len() == 1 {
733 representative.fingerprint.clone()
734 } else {
735 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
736 composite_fingerprint(&constituents)
737 };
738
739 let content = build_merged_comment_content(input);
740 let marker_line = review_markers(&fingerprint, review_id);
741 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
742
743 build_review_comment(ReviewCommentInput {
744 provider: input.provider,
745 representative,
746 gitlab_diff_refs: input.gitlab_diff_refs,
747 diff_index: input.diff_index,
748 path_prefix: input.path_prefix,
749 body,
750 fingerprint,
751 truncated,
752 })
753}
754
755#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
756fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
757 let mut content = String::new();
758 for (index, issue) in input.group.iter().enumerate() {
759 let label = review_label_from_codeclimate(&issue.severity);
760 if index > 0 {
761 content.push_str("\n\n");
762 }
763 write!(
764 content,
765 "**{}** `{}`: {}",
766 label,
767 escape_md(&issue.rule_id),
768 escape_md(&issue.description)
769 )
770 .expect("write to String is infallible");
771 if !issue.other_locations.is_empty() {
772 content.push_str("\n\nOther locations: ");
773 let locations = issue
774 .other_locations
775 .iter()
776 .map(|location| {
777 markdown_code_span(&format!(
778 "{}:{}-{}",
779 apply_path_prefix(input.path_prefix, &location.path),
780 location.line,
781 location.end_line
782 ))
783 })
784 .collect::<Vec<_>>()
785 .join(", ");
786 content.push_str(&locations);
787 }
788 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
789 content.push_str(&suggestion);
790 }
791 if input.include_guidance
792 && let Some(guidance) = (input.guidance_block)(issue)
793 {
794 content.push_str(&guidance);
795 }
796 }
797 content
798}
799
800struct ReviewCommentInput<'a> {
801 provider: CiProvider,
802 representative: &'a CiIssue,
803 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
804 diff_index: Option<&'a DiffIndex>,
805 path_prefix: &'a str,
806 body: String,
807 fingerprint: String,
808 truncated: bool,
809}
810
811fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
812 let ReviewCommentInput {
813 provider,
814 representative,
815 gitlab_diff_refs,
816 diff_index,
817 path_prefix,
818 body,
819 fingerprint,
820 truncated,
821 } = input;
822 match provider {
823 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
824 path: apply_path_prefix(path_prefix, &representative.path),
825 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
826 side: GitHubReviewSide::Right,
827 body,
828 fingerprint,
829 truncated,
830 }),
831 CiProvider::Gitlab => {
832 let old_rel = diff_index
835 .and_then(|di| di.old_path_for_root_relative(&representative.path))
836 .map_or_else(|| representative.path.clone(), Cow::into_owned);
837 let new_path = apply_path_prefix(path_prefix, &representative.path);
838 let old_path = apply_path_prefix(path_prefix, &old_rel);
839 let position = GitLabReviewPosition {
840 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
841 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
842 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
843 position_type: GitLabReviewPositionType::Text,
844 old_path,
845 new_path,
846 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
847 };
848 ReviewComment::GitLab(GitLabReviewComment {
849 body,
850 position,
851 fingerprint,
852 truncated,
853 })
854 }
855 }
856}
857
858#[must_use]
862pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
863 let intact_len = content.len() + marker_line.len();
864 if intact_len <= MAX_COMMENT_BODY_BYTES {
865 let mut out = String::with_capacity(intact_len);
866 out.push_str(content);
867 out.push_str(marker_line);
868 return (out, false);
869 }
870 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
871 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
872 let mut cut = budget.min(content.len());
873 while cut > 0 && !content.is_char_boundary(cut) {
874 cut -= 1;
875 }
876 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
877 out.push_str(&content[..cut]);
878 out.push_str(TRUNCATION_SUFFIX);
879 out.push_str(marker_line);
880 (out, true)
881}
882
883#[must_use]
886pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
887 match severity_name.as_bytes() {
888 b"major" | b"critical" | b"blocker" => "error",
889 _ => "warn",
890 }
891}
892
893#[must_use]
896pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
897 if issues
898 .iter()
899 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
900 {
901 ReviewCheckConclusion::Failure
902 } else if issues.is_empty() {
903 ReviewCheckConclusion::Success
904 } else {
905 ReviewCheckConclusion::Neutral
906 }
907}
908
909fn build_review_envelope_output(
910 provider: CiProvider,
911 body: String,
912 summary: ReviewEnvelopeSummary,
913 comments: Vec<ReviewComment>,
914 conclusion: ReviewCheckConclusion,
915) -> ReviewEnvelopeOutput {
916 match provider {
917 CiProvider::Github => ReviewEnvelopeOutput {
918 event: Some(ReviewEnvelopeEvent::Comment),
919 body,
920 summary,
921 comments,
922 marker_regex: default_marker_regex(),
923 marker_regex_flags: default_marker_regex_flags(),
924 meta: ReviewEnvelopeMeta {
925 schema: ReviewEnvelopeSchema::V3,
926 provider: ReviewProvider::Github,
927 check_conclusion: Some(conclusion),
928 },
929 },
930 CiProvider::Gitlab => ReviewEnvelopeOutput {
931 event: None,
932 body,
933 summary,
934 comments,
935 marker_regex: default_marker_regex(),
936 marker_regex_flags: default_marker_regex_flags(),
937 meta: ReviewEnvelopeMeta {
938 schema: ReviewEnvelopeSchema::V3,
939 provider: ReviewProvider::Gitlab,
940 check_conclusion: None,
941 },
942 },
943 }
944}
945
946fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
947 let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
948 match review_id {
949 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
950 None => fingerprint,
951 }
952}
953
954#[must_use]
956pub fn summary_fingerprint(body: &str) -> String {
957 fingerprint_hash(&[body])
958}
959
960#[must_use]
964pub fn composite_fingerprint(constituents: &[&str]) -> String {
965 let mut sorted: Vec<&str> = constituents.to_vec();
966 sorted.sort_unstable();
967 let joined = sorted.join(":");
968 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
975
976 fn escape_md_legacy(value: &str) -> String {
977 let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
978 let mut out = String::with_capacity(collapsed.len());
979 for ch in collapsed.chars() {
980 if matches!(
981 ch,
982 '\\' | '`'
983 | '*'
984 | '_'
985 | '['
986 | ']'
987 | '('
988 | ')'
989 | '!'
990 | '<'
991 | '>'
992 | '#'
993 | '|'
994 | '~'
995 | '&'
996 ) {
997 out.push('\\');
998 }
999 out.push(ch);
1000 }
1001 out.trim().to_owned()
1002 }
1003
1004 fn category_for_rule(rule_id: &str) -> &'static str {
1005 match rule_id {
1006 "fallow/code-duplication" => "Duplication",
1007 "fallow/high-complexity" => "Health",
1008 "fallow/unused-dependency" => "Dependencies",
1009 _ => "Dead code",
1010 }
1011 }
1012
1013 #[test]
1014 fn extracts_issues_from_codeclimate() {
1015 let value = serde_json::json!([{
1016 "check_name": "fallow/unused-export",
1017 "description": "Export x is never imported",
1018 "severity": "minor",
1019 "fingerprint": "abc",
1020 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1021 }]);
1022 let issues = issues_from_codeclimate(&value);
1023 assert_eq!(issues.len(), 1);
1024 assert_eq!(issues[0].path, "src/a.ts");
1025 assert_eq!(issues[0].line, 7);
1026 }
1027
1028 #[test]
1029 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1030 let severities = [
1031 (CodeClimateSeverity::Info, "info"),
1032 (CodeClimateSeverity::Minor, "minor"),
1033 (CodeClimateSeverity::Major, "major"),
1034 (CodeClimateSeverity::Critical, "critical"),
1035 (CodeClimateSeverity::Blocker, "blocker"),
1036 ];
1037 let typed = severities
1038 .iter()
1039 .enumerate()
1040 .map(|(index, (severity, _))| CodeClimateIssue {
1041 kind: CodeClimateIssueKind::Issue,
1042 check_name: format!("fallow/rule-{index}"),
1043 description: format!("Finding {index}"),
1044 categories: vec!["Complexity".to_owned()],
1045 severity: *severity,
1046 fingerprint: format!("fp-{index}"),
1047 location: CodeClimateLocation {
1048 path: format!("src/{index}.ts"),
1049 lines: CodeClimateLines {
1050 begin: u32::try_from(index + 1).expect("small fixture index"),
1051 end: Some(u32::try_from(index + 3).expect("small fixture index")),
1052 },
1053 },
1054 other_locations: vec![CodeClimateLocation {
1055 path: format!("src/peer-{index}.ts"),
1056 lines: CodeClimateLines {
1057 begin: 20,
1058 end: Some(24),
1059 },
1060 }],
1061 owner: None,
1062 group: None,
1063 })
1064 .collect::<Vec<_>>();
1065 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1066
1067 assert_eq!(
1068 issues_from_codeclimate_issues(&typed),
1069 issues_from_codeclimate(&value)
1070 );
1071 let normalized = issues_from_codeclimate_issues(&typed);
1072 assert_eq!(normalized[0].end_line, Some(3));
1073 assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1074 assert_eq!(normalized[0].other_locations[0].line, 20);
1075 assert_eq!(normalized[0].other_locations[0].end_line, 24);
1076 let typed_labels = issues_from_codeclimate_issues(&typed)
1077 .into_iter()
1078 .map(|issue| issue.severity)
1079 .collect::<Vec<_>>();
1080 let expected_labels = severities
1081 .iter()
1082 .map(|(_, label)| (*label).to_owned())
1083 .collect::<Vec<_>>();
1084 assert_eq!(typed_labels, expected_labels);
1085 }
1086
1087 #[test]
1088 fn review_comment_renders_repository_prefixed_peer_ranges() {
1089 let issue = CiIssue {
1090 rule_id: "fallow/code-duplication".to_owned(),
1091 description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1092 severity: "minor".to_owned(),
1093 path: "src/a.ts".to_owned(),
1094 line: 5,
1095 end_line: Some(15),
1096 other_locations: vec![CiLocation {
1097 path: "src/b.ts".to_owned(),
1098 line: 30,
1099 end_line: 40,
1100 }],
1101 fingerprint: "instance-fingerprint".to_owned(),
1102 };
1103 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1104 provider: CiProvider::Gitlab,
1105 group: &[&issue],
1106 gitlab_diff_refs: None,
1107 diff_index: None,
1108 path_prefix: "packages/app",
1109 include_guidance: false,
1110 suggestion_block: &|_, _| None,
1111 guidance_block: &|_| None,
1112 });
1113 let ReviewComment::GitLab(comment) = comment else {
1114 panic!("expected GitLab comment");
1115 };
1116
1117 assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1118 assert!(
1119 comment
1120 .body
1121 .contains("Other locations: `packages/app/src/b.ts:30-40`")
1122 );
1123 }
1124
1125 #[test]
1126 fn renders_default_empty_comment() {
1127 let body = render_pr_comment(&PrCommentRenderInput {
1128 command: "check",
1129 provider: CiProvider::Github,
1130 issues: &[],
1131 marker_id: "fallow-results".to_owned(),
1132 max_comments: 50,
1133 category_for_rule: &category_for_rule,
1134 });
1135 assert!(body.contains("<!-- fallow-id: fallow-results"));
1136 assert!(body.contains("No GitHub PR/MR findings."));
1137 }
1138
1139 #[test]
1140 fn escape_md_escapes_inline_commonmark_specials() {
1141 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1142 let escaped = escape_md(raw);
1143 for ch in [
1144 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1145 ] {
1146 let raw_count = raw.chars().filter(|c| c == &ch).count();
1147 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1148 assert_eq!(
1149 raw_count, escaped_count,
1150 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1157 let raw = "value *suspicious* here";
1158 let escaped = escape_md(raw);
1159 assert!(escaped.contains(r"\&"), "got: {escaped}");
1160 assert!(escaped.contains(r"\#"), "got: {escaped}");
1161 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1162 }
1163
1164 #[test]
1165 fn summary_label_foreshadows_truncation() {
1166 assert_eq!(
1167 summary_label("Duplication", 160, 50),
1168 "Duplication (160, showing 50)"
1169 );
1170 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1171 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1172 }
1173
1174 #[test]
1175 fn escape_md_does_not_escape_block_only_markers() {
1176 let raw = "fallow/test-only-dependency package.json:12";
1177 let escaped = escape_md(raw);
1178 assert!(!escaped.contains("\\-"), "should not escape `-`");
1179 assert!(!escaped.contains("\\."), "should not escape `.`");
1180 assert_eq!(escaped, raw);
1181 }
1182
1183 #[test]
1184 fn escape_md_collapses_newlines_to_spaces() {
1185 let raw = "first\nsecond\nthird";
1186 assert_eq!(escape_md(raw), "first second third");
1187 }
1188
1189 #[test]
1190 fn escape_md_collapses_carriage_returns_to_spaces() {
1191 assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1192 }
1193
1194 #[test]
1195 fn escape_md_matches_legacy_contract_corpus() {
1196 const ALPHABET: [char; 12] = [
1197 'a', ' ', '\t', '\r', '\n', '\\', '|', '`', '&', '\u{2003}', 'é', '🦀',
1198 ];
1199
1200 for length in 0..=4 {
1201 let case_count = ALPHABET.len().pow(length);
1202 for mut encoded in 0..case_count {
1203 let mut value = String::new();
1204 for _ in 0..length {
1205 value.push(ALPHABET[encoded % ALPHABET.len()]);
1206 encoded /= ALPHABET.len();
1207 }
1208 assert_eq!(
1209 escape_md(&value),
1210 escape_md_legacy(&value),
1211 "contract mismatch for {value:?}"
1212 );
1213 }
1214 }
1215 }
1216
1217 #[test]
1218 fn markdown_code_span_grows_fence_past_inner_backticks() {
1219 assert_eq!(markdown_code_span("plain"), "`plain`");
1220 assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1221 assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1222 }
1223
1224 #[test]
1225 fn markdown_table_code_span_escapes_pipes() {
1226 assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1227 assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1228 }
1229
1230 #[test]
1231 fn markdown_table_code_span_collapses_line_endings() {
1232 assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1233 }
1234
1235 #[test]
1236 fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1237 assert_eq!(markdown_table_text("a|b"), "a\\|b");
1238 assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1239 }
1240
1241 #[test]
1242 fn escape_md_leaves_safe_chars_unchanged() {
1243 let raw = "Export 'helperFn' is never imported by other modules";
1244 assert_eq!(
1245 escape_md(raw),
1246 r"Export 'helperFn' is never imported by other modules"
1247 );
1248 }
1249
1250 #[test]
1251 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1252 for rule_id in PROJECT_LEVEL_RULE_IDS {
1253 assert!(
1254 is_project_level_rule(rule_id),
1255 "{rule_id} must be project-level"
1256 );
1257 }
1258 for rule_id in [
1259 "fallow/unused-file",
1260 "fallow/unused-export",
1261 "fallow/unused-type",
1262 "fallow/unused-enum-member",
1263 "fallow/unused-class-member",
1264 "fallow/unused-store-member",
1265 "fallow/unresolved-import",
1266 "fallow/unlisted-dependency",
1267 "fallow/duplicate-export",
1268 "fallow/circular-dependency",
1269 "fallow/re-export-cycle",
1270 "fallow/boundary-violation",
1271 "fallow/stale-suppression",
1272 "fallow/private-type-leak",
1273 "fallow/high-complexity",
1274 "fallow/high-crap-score",
1275 ] {
1276 assert!(
1277 !is_project_level_rule(rule_id),
1278 "{rule_id} must NOT be project-level"
1279 );
1280 }
1281 }
1282
1283 #[test]
1284 fn escape_md_double_apply_is_safe() {
1285 let raw = "code with `backticks` and *stars*";
1286 let once = escape_md(raw);
1287 let twice = escape_md(&once);
1288 assert!(twice.contains(r"\\"));
1289 }
1290}