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