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]
299pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
300 render_pr_comment_with_verdict(input, None)
301}
302
303#[must_use]
314#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
315pub fn render_pr_comment_with_verdict(
316 input: &PrCommentRenderInput<'_>,
317 gate: Option<ReviewCheckConclusion>,
318) -> String {
319 let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
320 let title = command_title(input.command);
321 let count = input.issues.len();
322 let noun = if count == 1 { "finding" } else { "findings" };
323 let verdict = most_severe_conclusion(github_check_conclusion(input.issues), gate);
324
325 let mut out = String::new();
326 out.push_str(&marker);
327 out.push('\n');
328 write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
329 write!(&mut out, "**{}**\n\n", pr_comment_verdict(verdict)).expect("write to string");
330 if count == 0 {
331 writeln!(
332 &mut out,
333 "No findings for this {}.",
334 change_noun(input.provider)
335 )
336 .expect("write to string");
337 } else {
338 let groups = group_by_category(input.issues, input.category_for_rule);
339 if groups.len() > 1 {
340 write!(
341 &mut out,
342 "Found **{count}** {noun}: {}.\n\n",
343 category_breakdown(&groups)
344 )
345 .expect("write to string");
346 } else {
347 write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
348 }
349 for (category, group_issues) in &groups {
350 let summary_label = summary_label(category, group_issues.len(), input.max_comments);
351 render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
352 }
353 }
354 out.push_str("\nGenerated by fallow.");
355 out
356}
357
358const fn pr_comment_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
360 match conclusion {
361 ReviewCheckConclusion::Failure => "Quality gate failed",
362 ReviewCheckConclusion::Neutral => "Review needed",
363 ReviewCheckConclusion::Success => "Quality gate passed",
364 }
365}
366
367const fn conclusion_rank(conclusion: ReviewCheckConclusion) -> u8 {
368 match conclusion {
369 ReviewCheckConclusion::Success => 0,
370 ReviewCheckConclusion::Neutral => 1,
371 ReviewCheckConclusion::Failure => 2,
372 }
373}
374
375const fn most_severe_conclusion(
376 derived: ReviewCheckConclusion,
377 gate: Option<ReviewCheckConclusion>,
378) -> ReviewCheckConclusion {
379 match gate {
380 Some(gate) if conclusion_rank(gate) > conclusion_rank(derived) => gate,
381 _ => derived,
382 }
383}
384
385pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
388 "fallow/unused-catalog-entry",
389 "fallow/empty-catalog-group",
390 "fallow/unresolved-catalog-reference",
391 "fallow/unused-dependency-override",
392 "fallow/misconfigured-dependency-override",
393 "fallow/unused-dependency",
394 "fallow/unused-dev-dependency",
395 "fallow/unused-optional-dependency",
396 "fallow/type-only-dependency",
397 "fallow/test-only-dependency",
398 "fallow/dev-dependency-in-production",
399];
400
401#[must_use]
404pub fn is_project_level_rule(rule_id: &str) -> bool {
405 PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
406}
407
408const CATEGORY_ORDER: [&str; 10] = [
413 "Dead code",
414 "Dependencies",
415 "Duplication",
416 "Health",
417 "Architecture",
418 "Policy",
419 "Security",
420 "Flags",
421 "Suppressions",
422 "Other",
423];
424
425fn group_by_category<'a>(
426 issues: &'a [CiIssue],
427 category_for_rule: &dyn Fn(&str) -> &'static str,
428) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
429 let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
430 std::collections::BTreeMap::new();
431 for issue in issues {
432 let category = category_for_rule(&issue.rule_id);
433 buckets.entry(category).or_default().push(issue);
434 }
435 let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
436 for category in CATEGORY_ORDER {
437 if let Some(items) = buckets.remove(category) {
438 ordered.push((category, items));
439 }
440 }
441 for (category, items) in buckets {
442 ordered.push((category, items));
443 }
444 ordered
445}
446
447fn category_breakdown(groups: &[(&'static str, Vec<&CiIssue>)]) -> String {
451 groups
452 .iter()
453 .map(|(category, issues)| format!("{category} {}", issues.len()))
454 .collect::<Vec<_>>()
455 .join(", ")
456}
457
458#[must_use]
461pub fn summary_label(category: &str, total: usize, max: usize) -> String {
462 if total > max {
463 format!("{category} ({total}, showing {max})")
464 } else {
465 format!("{category} ({total})")
466 }
467}
468
469#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
470fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
471 writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
472 out.push_str("| Severity | Rule | Location | Description |\n");
473 out.push_str("| --- | --- | --- | --- |\n");
474 for issue in issues.iter().take(max) {
475 writeln!(
476 out,
477 "| {} | `{}` | `{}`:{} | {} |",
478 escape_md(&issue.severity),
479 escape_md(&issue.rule_id),
480 escape_md(&issue.path),
481 issue.line,
482 escape_md(&issue.description),
483 )
484 .expect("write to string");
485 }
486 if issues.len() > max {
487 writeln!(
488 out,
489 "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
490 issues.len(),
491 )
492 .expect("write to string");
493 }
494 out.push_str("\n</details>\n\n");
495}
496
497#[must_use]
500pub fn command_title(command: &str) -> &'static str {
501 match command {
502 "dead-code" | "check" => "codebase report",
503 "dupes" => "duplication report",
504 "health" => "health report",
505 "audit" => "audit report",
506 "security" => "security report",
507 "fix" => "fix report",
508 "" | "combined" => "combined report",
509 _ => "report",
510 }
511}
512
513const fn change_noun(provider: CiProvider) -> &'static str {
515 match provider {
516 CiProvider::Github => "pull request",
517 CiProvider::Gitlab => "merge request",
518 }
519}
520
521const fn changes_tab(provider: CiProvider) -> &'static str {
523 match provider {
524 CiProvider::Github => "Files changed",
525 CiProvider::Gitlab => "Changes",
526 }
527}
528
529#[must_use]
531pub fn escape_md(value: &str) -> String {
532 let value = value.trim();
533 let mut chars = value.chars().peekable();
536 let mut out = String::with_capacity(value.len());
537 while let Some(ch) = chars.next() {
538 let ch = match ch {
539 '\r' => {
540 if chars.peek() == Some(&'\n') {
541 chars.next();
542 }
543 ' '
544 }
545 '\n' => ' ',
546 _ => ch,
547 };
548 if matches!(
549 ch,
550 '\\' | '`'
551 | '*'
552 | '_'
553 | '['
554 | ']'
555 | '('
556 | ')'
557 | '!'
558 | '<'
559 | '>'
560 | '#'
561 | '|'
562 | '~'
563 | '&'
564 ) {
565 out.push('\\');
566 }
567 out.push(ch);
568 }
569 out
570}
571
572#[must_use]
576pub fn markdown_code_span(value: &str) -> String {
577 let longest_run = value
578 .split(|c| c != '`')
579 .map(str::len)
580 .max()
581 .unwrap_or_default();
582 let fence = "`".repeat(longest_run + 1);
583 let needs_padding = value.starts_with('`')
584 || value.ends_with('`')
585 || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
586 if needs_padding {
587 format!("{fence} {value} {fence}")
588 } else {
589 format!("{fence}{value}{fence}")
590 }
591}
592
593#[must_use]
597pub fn markdown_table_code_span(value: &str) -> String {
598 let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
599 markdown_code_span(&collapsed.replace('|', "\\|"))
600}
601
602#[must_use]
605pub fn markdown_table_text(value: &str) -> String {
606 value
607 .replace("\r\n", " ")
608 .replace(['\n', '\r'], " ")
609 .replace('|', "\\|")
610}
611
612#[must_use]
614pub fn render_review_envelope(
615 input: &ReviewEnvelopeRenderInput<'_>,
616 status_message: Option<&str>,
617) -> ReviewEnvelopeRenderResult {
618 render_review_envelope_with_id(input, None, None, status_message)
619}
620
621#[must_use]
623pub fn render_review_envelope_with_conclusion(
624 input: &ReviewEnvelopeRenderInput<'_>,
625 conclusion: ReviewCheckConclusion,
626 status_message: Option<&str>,
627) -> ReviewEnvelopeRenderResult {
628 render_review_envelope_with_id(input, None, Some(conclusion), status_message)
629}
630
631#[must_use]
633pub fn render_scoped_review_envelope(
634 input: &ReviewEnvelopeRenderInput<'_>,
635 review_id: &ReviewId,
636 status_message: Option<&str>,
637) -> ReviewEnvelopeRenderResult {
638 render_review_envelope_with_id(input, Some(review_id), None, status_message)
639}
640
641#[must_use]
643pub fn render_scoped_review_envelope_with_conclusion(
644 input: &ReviewEnvelopeRenderInput<'_>,
645 review_id: &ReviewId,
646 conclusion: ReviewCheckConclusion,
647 status_message: Option<&str>,
648) -> ReviewEnvelopeRenderResult {
649 render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
650}
651
652fn render_review_envelope_with_id(
653 input: &ReviewEnvelopeRenderInput<'_>,
654 review_id: Option<&ReviewId>,
655 conclusion: Option<ReviewCheckConclusion>,
656 status_message: Option<&str>,
657) -> ReviewEnvelopeRenderResult {
658 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
659
660 let comments: Vec<ReviewComment> = grouped
661 .groups
662 .iter()
663 .map(|group| {
664 render_review_comment_for_group_with_id(
665 &ReviewCommentRenderInput {
666 provider: input.provider,
667 group,
668 gitlab_diff_refs: input.gitlab_diff_refs,
669 diff_index: input.diff_index,
670 path_prefix: input.path_prefix,
671 include_guidance: input.include_guidance,
672 suggestion_block: input.suggestion_block,
673 guidance_block: input.guidance_block,
674 },
675 review_id,
676 )
677 })
678 .collect();
679
680 let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
681 let summary_text = review_summary_text(
682 input.command,
683 input.provider,
684 comments.len(),
685 status_message,
686 );
687 let summary_fp = summary_fingerprint(&summary_text);
688 let summary_marker = review_markers(&summary_fp, review_id);
689 let body = format!("{summary_text}{summary_marker}");
690 let summary = ReviewEnvelopeSummary {
691 body: body.clone(),
692 fingerprint: summary_fp,
693 };
694
695 let truncation = ReviewEnvelopeTruncation {
696 body: comments.iter().any(review_comment_truncated),
697 comment_limit: grouped.truncated,
698 };
699
700 ReviewEnvelopeRenderResult {
701 envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
702 truncation,
703 }
704}
705
706fn review_summary_text(
713 command: &str,
714 provider: CiProvider,
715 comment_count: usize,
716 status_message: Option<&str>,
717) -> String {
718 let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
719 format!(
720 "### Fallow {}{}\n\n{}\n\n<!-- fallow-review -->",
721 command_title(command),
722 status,
723 inline_comment_line(provider, comment_count),
724 )
725}
726
727fn inline_comment_line(provider: CiProvider, count: usize) -> String {
728 if count == 0 {
729 return format!(
730 "No findings anchored to the changed lines in this {}.",
731 change_noun(provider)
732 );
733 }
734 format!(
735 "{count} inline comment{} on the changed lines. Open the {} tab to review.",
736 if count == 1 { "" } else { "s" },
737 changes_tab(provider),
738 )
739}
740
741#[derive(Debug, PartialEq, Eq)]
744pub struct GroupedReviewIssues<'a> {
745 pub groups: Vec<Vec<&'a CiIssue>>,
747 pub truncated: bool,
749}
750
751#[must_use]
754pub fn group_review_issues_by_path_line(
755 issues: &[CiIssue],
756 max_groups: usize,
757) -> GroupedReviewIssues<'_> {
758 if max_groups == 0 {
759 return GroupedReviewIssues {
760 groups: Vec::new(),
761 truncated: !issues.is_empty(),
762 };
763 }
764 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
765 let mut current: Vec<&CiIssue> = Vec::new();
766 let mut current_key: Option<(&str, u64)> = None;
767 for issue in issues {
768 let key = (issue.path.as_str(), issue.line);
769 if Some(key) != current_key {
770 if !current.is_empty() {
771 groups.push(std::mem::take(&mut current));
772 if groups.len() == max_groups {
773 return GroupedReviewIssues {
774 groups,
775 truncated: true,
776 };
777 }
778 }
779 current_key = Some(key);
780 }
781 current.push(issue);
782 }
783 if !current.is_empty() && groups.len() < max_groups {
784 groups.push(current);
785 }
786 GroupedReviewIssues {
787 groups,
788 truncated: false,
789 }
790}
791
792fn review_comment_truncated(comment: &ReviewComment) -> bool {
793 match comment {
794 ReviewComment::GitHub(comment) => comment.truncated,
795 ReviewComment::GitLab(comment) => comment.truncated,
796 }
797}
798
799pub struct ReviewCommentRenderInput<'a, 'group> {
801 pub provider: CiProvider,
803 pub group: &'a [&'group CiIssue],
805 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
807 pub diff_index: Option<&'a DiffIndex>,
809 pub path_prefix: &'a str,
811 pub include_guidance: bool,
813 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
816 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
818}
819
820#[must_use]
822pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
823 render_review_comment_for_group_with_id(input, None)
824}
825
826fn render_review_comment_for_group_with_id(
827 input: &ReviewCommentRenderInput<'_, '_>,
828 review_id: Option<&ReviewId>,
829) -> ReviewComment {
830 assert!(
831 !input.group.is_empty(),
832 "group_review_issues_by_path_line never yields empty"
833 );
834 let representative = input.group[0];
835 let fingerprint = if input.group.len() == 1 {
836 representative.fingerprint.clone()
837 } else {
838 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
839 composite_fingerprint(&constituents)
840 };
841
842 let content = build_merged_comment_content(input);
843 let marker_line = review_markers(&fingerprint, review_id);
844 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
845
846 build_review_comment(ReviewCommentInput {
847 provider: input.provider,
848 representative,
849 gitlab_diff_refs: input.gitlab_diff_refs,
850 diff_index: input.diff_index,
851 path_prefix: input.path_prefix,
852 body,
853 fingerprint,
854 truncated,
855 })
856}
857
858#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
859fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
860 let mut content = String::new();
861 for (index, issue) in input.group.iter().enumerate() {
862 let label = review_label_from_codeclimate(&issue.severity);
863 if index > 0 {
864 content.push_str("\n\n");
865 }
866 write!(
867 content,
868 "**{}** `{}`: {}",
869 label,
870 escape_md(&issue.rule_id),
871 escape_md(&issue.description)
872 )
873 .expect("write to String is infallible");
874 if !issue.other_locations.is_empty() {
875 content.push_str("\n\nOther locations: ");
876 let locations = issue
877 .other_locations
878 .iter()
879 .map(|location| {
880 markdown_code_span(&format!(
881 "{}:{}-{}",
882 apply_path_prefix(input.path_prefix, &location.path),
883 location.line,
884 location.end_line
885 ))
886 })
887 .collect::<Vec<_>>()
888 .join(", ");
889 content.push_str(&locations);
890 }
891 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
892 content.push_str(&suggestion);
893 }
894 if input.include_guidance
895 && let Some(guidance) = (input.guidance_block)(issue)
896 {
897 content.push_str(&guidance);
898 }
899 }
900 content
901}
902
903struct ReviewCommentInput<'a> {
904 provider: CiProvider,
905 representative: &'a CiIssue,
906 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
907 diff_index: Option<&'a DiffIndex>,
908 path_prefix: &'a str,
909 body: String,
910 fingerprint: String,
911 truncated: bool,
912}
913
914fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
915 let ReviewCommentInput {
916 provider,
917 representative,
918 gitlab_diff_refs,
919 diff_index,
920 path_prefix,
921 body,
922 fingerprint,
923 truncated,
924 } = input;
925 match provider {
926 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
927 path: apply_path_prefix(path_prefix, &representative.path),
928 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
929 side: GitHubReviewSide::Right,
930 body,
931 fingerprint,
932 truncated,
933 }),
934 CiProvider::Gitlab => {
935 let old_rel = diff_index
938 .and_then(|di| di.old_path_for_root_relative(&representative.path))
939 .map_or_else(|| representative.path.clone(), Cow::into_owned);
940 let new_path = apply_path_prefix(path_prefix, &representative.path);
941 let old_path = apply_path_prefix(path_prefix, &old_rel);
942 let position = GitLabReviewPosition {
943 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
944 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
945 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
946 position_type: GitLabReviewPositionType::Text,
947 old_path,
948 new_path,
949 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
950 };
951 ReviewComment::GitLab(GitLabReviewComment {
952 body,
953 position,
954 fingerprint,
955 truncated,
956 })
957 }
958 }
959}
960
961#[must_use]
965pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
966 let intact_len = content.len() + marker_line.len();
967 if intact_len <= MAX_COMMENT_BODY_BYTES {
968 let mut out = String::with_capacity(intact_len);
969 out.push_str(content);
970 out.push_str(marker_line);
971 return (out, false);
972 }
973 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
974 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
975 let mut cut = budget.min(content.len());
976 while cut > 0 && !content.is_char_boundary(cut) {
977 cut -= 1;
978 }
979 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
980 out.push_str(&content[..cut]);
981 out.push_str(TRUNCATION_SUFFIX);
982 out.push_str(marker_line);
983 (out, true)
984}
985
986#[must_use]
989pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
990 match severity_name.as_bytes() {
991 b"major" | b"critical" | b"blocker" => "error",
992 _ => "warn",
993 }
994}
995
996#[must_use]
999pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1000 if issues
1001 .iter()
1002 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1003 {
1004 ReviewCheckConclusion::Failure
1005 } else if issues.is_empty() {
1006 ReviewCheckConclusion::Success
1007 } else {
1008 ReviewCheckConclusion::Neutral
1009 }
1010}
1011
1012fn build_review_envelope_output(
1013 provider: CiProvider,
1014 body: String,
1015 summary: ReviewEnvelopeSummary,
1016 comments: Vec<ReviewComment>,
1017 conclusion: ReviewCheckConclusion,
1018) -> ReviewEnvelopeOutput {
1019 match provider {
1020 CiProvider::Github => ReviewEnvelopeOutput {
1021 event: Some(ReviewEnvelopeEvent::Comment),
1022 body,
1023 summary,
1024 comments,
1025 marker_regex: default_marker_regex(),
1026 marker_regex_flags: default_marker_regex_flags(),
1027 meta: ReviewEnvelopeMeta {
1028 schema: ReviewEnvelopeSchema::V3,
1029 provider: ReviewProvider::Github,
1030 check_conclusion: Some(conclusion),
1031 },
1032 },
1033 CiProvider::Gitlab => ReviewEnvelopeOutput {
1034 event: None,
1035 body,
1036 summary,
1037 comments,
1038 marker_regex: default_marker_regex(),
1039 marker_regex_flags: default_marker_regex_flags(),
1040 meta: ReviewEnvelopeMeta {
1041 schema: ReviewEnvelopeSchema::V3,
1042 provider: ReviewProvider::Gitlab,
1043 check_conclusion: None,
1044 },
1045 },
1046 }
1047}
1048
1049fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1050 let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
1051 match review_id {
1052 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1053 None => fingerprint,
1054 }
1055}
1056
1057#[must_use]
1059pub fn summary_fingerprint(body: &str) -> String {
1060 fingerprint_hash(&[body])
1061}
1062
1063#[must_use]
1067pub fn composite_fingerprint(constituents: &[&str]) -> String {
1068 let mut sorted: Vec<&str> = constituents.to_vec();
1069 sorted.sort_unstable();
1070 let joined = sorted.join(":");
1071 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1078
1079 fn category_for_rule(rule_id: &str) -> &'static str {
1080 match rule_id {
1081 "fallow/code-duplication" => "Duplication",
1082 "fallow/high-complexity" => "Health",
1083 "fallow/unused-dependency" => "Dependencies",
1084 _ => "Dead code",
1085 }
1086 }
1087
1088 #[test]
1089 fn extracts_issues_from_codeclimate() {
1090 let value = serde_json::json!([{
1091 "check_name": "fallow/unused-export",
1092 "description": "Export x is never imported",
1093 "severity": "minor",
1094 "fingerprint": "abc",
1095 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1096 }]);
1097 let issues = issues_from_codeclimate(&value);
1098 assert_eq!(issues.len(), 1);
1099 assert_eq!(issues[0].path, "src/a.ts");
1100 assert_eq!(issues[0].line, 7);
1101 }
1102
1103 #[test]
1104 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1105 let severities = [
1106 (CodeClimateSeverity::Info, "info"),
1107 (CodeClimateSeverity::Minor, "minor"),
1108 (CodeClimateSeverity::Major, "major"),
1109 (CodeClimateSeverity::Critical, "critical"),
1110 (CodeClimateSeverity::Blocker, "blocker"),
1111 ];
1112 let typed = severities
1113 .iter()
1114 .enumerate()
1115 .map(|(index, (severity, _))| CodeClimateIssue {
1116 kind: CodeClimateIssueKind::Issue,
1117 check_name: format!("fallow/rule-{index}"),
1118 description: format!("Finding {index}"),
1119 categories: vec!["Complexity".to_owned()],
1120 severity: *severity,
1121 fingerprint: format!("fp-{index}"),
1122 location: CodeClimateLocation {
1123 path: format!("src/{index}.ts"),
1124 lines: CodeClimateLines {
1125 begin: u32::try_from(index + 1).expect("small fixture index"),
1126 end: Some(u32::try_from(index + 3).expect("small fixture index")),
1127 },
1128 },
1129 other_locations: vec![CodeClimateLocation {
1130 path: format!("src/peer-{index}.ts"),
1131 lines: CodeClimateLines {
1132 begin: 20,
1133 end: Some(24),
1134 },
1135 }],
1136 owner: None,
1137 group: None,
1138 })
1139 .collect::<Vec<_>>();
1140 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1141
1142 assert_eq!(
1143 issues_from_codeclimate_issues(&typed),
1144 issues_from_codeclimate(&value)
1145 );
1146 let normalized = issues_from_codeclimate_issues(&typed);
1147 assert_eq!(normalized[0].end_line, Some(3));
1148 assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1149 assert_eq!(normalized[0].other_locations[0].line, 20);
1150 assert_eq!(normalized[0].other_locations[0].end_line, 24);
1151 let typed_labels = issues_from_codeclimate_issues(&typed)
1152 .into_iter()
1153 .map(|issue| issue.severity)
1154 .collect::<Vec<_>>();
1155 let expected_labels = severities
1156 .iter()
1157 .map(|(_, label)| (*label).to_owned())
1158 .collect::<Vec<_>>();
1159 assert_eq!(typed_labels, expected_labels);
1160 }
1161
1162 #[test]
1163 fn review_comment_renders_repository_prefixed_peer_ranges() {
1164 let issue = CiIssue {
1165 rule_id: "fallow/code-duplication".to_owned(),
1166 description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1167 severity: "minor".to_owned(),
1168 path: "src/a.ts".to_owned(),
1169 line: 5,
1170 end_line: Some(15),
1171 other_locations: vec![CiLocation {
1172 path: "src/b.ts".to_owned(),
1173 line: 30,
1174 end_line: 40,
1175 }],
1176 fingerprint: "instance-fingerprint".to_owned(),
1177 };
1178 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1179 provider: CiProvider::Gitlab,
1180 group: &[&issue],
1181 gitlab_diff_refs: None,
1182 diff_index: None,
1183 path_prefix: "packages/app",
1184 include_guidance: false,
1185 suggestion_block: &|_, _| None,
1186 guidance_block: &|_| None,
1187 });
1188 let ReviewComment::GitLab(comment) = comment else {
1189 panic!("expected GitLab comment");
1190 };
1191
1192 assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1193 assert!(
1194 comment
1195 .body
1196 .contains("Other locations: `packages/app/src/b.ts:30-40`")
1197 );
1198 }
1199
1200 #[test]
1201 fn renders_default_empty_comment() {
1202 let body = render_pr_comment(&PrCommentRenderInput {
1203 command: "check",
1204 provider: CiProvider::Github,
1205 issues: &[],
1206 marker_id: "fallow-results".to_owned(),
1207 max_comments: 50,
1208 category_for_rule: &category_for_rule,
1209 });
1210 assert!(body.contains("<!-- fallow-id: fallow-results"));
1211 assert!(body.contains("No findings for this pull request."));
1212 }
1213
1214 fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1215 CiIssue {
1216 rule_id: rule_id.to_owned(),
1217 description: description.to_owned(),
1218 severity: severity.to_owned(),
1219 path: path.to_owned(),
1220 line: 3,
1221 end_line: None,
1222 other_locations: Vec::new(),
1223 fingerprint: path.to_owned(),
1224 }
1225 }
1226
1227 #[test]
1228 fn pr_comment_titles_by_content_and_names_its_only_category() {
1229 let issues = vec![
1230 pr_comment_issue(
1231 "fallow/unresolved-import",
1232 "Import './x' could not be resolved",
1233 "major",
1234 "src/a.ts",
1235 ),
1236 pr_comment_issue(
1237 "fallow/unresolved-import",
1238 "Import './y' could not be resolved",
1239 "major",
1240 "src/b.ts",
1241 ),
1242 ];
1243 let body = render_pr_comment(&PrCommentRenderInput {
1244 command: "dead-code",
1245 provider: CiProvider::Github,
1246 issues: &issues,
1247 marker_id: "fallow-results".to_owned(),
1248 max_comments: 50,
1249 category_for_rule: &category_for_rule,
1250 });
1251 assert!(body.contains("### Fallow codebase report"), "{body}");
1252 assert!(body.contains("**Quality gate failed**"), "{body}");
1253 assert!(body.contains("Found **2** findings."), "{body}");
1254 assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1255 }
1256
1257 #[test]
1258 fn pr_comment_breakdown_indexes_several_categories() {
1259 let issues = vec![
1260 pr_comment_issue(
1261 "fallow/unresolved-import",
1262 "Import './x' could not be resolved",
1263 "major",
1264 "src/a.ts",
1265 ),
1266 pr_comment_issue(
1267 "fallow/unused-dependency",
1268 "Package 'lodash' is never imported",
1269 "minor",
1270 "package.json",
1271 ),
1272 ];
1273 let body = render_pr_comment(&PrCommentRenderInput {
1274 command: "check",
1275 provider: CiProvider::Github,
1276 issues: &issues,
1277 marker_id: "fallow-results".to_owned(),
1278 max_comments: 50,
1279 category_for_rule: &category_for_rule,
1280 });
1281 assert!(
1282 body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1283 "{body}"
1284 );
1285 }
1286
1287 #[test]
1288 fn pr_comment_empty_state_speaks_the_provider_language() {
1289 let github = render_pr_comment(&PrCommentRenderInput {
1290 command: "dead-code",
1291 provider: CiProvider::Github,
1292 issues: &[],
1293 marker_id: "fallow-results".to_owned(),
1294 max_comments: 50,
1295 category_for_rule: &category_for_rule,
1296 });
1297 let gitlab = render_pr_comment(&PrCommentRenderInput {
1298 command: "dead-code",
1299 provider: CiProvider::Gitlab,
1300 issues: &[],
1301 marker_id: "fallow-results".to_owned(),
1302 max_comments: 50,
1303 category_for_rule: &category_for_rule,
1304 });
1305 assert!(
1306 github.contains("No findings for this pull request."),
1307 "{github}"
1308 );
1309 assert!(
1310 gitlab.contains("No findings for this merge request."),
1311 "{gitlab}"
1312 );
1313 assert!(
1314 github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1315 "{github}"
1316 );
1317 assert!(github.contains("Generated by fallow."), "{github}");
1318 }
1319
1320 #[test]
1321 fn escape_md_escapes_inline_commonmark_specials() {
1322 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1323 let escaped = escape_md(raw);
1324 for ch in [
1325 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1326 ] {
1327 let raw_count = raw.chars().filter(|c| c == &ch).count();
1328 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1329 assert_eq!(
1330 raw_count, escaped_count,
1331 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1332 );
1333 }
1334 }
1335
1336 #[test]
1337 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1338 let raw = "value *suspicious* here";
1339 let escaped = escape_md(raw);
1340 assert!(escaped.contains(r"\&"), "got: {escaped}");
1341 assert!(escaped.contains(r"\#"), "got: {escaped}");
1342 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1343 }
1344
1345 #[test]
1346 fn summary_label_foreshadows_truncation() {
1347 assert_eq!(
1348 summary_label("Duplication", 160, 50),
1349 "Duplication (160, showing 50)"
1350 );
1351 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1352 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1353 }
1354
1355 #[test]
1356 fn escape_md_does_not_escape_block_only_markers() {
1357 let raw = "fallow/test-only-dependency package.json:12";
1358 let escaped = escape_md(raw);
1359 assert!(!escaped.contains("\\-"), "should not escape `-`");
1360 assert!(!escaped.contains("\\."), "should not escape `.`");
1361 assert_eq!(escaped, raw);
1362 }
1363
1364 #[test]
1365 fn escape_md_collapses_newlines_to_spaces() {
1366 let raw = "first\nsecond\nthird";
1367 assert_eq!(escape_md(raw), "first second third");
1368 }
1369
1370 #[test]
1371 fn escape_md_collapses_carriage_returns_to_spaces() {
1372 assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1373 }
1374
1375 #[test]
1376 fn escape_md_trims_surrounding_whitespace() {
1377 assert_eq!(escape_md(" a\u{2003}\r\n"), "a");
1378 assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1379 }
1380
1381 #[test]
1382 fn escape_md_collapses_crlf_to_one_space() {
1383 let collapsed = escape_md("a\r\nb\rc\nd");
1384 assert_eq!(collapsed, "a b c d");
1385 assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1386 }
1387
1388 #[test]
1389 fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1390 assert_eq!(escape_md("a\tb"), "a\tb");
1391 assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1392 }
1393
1394 #[test]
1395 fn escape_md_passes_non_ascii_through_unchanged() {
1396 assert_eq!(escape_md("é🦀"), "é🦀");
1397 }
1398
1399 #[test]
1400 fn markdown_code_span_grows_fence_past_inner_backticks() {
1401 assert_eq!(markdown_code_span("plain"), "`plain`");
1402 assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1403 assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1404 }
1405
1406 #[test]
1407 fn markdown_table_code_span_escapes_pipes() {
1408 assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1409 assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1410 }
1411
1412 #[test]
1413 fn markdown_table_code_span_collapses_line_endings() {
1414 assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1415 }
1416
1417 #[test]
1418 fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1419 assert_eq!(markdown_table_text("a|b"), "a\\|b");
1420 assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1421 }
1422
1423 #[test]
1424 fn escape_md_leaves_safe_chars_unchanged() {
1425 let raw = "Export 'helperFn' is never imported by other modules";
1426 assert_eq!(
1427 escape_md(raw),
1428 r"Export 'helperFn' is never imported by other modules"
1429 );
1430 }
1431
1432 #[test]
1433 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1434 for rule_id in PROJECT_LEVEL_RULE_IDS {
1435 assert!(
1436 is_project_level_rule(rule_id),
1437 "{rule_id} must be project-level"
1438 );
1439 }
1440 for rule_id in [
1441 "fallow/unused-file",
1442 "fallow/unused-export",
1443 "fallow/unused-type",
1444 "fallow/unused-enum-member",
1445 "fallow/unused-class-member",
1446 "fallow/unused-store-member",
1447 "fallow/unresolved-import",
1448 "fallow/unlisted-dependency",
1449 "fallow/duplicate-export",
1450 "fallow/circular-dependency",
1451 "fallow/re-export-cycle",
1452 "fallow/boundary-violation",
1453 "fallow/stale-suppression",
1454 "fallow/private-type-leak",
1455 "fallow/high-complexity",
1456 "fallow/high-crap-score",
1457 ] {
1458 assert!(
1459 !is_project_level_rule(rule_id),
1460 "{rule_id} must NOT be project-level"
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn escape_md_double_apply_is_safe() {
1467 let raw = "code with `backticks` and *stars*";
1468 let once = escape_md(raw);
1469 let twice = escape_md(&once);
1470 assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1471 assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1472 }
1473}