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(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
615 render_review_envelope_with_id(input, None, None, None)
616}
617
618#[must_use]
620pub fn render_review_envelope_with_conclusion(
621 input: &ReviewEnvelopeRenderInput<'_>,
622 conclusion: ReviewCheckConclusion,
623 status_message: Option<&str>,
624) -> ReviewEnvelopeRenderResult {
625 render_review_envelope_with_id(input, None, Some(conclusion), status_message)
626}
627
628#[must_use]
630pub fn render_scoped_review_envelope(
631 input: &ReviewEnvelopeRenderInput<'_>,
632 review_id: &ReviewId,
633) -> ReviewEnvelopeRenderResult {
634 render_review_envelope_with_id(input, Some(review_id), None, None)
635}
636
637#[must_use]
639pub fn render_scoped_review_envelope_with_conclusion(
640 input: &ReviewEnvelopeRenderInput<'_>,
641 review_id: &ReviewId,
642 conclusion: ReviewCheckConclusion,
643 status_message: Option<&str>,
644) -> ReviewEnvelopeRenderResult {
645 render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
646}
647
648fn render_review_envelope_with_id(
649 input: &ReviewEnvelopeRenderInput<'_>,
650 review_id: Option<&ReviewId>,
651 conclusion: Option<ReviewCheckConclusion>,
652 status_message: Option<&str>,
653) -> ReviewEnvelopeRenderResult {
654 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
655
656 let comments: Vec<ReviewComment> = grouped
657 .groups
658 .iter()
659 .map(|group| {
660 render_review_comment_for_group_with_id(
661 &ReviewCommentRenderInput {
662 provider: input.provider,
663 group,
664 gitlab_diff_refs: input.gitlab_diff_refs,
665 diff_index: input.diff_index,
666 path_prefix: input.path_prefix,
667 include_guidance: input.include_guidance,
668 suggestion_block: input.suggestion_block,
669 guidance_block: input.guidance_block,
670 },
671 review_id,
672 )
673 })
674 .collect();
675
676 let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
677 let summary_text = review_summary_text(
678 input.command,
679 input.provider,
680 comments.len(),
681 status_message,
682 );
683 let summary_fp = summary_fingerprint(&summary_text);
684 let summary_marker = review_markers(&summary_fp, review_id);
685 let body = format!("{summary_text}{summary_marker}");
686 let summary = ReviewEnvelopeSummary {
687 body: body.clone(),
688 fingerprint: summary_fp,
689 };
690
691 let truncation = ReviewEnvelopeTruncation {
692 body: comments.iter().any(review_comment_truncated),
693 comment_limit: grouped.truncated,
694 };
695
696 ReviewEnvelopeRenderResult {
697 envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
698 truncation,
699 }
700}
701
702fn review_summary_text(
709 command: &str,
710 provider: CiProvider,
711 comment_count: usize,
712 status_message: Option<&str>,
713) -> String {
714 let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
715 format!(
716 "### Fallow {}{}\n\n{}\n\n<!-- fallow-review -->",
717 command_title(command),
718 status,
719 inline_comment_line(provider, comment_count),
720 )
721}
722
723fn inline_comment_line(provider: CiProvider, count: usize) -> String {
724 if count == 0 {
725 return format!(
726 "No findings anchored to the changed lines in this {}.",
727 change_noun(provider)
728 );
729 }
730 format!(
731 "{count} inline comment{} on the changed lines. Open the {} tab to review.",
732 if count == 1 { "" } else { "s" },
733 changes_tab(provider),
734 )
735}
736
737#[derive(Debug, PartialEq, Eq)]
740pub struct GroupedReviewIssues<'a> {
741 pub groups: Vec<Vec<&'a CiIssue>>,
743 pub truncated: bool,
745}
746
747#[must_use]
750pub fn group_review_issues_by_path_line(
751 issues: &[CiIssue],
752 max_groups: usize,
753) -> GroupedReviewIssues<'_> {
754 if max_groups == 0 {
755 return GroupedReviewIssues {
756 groups: Vec::new(),
757 truncated: !issues.is_empty(),
758 };
759 }
760 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
761 let mut current: Vec<&CiIssue> = Vec::new();
762 let mut current_key: Option<(&str, u64)> = None;
763 for issue in issues {
764 let key = (issue.path.as_str(), issue.line);
765 if Some(key) != current_key {
766 if !current.is_empty() {
767 groups.push(std::mem::take(&mut current));
768 if groups.len() == max_groups {
769 return GroupedReviewIssues {
770 groups,
771 truncated: true,
772 };
773 }
774 }
775 current_key = Some(key);
776 }
777 current.push(issue);
778 }
779 if !current.is_empty() && groups.len() < max_groups {
780 groups.push(current);
781 }
782 GroupedReviewIssues {
783 groups,
784 truncated: false,
785 }
786}
787
788fn review_comment_truncated(comment: &ReviewComment) -> bool {
789 match comment {
790 ReviewComment::GitHub(comment) => comment.truncated,
791 ReviewComment::GitLab(comment) => comment.truncated,
792 }
793}
794
795pub struct ReviewCommentRenderInput<'a, 'group> {
797 pub provider: CiProvider,
799 pub group: &'a [&'group CiIssue],
801 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
803 pub diff_index: Option<&'a DiffIndex>,
805 pub path_prefix: &'a str,
807 pub include_guidance: bool,
809 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
812 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
814}
815
816#[must_use]
818pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
819 render_review_comment_for_group_with_id(input, None)
820}
821
822fn render_review_comment_for_group_with_id(
823 input: &ReviewCommentRenderInput<'_, '_>,
824 review_id: Option<&ReviewId>,
825) -> ReviewComment {
826 assert!(
827 !input.group.is_empty(),
828 "group_review_issues_by_path_line never yields empty"
829 );
830 let representative = input.group[0];
831 let fingerprint = if input.group.len() == 1 {
832 representative.fingerprint.clone()
833 } else {
834 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
835 composite_fingerprint(&constituents)
836 };
837
838 let content = build_merged_comment_content(input);
839 let marker_line = review_markers(&fingerprint, review_id);
840 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
841
842 build_review_comment(ReviewCommentInput {
843 provider: input.provider,
844 representative,
845 gitlab_diff_refs: input.gitlab_diff_refs,
846 diff_index: input.diff_index,
847 path_prefix: input.path_prefix,
848 body,
849 fingerprint,
850 truncated,
851 })
852}
853
854#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
855fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
856 let mut content = String::new();
857 for (index, issue) in input.group.iter().enumerate() {
858 let label = review_label_from_codeclimate(&issue.severity);
859 if index > 0 {
860 content.push_str("\n\n");
861 }
862 write!(
863 content,
864 "**{}** `{}`: {}",
865 label,
866 escape_md(&issue.rule_id),
867 escape_md(&issue.description)
868 )
869 .expect("write to String is infallible");
870 if !issue.other_locations.is_empty() {
871 content.push_str("\n\nOther locations: ");
872 let locations = issue
873 .other_locations
874 .iter()
875 .map(|location| {
876 markdown_code_span(&format!(
877 "{}:{}-{}",
878 apply_path_prefix(input.path_prefix, &location.path),
879 location.line,
880 location.end_line
881 ))
882 })
883 .collect::<Vec<_>>()
884 .join(", ");
885 content.push_str(&locations);
886 }
887 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
888 content.push_str(&suggestion);
889 }
890 if input.include_guidance
891 && let Some(guidance) = (input.guidance_block)(issue)
892 {
893 content.push_str(&guidance);
894 }
895 }
896 content
897}
898
899struct ReviewCommentInput<'a> {
900 provider: CiProvider,
901 representative: &'a CiIssue,
902 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
903 diff_index: Option<&'a DiffIndex>,
904 path_prefix: &'a str,
905 body: String,
906 fingerprint: String,
907 truncated: bool,
908}
909
910fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
911 let ReviewCommentInput {
912 provider,
913 representative,
914 gitlab_diff_refs,
915 diff_index,
916 path_prefix,
917 body,
918 fingerprint,
919 truncated,
920 } = input;
921 match provider {
922 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
923 path: apply_path_prefix(path_prefix, &representative.path),
924 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
925 side: GitHubReviewSide::Right,
926 body,
927 fingerprint,
928 truncated,
929 }),
930 CiProvider::Gitlab => {
931 let old_rel = diff_index
934 .and_then(|di| di.old_path_for_root_relative(&representative.path))
935 .map_or_else(|| representative.path.clone(), Cow::into_owned);
936 let new_path = apply_path_prefix(path_prefix, &representative.path);
937 let old_path = apply_path_prefix(path_prefix, &old_rel);
938 let position = GitLabReviewPosition {
939 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
940 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
941 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
942 position_type: GitLabReviewPositionType::Text,
943 old_path,
944 new_path,
945 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
946 };
947 ReviewComment::GitLab(GitLabReviewComment {
948 body,
949 position,
950 fingerprint,
951 truncated,
952 })
953 }
954 }
955}
956
957#[must_use]
961pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
962 let intact_len = content.len() + marker_line.len();
963 if intact_len <= MAX_COMMENT_BODY_BYTES {
964 let mut out = String::with_capacity(intact_len);
965 out.push_str(content);
966 out.push_str(marker_line);
967 return (out, false);
968 }
969 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
970 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
971 let mut cut = budget.min(content.len());
972 while cut > 0 && !content.is_char_boundary(cut) {
973 cut -= 1;
974 }
975 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
976 out.push_str(&content[..cut]);
977 out.push_str(TRUNCATION_SUFFIX);
978 out.push_str(marker_line);
979 (out, true)
980}
981
982#[must_use]
985pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
986 match severity_name.as_bytes() {
987 b"major" | b"critical" | b"blocker" => "error",
988 _ => "warn",
989 }
990}
991
992#[must_use]
995pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
996 if issues
997 .iter()
998 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
999 {
1000 ReviewCheckConclusion::Failure
1001 } else if issues.is_empty() {
1002 ReviewCheckConclusion::Success
1003 } else {
1004 ReviewCheckConclusion::Neutral
1005 }
1006}
1007
1008fn build_review_envelope_output(
1009 provider: CiProvider,
1010 body: String,
1011 summary: ReviewEnvelopeSummary,
1012 comments: Vec<ReviewComment>,
1013 conclusion: ReviewCheckConclusion,
1014) -> ReviewEnvelopeOutput {
1015 match provider {
1016 CiProvider::Github => ReviewEnvelopeOutput {
1017 event: Some(ReviewEnvelopeEvent::Comment),
1018 body,
1019 summary,
1020 comments,
1021 marker_regex: default_marker_regex(),
1022 marker_regex_flags: default_marker_regex_flags(),
1023 meta: ReviewEnvelopeMeta {
1024 schema: ReviewEnvelopeSchema::V3,
1025 provider: ReviewProvider::Github,
1026 check_conclusion: Some(conclusion),
1027 },
1028 },
1029 CiProvider::Gitlab => ReviewEnvelopeOutput {
1030 event: None,
1031 body,
1032 summary,
1033 comments,
1034 marker_regex: default_marker_regex(),
1035 marker_regex_flags: default_marker_regex_flags(),
1036 meta: ReviewEnvelopeMeta {
1037 schema: ReviewEnvelopeSchema::V3,
1038 provider: ReviewProvider::Gitlab,
1039 check_conclusion: None,
1040 },
1041 },
1042 }
1043}
1044
1045fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1046 let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
1047 match review_id {
1048 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1049 None => fingerprint,
1050 }
1051}
1052
1053#[must_use]
1055pub fn summary_fingerprint(body: &str) -> String {
1056 fingerprint_hash(&[body])
1057}
1058
1059#[must_use]
1063pub fn composite_fingerprint(constituents: &[&str]) -> String {
1064 let mut sorted: Vec<&str> = constituents.to_vec();
1065 sorted.sort_unstable();
1066 let joined = sorted.join(":");
1067 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1074
1075 fn category_for_rule(rule_id: &str) -> &'static str {
1076 match rule_id {
1077 "fallow/code-duplication" => "Duplication",
1078 "fallow/high-complexity" => "Health",
1079 "fallow/unused-dependency" => "Dependencies",
1080 _ => "Dead code",
1081 }
1082 }
1083
1084 #[test]
1085 fn extracts_issues_from_codeclimate() {
1086 let value = serde_json::json!([{
1087 "check_name": "fallow/unused-export",
1088 "description": "Export x is never imported",
1089 "severity": "minor",
1090 "fingerprint": "abc",
1091 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1092 }]);
1093 let issues = issues_from_codeclimate(&value);
1094 assert_eq!(issues.len(), 1);
1095 assert_eq!(issues[0].path, "src/a.ts");
1096 assert_eq!(issues[0].line, 7);
1097 }
1098
1099 #[test]
1100 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1101 let severities = [
1102 (CodeClimateSeverity::Info, "info"),
1103 (CodeClimateSeverity::Minor, "minor"),
1104 (CodeClimateSeverity::Major, "major"),
1105 (CodeClimateSeverity::Critical, "critical"),
1106 (CodeClimateSeverity::Blocker, "blocker"),
1107 ];
1108 let typed = severities
1109 .iter()
1110 .enumerate()
1111 .map(|(index, (severity, _))| CodeClimateIssue {
1112 kind: CodeClimateIssueKind::Issue,
1113 check_name: format!("fallow/rule-{index}"),
1114 description: format!("Finding {index}"),
1115 categories: vec!["Complexity".to_owned()],
1116 severity: *severity,
1117 fingerprint: format!("fp-{index}"),
1118 location: CodeClimateLocation {
1119 path: format!("src/{index}.ts"),
1120 lines: CodeClimateLines {
1121 begin: u32::try_from(index + 1).expect("small fixture index"),
1122 end: Some(u32::try_from(index + 3).expect("small fixture index")),
1123 },
1124 },
1125 other_locations: vec![CodeClimateLocation {
1126 path: format!("src/peer-{index}.ts"),
1127 lines: CodeClimateLines {
1128 begin: 20,
1129 end: Some(24),
1130 },
1131 }],
1132 owner: None,
1133 group: None,
1134 })
1135 .collect::<Vec<_>>();
1136 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1137
1138 assert_eq!(
1139 issues_from_codeclimate_issues(&typed),
1140 issues_from_codeclimate(&value)
1141 );
1142 let normalized = issues_from_codeclimate_issues(&typed);
1143 assert_eq!(normalized[0].end_line, Some(3));
1144 assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1145 assert_eq!(normalized[0].other_locations[0].line, 20);
1146 assert_eq!(normalized[0].other_locations[0].end_line, 24);
1147 let typed_labels = issues_from_codeclimate_issues(&typed)
1148 .into_iter()
1149 .map(|issue| issue.severity)
1150 .collect::<Vec<_>>();
1151 let expected_labels = severities
1152 .iter()
1153 .map(|(_, label)| (*label).to_owned())
1154 .collect::<Vec<_>>();
1155 assert_eq!(typed_labels, expected_labels);
1156 }
1157
1158 #[test]
1159 fn review_comment_renders_repository_prefixed_peer_ranges() {
1160 let issue = CiIssue {
1161 rule_id: "fallow/code-duplication".to_owned(),
1162 description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1163 severity: "minor".to_owned(),
1164 path: "src/a.ts".to_owned(),
1165 line: 5,
1166 end_line: Some(15),
1167 other_locations: vec![CiLocation {
1168 path: "src/b.ts".to_owned(),
1169 line: 30,
1170 end_line: 40,
1171 }],
1172 fingerprint: "instance-fingerprint".to_owned(),
1173 };
1174 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1175 provider: CiProvider::Gitlab,
1176 group: &[&issue],
1177 gitlab_diff_refs: None,
1178 diff_index: None,
1179 path_prefix: "packages/app",
1180 include_guidance: false,
1181 suggestion_block: &|_, _| None,
1182 guidance_block: &|_| None,
1183 });
1184 let ReviewComment::GitLab(comment) = comment else {
1185 panic!("expected GitLab comment");
1186 };
1187
1188 assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1189 assert!(
1190 comment
1191 .body
1192 .contains("Other locations: `packages/app/src/b.ts:30-40`")
1193 );
1194 }
1195
1196 #[test]
1197 fn renders_default_empty_comment() {
1198 let body = render_pr_comment(&PrCommentRenderInput {
1199 command: "check",
1200 provider: CiProvider::Github,
1201 issues: &[],
1202 marker_id: "fallow-results".to_owned(),
1203 max_comments: 50,
1204 category_for_rule: &category_for_rule,
1205 });
1206 assert!(body.contains("<!-- fallow-id: fallow-results"));
1207 assert!(body.contains("No findings for this pull request."));
1208 }
1209
1210 fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1211 CiIssue {
1212 rule_id: rule_id.to_owned(),
1213 description: description.to_owned(),
1214 severity: severity.to_owned(),
1215 path: path.to_owned(),
1216 line: 3,
1217 end_line: None,
1218 other_locations: Vec::new(),
1219 fingerprint: path.to_owned(),
1220 }
1221 }
1222
1223 #[test]
1224 fn pr_comment_titles_by_content_and_names_its_only_category() {
1225 let issues = vec![
1226 pr_comment_issue(
1227 "fallow/unresolved-import",
1228 "Import './x' could not be resolved",
1229 "major",
1230 "src/a.ts",
1231 ),
1232 pr_comment_issue(
1233 "fallow/unresolved-import",
1234 "Import './y' could not be resolved",
1235 "major",
1236 "src/b.ts",
1237 ),
1238 ];
1239 let body = render_pr_comment(&PrCommentRenderInput {
1240 command: "dead-code",
1241 provider: CiProvider::Github,
1242 issues: &issues,
1243 marker_id: "fallow-results".to_owned(),
1244 max_comments: 50,
1245 category_for_rule: &category_for_rule,
1246 });
1247 assert!(body.contains("### Fallow codebase report"), "{body}");
1248 assert!(body.contains("**Quality gate failed**"), "{body}");
1249 assert!(body.contains("Found **2** findings."), "{body}");
1250 assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1251 }
1252
1253 #[test]
1254 fn pr_comment_breakdown_indexes_several_categories() {
1255 let issues = vec![
1256 pr_comment_issue(
1257 "fallow/unresolved-import",
1258 "Import './x' could not be resolved",
1259 "major",
1260 "src/a.ts",
1261 ),
1262 pr_comment_issue(
1263 "fallow/unused-dependency",
1264 "Package 'lodash' is never imported",
1265 "minor",
1266 "package.json",
1267 ),
1268 ];
1269 let body = render_pr_comment(&PrCommentRenderInput {
1270 command: "check",
1271 provider: CiProvider::Github,
1272 issues: &issues,
1273 marker_id: "fallow-results".to_owned(),
1274 max_comments: 50,
1275 category_for_rule: &category_for_rule,
1276 });
1277 assert!(
1278 body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1279 "{body}"
1280 );
1281 }
1282
1283 #[test]
1284 fn pr_comment_empty_state_speaks_the_provider_language() {
1285 let github = render_pr_comment(&PrCommentRenderInput {
1286 command: "dead-code",
1287 provider: CiProvider::Github,
1288 issues: &[],
1289 marker_id: "fallow-results".to_owned(),
1290 max_comments: 50,
1291 category_for_rule: &category_for_rule,
1292 });
1293 let gitlab = render_pr_comment(&PrCommentRenderInput {
1294 command: "dead-code",
1295 provider: CiProvider::Gitlab,
1296 issues: &[],
1297 marker_id: "fallow-results".to_owned(),
1298 max_comments: 50,
1299 category_for_rule: &category_for_rule,
1300 });
1301 assert!(
1302 github.contains("No findings for this pull request."),
1303 "{github}"
1304 );
1305 assert!(
1306 gitlab.contains("No findings for this merge request."),
1307 "{gitlab}"
1308 );
1309 assert!(
1310 github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1311 "{github}"
1312 );
1313 assert!(github.contains("Generated by fallow."), "{github}");
1314 }
1315
1316 #[test]
1317 fn escape_md_escapes_inline_commonmark_specials() {
1318 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1319 let escaped = escape_md(raw);
1320 for ch in [
1321 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1322 ] {
1323 let raw_count = raw.chars().filter(|c| c == &ch).count();
1324 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1325 assert_eq!(
1326 raw_count, escaped_count,
1327 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1328 );
1329 }
1330 }
1331
1332 #[test]
1333 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1334 let raw = "value *suspicious* here";
1335 let escaped = escape_md(raw);
1336 assert!(escaped.contains(r"\&"), "got: {escaped}");
1337 assert!(escaped.contains(r"\#"), "got: {escaped}");
1338 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1339 }
1340
1341 #[test]
1342 fn summary_label_foreshadows_truncation() {
1343 assert_eq!(
1344 summary_label("Duplication", 160, 50),
1345 "Duplication (160, showing 50)"
1346 );
1347 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1348 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1349 }
1350
1351 #[test]
1352 fn escape_md_does_not_escape_block_only_markers() {
1353 let raw = "fallow/test-only-dependency package.json:12";
1354 let escaped = escape_md(raw);
1355 assert!(!escaped.contains("\\-"), "should not escape `-`");
1356 assert!(!escaped.contains("\\."), "should not escape `.`");
1357 assert_eq!(escaped, raw);
1358 }
1359
1360 #[test]
1361 fn escape_md_collapses_newlines_to_spaces() {
1362 let raw = "first\nsecond\nthird";
1363 assert_eq!(escape_md(raw), "first second third");
1364 }
1365
1366 #[test]
1367 fn escape_md_collapses_carriage_returns_to_spaces() {
1368 assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1369 }
1370
1371 #[test]
1372 fn escape_md_trims_surrounding_whitespace() {
1373 assert_eq!(escape_md(" a\u{2003}\r\n"), "a");
1374 assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1375 }
1376
1377 #[test]
1378 fn escape_md_collapses_crlf_to_one_space() {
1379 let collapsed = escape_md("a\r\nb\rc\nd");
1380 assert_eq!(collapsed, "a b c d");
1381 assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1382 }
1383
1384 #[test]
1385 fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1386 assert_eq!(escape_md("a\tb"), "a\tb");
1387 assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1388 }
1389
1390 #[test]
1391 fn escape_md_passes_non_ascii_through_unchanged() {
1392 assert_eq!(escape_md("é🦀"), "é🦀");
1393 }
1394
1395 #[test]
1396 fn markdown_code_span_grows_fence_past_inner_backticks() {
1397 assert_eq!(markdown_code_span("plain"), "`plain`");
1398 assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1399 assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1400 }
1401
1402 #[test]
1403 fn markdown_table_code_span_escapes_pipes() {
1404 assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1405 assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1406 }
1407
1408 #[test]
1409 fn markdown_table_code_span_collapses_line_endings() {
1410 assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1411 }
1412
1413 #[test]
1414 fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1415 assert_eq!(markdown_table_text("a|b"), "a\\|b");
1416 assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1417 }
1418
1419 #[test]
1420 fn escape_md_leaves_safe_chars_unchanged() {
1421 let raw = "Export 'helperFn' is never imported by other modules";
1422 assert_eq!(
1423 escape_md(raw),
1424 r"Export 'helperFn' is never imported by other modules"
1425 );
1426 }
1427
1428 #[test]
1429 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1430 for rule_id in PROJECT_LEVEL_RULE_IDS {
1431 assert!(
1432 is_project_level_rule(rule_id),
1433 "{rule_id} must be project-level"
1434 );
1435 }
1436 for rule_id in [
1437 "fallow/unused-file",
1438 "fallow/unused-export",
1439 "fallow/unused-type",
1440 "fallow/unused-enum-member",
1441 "fallow/unused-class-member",
1442 "fallow/unused-store-member",
1443 "fallow/unresolved-import",
1444 "fallow/unlisted-dependency",
1445 "fallow/duplicate-export",
1446 "fallow/circular-dependency",
1447 "fallow/re-export-cycle",
1448 "fallow/boundary-violation",
1449 "fallow/stale-suppression",
1450 "fallow/private-type-leak",
1451 "fallow/high-complexity",
1452 "fallow/high-crap-score",
1453 ] {
1454 assert!(
1455 !is_project_level_rule(rule_id),
1456 "{rule_id} must NOT be project-level"
1457 );
1458 }
1459 }
1460
1461 #[test]
1462 fn escape_md_double_apply_is_safe() {
1463 let raw = "code with `backticks` and *stars*";
1464 let once = escape_md(raw);
1465 let twice = escape_md(&once);
1466 assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1467 assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1468 }
1469}