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,
19 Gitlab,
20}
21
22impl CiProvider {
23 #[must_use]
24 pub const fn name(self) -> &'static str {
25 match self {
26 Self::Github => "GitHub",
27 Self::Gitlab => "GitLab",
28 }
29 }
30}
31
32#[must_use]
40pub fn apply_path_prefix(prefix: &str, path: &str) -> String {
41 if prefix.is_empty() {
42 return path.to_owned();
43 }
44 format!("{prefix}/{path}")
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct CiIssue {
50 pub rule_id: String,
51 pub description: String,
52 pub severity: String,
53 pub path: String,
54 pub line: u64,
55 pub fingerprint: String,
56}
57
58pub struct PrCommentRenderInput<'a> {
60 pub command: &'a str,
61 pub provider: CiProvider,
62 pub issues: &'a [CiIssue],
63 pub marker_id: String,
64 pub max_comments: usize,
65 pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct ReviewGitlabDiffRefs {
71 pub base_sha: String,
72 pub start_sha: String,
73 pub head_sha: String,
74}
75
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub struct ReviewEnvelopeTruncation {
79 pub body: bool,
80 pub comment_limit: bool,
81}
82
83#[derive(Debug)]
85pub struct ReviewEnvelopeRenderResult {
86 pub envelope: ReviewEnvelopeOutput,
87 pub truncation: ReviewEnvelopeTruncation,
88}
89
90pub struct ReviewEnvelopeRenderInput<'a> {
92 pub command: &'a str,
93 pub provider: CiProvider,
94 pub issues: &'a [CiIssue],
95 pub diff_index: Option<&'a DiffIndex>,
96 pub path_prefix: &'a str,
98 pub max_comments: usize,
99 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
100 pub include_guidance: bool,
101 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
102 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
103}
104
105pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
107
108pub const MARKER_SUFFIX_V2: &str = " -->";
110
111pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
112const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
113
114#[must_use]
115pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
116 let mut issues = value
117 .as_array()
118 .into_iter()
119 .flatten()
120 .filter_map(issue_from_codeclimate)
121 .collect::<Vec<_>>();
122 sort_ci_issues(&mut issues);
123 issues
124}
125
126#[must_use]
127pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
128 let mut issues = issues
129 .iter()
130 .map(issue_from_codeclimate_issue)
131 .collect::<Vec<_>>();
132 sort_ci_issues(&mut issues);
133 issues
134}
135
136fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
137 let path = value.pointer("/location/path")?.as_str()?.to_string();
138 let line = value
139 .pointer("/location/lines/begin")
140 .and_then(Value::as_u64)
141 .unwrap_or(1);
142 Some(CiIssue {
143 rule_id: value
144 .get("check_name")
145 .and_then(Value::as_str)
146 .unwrap_or("fallow/finding")
147 .to_string(),
148 description: value
149 .get("description")
150 .and_then(Value::as_str)
151 .unwrap_or("Fallow finding")
152 .to_string(),
153 severity: value
154 .get("severity")
155 .and_then(Value::as_str)
156 .unwrap_or("minor")
157 .to_string(),
158 fingerprint: value
159 .get("fingerprint")
160 .and_then(Value::as_str)
161 .unwrap_or("")
162 .to_string(),
163 path,
164 line,
165 })
166}
167
168fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
169 CiIssue {
170 rule_id: issue.check_name.clone(),
171 description: issue.description.clone(),
172 severity: codeclimate_severity_label(issue.severity).to_owned(),
173 path: issue.location.path.clone(),
174 line: u64::from(issue.location.lines.begin),
175 fingerprint: issue.fingerprint.clone(),
176 }
177}
178
179const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
180 match severity {
181 CodeClimateSeverity::Info => "info",
182 CodeClimateSeverity::Minor => "minor",
183 CodeClimateSeverity::Major => "major",
184 CodeClimateSeverity::Critical => "critical",
185 CodeClimateSeverity::Blocker => "blocker",
186 }
187}
188
189fn sort_ci_issues(issues: &mut [CiIssue]) {
190 issues
191 .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
192}
193
194fn fingerprint_hash(parts: &[&str]) -> String {
195 crate::codeclimate_fingerprint_hash(parts)
196}
197
198#[must_use]
199#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
200pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
201 let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
202 let title = command_title(input.command);
203 let count = input.issues.len();
204 let noun = if count == 1 { "finding" } else { "findings" };
205
206 let mut out = String::new();
207 out.push_str(&marker);
208 out.push('\n');
209 write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
210 if count == 0 {
211 writeln!(
212 &mut out,
213 "No {provider} PR/MR findings.",
214 provider = input.provider.name()
215 )
216 .expect("write to string");
217 } else {
218 write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
219 let groups = group_by_category(input.issues, input.category_for_rule);
220 if let [(_, group_issues)] = groups.as_slice() {
221 render_findings_table(&mut out, group_issues, input.max_comments, "Details");
222 } else {
223 for (category, group_issues) in &groups {
224 let summary_label = summary_label(category, group_issues.len(), input.max_comments);
225 render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
226 }
227 }
228 }
229 out.push_str("\nGenerated by fallow.");
230 out
231}
232
233pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
236 "fallow/unused-catalog-entry",
237 "fallow/empty-catalog-group",
238 "fallow/unresolved-catalog-reference",
239 "fallow/unused-dependency-override",
240 "fallow/misconfigured-dependency-override",
241 "fallow/unused-dependency",
242 "fallow/unused-dev-dependency",
243 "fallow/unused-optional-dependency",
244 "fallow/type-only-dependency",
245 "fallow/test-only-dependency",
246 "fallow/dev-dependency-in-production",
247];
248
249#[must_use]
250pub fn is_project_level_rule(rule_id: &str) -> bool {
251 PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
252}
253
254const CATEGORY_ORDER: [&str; 6] = [
255 "Dead code",
256 "Dependencies",
257 "Duplication",
258 "Health",
259 "Architecture",
260 "Suppressions",
261];
262
263fn group_by_category<'a>(
264 issues: &'a [CiIssue],
265 category_for_rule: &dyn Fn(&str) -> &'static str,
266) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
267 let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
268 std::collections::BTreeMap::new();
269 for issue in issues {
270 let category = category_for_rule(&issue.rule_id);
271 buckets.entry(category).or_default().push(issue);
272 }
273 let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
274 for category in CATEGORY_ORDER {
275 if let Some(items) = buckets.remove(category) {
276 ordered.push((category, items));
277 }
278 }
279 for (category, items) in buckets {
280 ordered.push((category, items));
281 }
282 ordered
283}
284
285#[must_use]
286pub fn summary_label(category: &str, total: usize, max: usize) -> String {
287 if total > max {
288 format!("{category} ({total}, showing {max})")
289 } else {
290 format!("{category} ({total})")
291 }
292}
293
294#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
295fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
296 writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
297 out.push_str("| Severity | Rule | Location | Description |\n");
298 out.push_str("| --- | --- | --- | --- |\n");
299 for issue in issues.iter().take(max) {
300 writeln!(
301 out,
302 "| {} | `{}` | `{}`:{} | {} |",
303 escape_md(&issue.severity),
304 escape_md(&issue.rule_id),
305 escape_md(&issue.path),
306 issue.line,
307 escape_md(&issue.description),
308 )
309 .expect("write to string");
310 }
311 if issues.len() > max {
312 writeln!(
313 out,
314 "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
315 issues.len(),
316 )
317 .expect("write to string");
318 }
319 out.push_str("\n</details>\n\n");
320}
321
322#[must_use]
323pub fn command_title(command: &str) -> &'static str {
324 match command {
325 "dead-code" | "check" => "dead-code report",
326 "dupes" => "duplication report",
327 "health" => "health report",
328 "audit" => "audit report",
329 "" | "combined" => "combined report",
330 _ => "report",
331 }
332}
333
334#[must_use]
336pub fn escape_md(value: &str) -> String {
337 let collapsed = value.replace('\n', " ");
338 let mut out = String::with_capacity(collapsed.len());
339 for ch in collapsed.chars() {
340 if matches!(
341 ch,
342 '\\' | '`'
343 | '*'
344 | '_'
345 | '['
346 | ']'
347 | '('
348 | ')'
349 | '!'
350 | '<'
351 | '>'
352 | '#'
353 | '|'
354 | '~'
355 | '&'
356 ) {
357 out.push('\\');
358 }
359 out.push(ch);
360 }
361 out.trim().to_owned()
362}
363
364#[must_use]
366pub fn render_review_envelope(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
367 render_review_envelope_with_id(input, None)
368}
369
370#[must_use]
372pub fn render_scoped_review_envelope(
373 input: &ReviewEnvelopeRenderInput<'_>,
374 review_id: &ReviewId,
375) -> ReviewEnvelopeRenderResult {
376 render_review_envelope_with_id(input, Some(review_id))
377}
378
379fn render_review_envelope_with_id(
380 input: &ReviewEnvelopeRenderInput<'_>,
381 review_id: Option<&ReviewId>,
382) -> ReviewEnvelopeRenderResult {
383 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
384
385 let comments: Vec<ReviewComment> = grouped
386 .groups
387 .iter()
388 .map(|group| {
389 render_review_comment_for_group_with_id(
390 &ReviewCommentRenderInput {
391 provider: input.provider,
392 group,
393 gitlab_diff_refs: input.gitlab_diff_refs,
394 diff_index: input.diff_index,
395 path_prefix: input.path_prefix,
396 include_guidance: input.include_guidance,
397 suggestion_block: input.suggestion_block,
398 guidance_block: input.guidance_block,
399 },
400 review_id,
401 )
402 })
403 .collect();
404
405 let summary_text =
406 review_summary_text(input.command, input.provider, comments.len(), input.issues);
407 let summary_fp = summary_fingerprint(&summary_text);
408 let summary_marker = review_markers(&summary_fp, review_id);
409 let body = format!("{summary_text}{summary_marker}");
410 let summary = ReviewEnvelopeSummary {
411 body: body.clone(),
412 fingerprint: summary_fp,
413 };
414
415 let truncation = ReviewEnvelopeTruncation {
416 body: comments.iter().any(review_comment_truncated),
417 comment_limit: grouped.truncated,
418 };
419
420 ReviewEnvelopeRenderResult {
421 envelope: build_review_envelope_output(
422 input.provider,
423 body,
424 summary,
425 comments,
426 input.issues,
427 ),
428 truncation,
429 }
430}
431
432fn review_summary_text(
433 command: &str,
434 provider: CiProvider,
435 comment_count: usize,
436 issues: &[CiIssue],
437) -> String {
438 let verdict = review_summary_verdict(issues);
439 format!(
440 "### Fallow {}\n\n**{}**\n\n{} inline finding{} selected for {} review.\n\n<!-- fallow-review -->",
441 command_title(command),
442 verdict,
443 comment_count,
444 if comment_count == 1 { "" } else { "s" },
445 provider.name(),
446 )
447}
448
449fn review_summary_verdict(issues: &[CiIssue]) -> &'static str {
450 match github_check_conclusion(issues) {
451 ReviewCheckConclusion::Failure => "Quality gate failed",
452 ReviewCheckConclusion::Neutral => "Review needed",
453 ReviewCheckConclusion::Success => "Quality gate passed",
454 }
455}
456
457#[derive(Debug, PartialEq, Eq)]
458pub struct GroupedReviewIssues<'a> {
459 pub groups: Vec<Vec<&'a CiIssue>>,
460 pub truncated: bool,
461}
462
463#[must_use]
466pub fn group_review_issues_by_path_line(
467 issues: &[CiIssue],
468 max_groups: usize,
469) -> GroupedReviewIssues<'_> {
470 if max_groups == 0 {
471 return GroupedReviewIssues {
472 groups: Vec::new(),
473 truncated: !issues.is_empty(),
474 };
475 }
476 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
477 let mut current: Vec<&CiIssue> = Vec::new();
478 let mut current_key: Option<(&str, u64)> = None;
479 for issue in issues {
480 let key = (issue.path.as_str(), issue.line);
481 if Some(key) != current_key {
482 if !current.is_empty() {
483 groups.push(std::mem::take(&mut current));
484 if groups.len() == max_groups {
485 return GroupedReviewIssues {
486 groups,
487 truncated: true,
488 };
489 }
490 }
491 current_key = Some(key);
492 }
493 current.push(issue);
494 }
495 if !current.is_empty() && groups.len() < max_groups {
496 groups.push(current);
497 }
498 GroupedReviewIssues {
499 groups,
500 truncated: false,
501 }
502}
503
504fn review_comment_truncated(comment: &ReviewComment) -> bool {
505 match comment {
506 ReviewComment::GitHub(comment) => comment.truncated,
507 ReviewComment::GitLab(comment) => comment.truncated,
508 }
509}
510
511pub struct ReviewCommentRenderInput<'a, 'group> {
512 pub provider: CiProvider,
513 pub group: &'a [&'group CiIssue],
514 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
515 pub diff_index: Option<&'a DiffIndex>,
516 pub path_prefix: &'a str,
518 pub include_guidance: bool,
519 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
520 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
521}
522
523#[must_use]
525pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
526 render_review_comment_for_group_with_id(input, None)
527}
528
529fn render_review_comment_for_group_with_id(
530 input: &ReviewCommentRenderInput<'_, '_>,
531 review_id: Option<&ReviewId>,
532) -> ReviewComment {
533 assert!(
534 !input.group.is_empty(),
535 "group_review_issues_by_path_line never yields empty"
536 );
537 let representative = input.group[0];
538 let fingerprint = if input.group.len() == 1 {
539 representative.fingerprint.clone()
540 } else {
541 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
542 composite_fingerprint(&constituents)
543 };
544
545 let content = build_merged_comment_content(input);
546 let marker_line = review_markers(&fingerprint, review_id);
547 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
548
549 build_review_comment(ReviewCommentInput {
550 provider: input.provider,
551 representative,
552 gitlab_diff_refs: input.gitlab_diff_refs,
553 diff_index: input.diff_index,
554 path_prefix: input.path_prefix,
555 body,
556 fingerprint,
557 truncated,
558 })
559}
560
561#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
562fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
563 let mut content = String::new();
564 for (index, issue) in input.group.iter().enumerate() {
565 let label = review_label_from_codeclimate(&issue.severity);
566 if index > 0 {
567 content.push_str("\n\n");
568 }
569 write!(
570 content,
571 "**{}** `{}`: {}",
572 label,
573 escape_md(&issue.rule_id),
574 escape_md(&issue.description)
575 )
576 .expect("write to String is infallible");
577 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
578 content.push_str(&suggestion);
579 }
580 if input.include_guidance
581 && let Some(guidance) = (input.guidance_block)(issue)
582 {
583 content.push_str(&guidance);
584 }
585 }
586 content
587}
588
589struct ReviewCommentInput<'a> {
590 provider: CiProvider,
591 representative: &'a CiIssue,
592 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
593 diff_index: Option<&'a DiffIndex>,
594 path_prefix: &'a str,
595 body: String,
596 fingerprint: String,
597 truncated: bool,
598}
599
600fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
601 let ReviewCommentInput {
602 provider,
603 representative,
604 gitlab_diff_refs,
605 diff_index,
606 path_prefix,
607 body,
608 fingerprint,
609 truncated,
610 } = input;
611 match provider {
612 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
613 path: apply_path_prefix(path_prefix, &representative.path),
614 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
615 side: GitHubReviewSide::Right,
616 body,
617 fingerprint,
618 truncated,
619 }),
620 CiProvider::Gitlab => {
621 let old_rel = diff_index
624 .and_then(|di| di.old_path_for_root_relative(&representative.path))
625 .map_or_else(|| representative.path.clone(), Cow::into_owned);
626 let new_path = apply_path_prefix(path_prefix, &representative.path);
627 let old_path = apply_path_prefix(path_prefix, &old_rel);
628 let position = GitLabReviewPosition {
629 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
630 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
631 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
632 position_type: GitLabReviewPositionType::Text,
633 old_path,
634 new_path,
635 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
636 };
637 ReviewComment::GitLab(GitLabReviewComment {
638 body,
639 position,
640 fingerprint,
641 truncated,
642 })
643 }
644 }
645}
646
647#[must_use]
648pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
649 let intact_len = content.len() + marker_line.len();
650 if intact_len <= MAX_COMMENT_BODY_BYTES {
651 let mut out = String::with_capacity(intact_len);
652 out.push_str(content);
653 out.push_str(marker_line);
654 return (out, false);
655 }
656 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
657 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
658 let mut cut = budget.min(content.len());
659 while cut > 0 && !content.is_char_boundary(cut) {
660 cut -= 1;
661 }
662 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
663 out.push_str(&content[..cut]);
664 out.push_str(TRUNCATION_SUFFIX);
665 out.push_str(marker_line);
666 (out, true)
667}
668
669#[must_use]
670pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
671 match severity_name.as_bytes() {
672 b"major" | b"critical" | b"blocker" => "error",
673 _ => "warn",
674 }
675}
676
677#[must_use]
678pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
679 if issues
680 .iter()
681 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
682 {
683 ReviewCheckConclusion::Failure
684 } else if issues.is_empty() {
685 ReviewCheckConclusion::Success
686 } else {
687 ReviewCheckConclusion::Neutral
688 }
689}
690
691fn build_review_envelope_output(
692 provider: CiProvider,
693 body: String,
694 summary: ReviewEnvelopeSummary,
695 comments: Vec<ReviewComment>,
696 issues: &[CiIssue],
697) -> ReviewEnvelopeOutput {
698 match provider {
699 CiProvider::Github => ReviewEnvelopeOutput {
700 event: Some(ReviewEnvelopeEvent::Comment),
701 body,
702 summary,
703 comments,
704 marker_regex: default_marker_regex(),
705 marker_regex_flags: default_marker_regex_flags(),
706 meta: ReviewEnvelopeMeta {
707 schema: ReviewEnvelopeSchema::V2,
708 provider: ReviewProvider::Github,
709 check_conclusion: Some(github_check_conclusion(issues)),
710 },
711 },
712 CiProvider::Gitlab => ReviewEnvelopeOutput {
713 event: None,
714 body,
715 summary,
716 comments,
717 marker_regex: default_marker_regex(),
718 marker_regex_flags: default_marker_regex_flags(),
719 meta: ReviewEnvelopeMeta {
720 schema: ReviewEnvelopeSchema::V2,
721 provider: ReviewProvider::Gitlab,
722 check_conclusion: None,
723 },
724 },
725 }
726}
727
728fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
729 let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
730 match review_id {
731 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
732 None => fingerprint,
733 }
734}
735
736#[must_use]
737pub fn summary_fingerprint(body: &str) -> String {
738 fingerprint_hash(&[body])
739}
740
741#[must_use]
742pub fn composite_fingerprint(constituents: &[&str]) -> String {
743 let mut sorted: Vec<&str> = constituents.to_vec();
744 sorted.sort_unstable();
745 let joined = sorted.join(":");
746 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
753
754 fn category_for_rule(rule_id: &str) -> &'static str {
755 match rule_id {
756 "fallow/code-duplication" => "Duplication",
757 "fallow/high-complexity" => "Health",
758 "fallow/unused-dependency" => "Dependencies",
759 _ => "Dead code",
760 }
761 }
762
763 #[test]
764 fn extracts_issues_from_codeclimate() {
765 let value = serde_json::json!([{
766 "check_name": "fallow/unused-export",
767 "description": "Export x is never imported",
768 "severity": "minor",
769 "fingerprint": "abc",
770 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
771 }]);
772 let issues = issues_from_codeclimate(&value);
773 assert_eq!(issues.len(), 1);
774 assert_eq!(issues[0].path, "src/a.ts");
775 assert_eq!(issues[0].line, 7);
776 }
777
778 #[test]
779 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
780 let severities = [
781 (CodeClimateSeverity::Info, "info"),
782 (CodeClimateSeverity::Minor, "minor"),
783 (CodeClimateSeverity::Major, "major"),
784 (CodeClimateSeverity::Critical, "critical"),
785 (CodeClimateSeverity::Blocker, "blocker"),
786 ];
787 let typed = severities
788 .iter()
789 .enumerate()
790 .map(|(index, (severity, _))| CodeClimateIssue {
791 kind: CodeClimateIssueKind::Issue,
792 check_name: format!("fallow/rule-{index}"),
793 description: format!("Finding {index}"),
794 categories: vec!["Complexity".to_owned()],
795 severity: *severity,
796 fingerprint: format!("fp-{index}"),
797 location: CodeClimateLocation {
798 path: format!("src/{index}.ts"),
799 lines: CodeClimateLines {
800 begin: u32::try_from(index + 1).expect("small fixture index"),
801 },
802 },
803 owner: None,
804 group: None,
805 })
806 .collect::<Vec<_>>();
807 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
808
809 assert_eq!(
810 issues_from_codeclimate_issues(&typed),
811 issues_from_codeclimate(&value)
812 );
813 let typed_labels = issues_from_codeclimate_issues(&typed)
814 .into_iter()
815 .map(|issue| issue.severity)
816 .collect::<Vec<_>>();
817 let expected_labels = severities
818 .iter()
819 .map(|(_, label)| (*label).to_owned())
820 .collect::<Vec<_>>();
821 assert_eq!(typed_labels, expected_labels);
822 }
823
824 #[test]
825 fn renders_default_empty_comment() {
826 let body = render_pr_comment(&PrCommentRenderInput {
827 command: "check",
828 provider: CiProvider::Github,
829 issues: &[],
830 marker_id: "fallow-results".to_owned(),
831 max_comments: 50,
832 category_for_rule: &category_for_rule,
833 });
834 assert!(body.contains("<!-- fallow-id: fallow-results"));
835 assert!(body.contains("No GitHub PR/MR findings."));
836 }
837
838 #[test]
839 fn escape_md_escapes_inline_commonmark_specials() {
840 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
841 let escaped = escape_md(raw);
842 for ch in [
843 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
844 ] {
845 let raw_count = raw.chars().filter(|c| c == &ch).count();
846 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
847 assert_eq!(
848 raw_count, escaped_count,
849 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
850 );
851 }
852 }
853
854 #[test]
855 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
856 let raw = "value *suspicious* here";
857 let escaped = escape_md(raw);
858 assert!(escaped.contains(r"\&"), "got: {escaped}");
859 assert!(escaped.contains(r"\#"), "got: {escaped}");
860 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
861 }
862
863 #[test]
864 fn summary_label_foreshadows_truncation() {
865 assert_eq!(
866 summary_label("Duplication", 160, 50),
867 "Duplication (160, showing 50)"
868 );
869 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
870 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
871 }
872
873 #[test]
874 fn escape_md_does_not_escape_block_only_markers() {
875 let raw = "fallow/test-only-dependency package.json:12";
876 let escaped = escape_md(raw);
877 assert!(!escaped.contains("\\-"), "should not escape `-`");
878 assert!(!escaped.contains("\\."), "should not escape `.`");
879 assert_eq!(escaped, raw);
880 }
881
882 #[test]
883 fn escape_md_collapses_newlines_to_spaces() {
884 let raw = "first\nsecond\nthird";
885 assert_eq!(escape_md(raw), "first second third");
886 }
887
888 #[test]
889 fn escape_md_leaves_safe_chars_unchanged() {
890 let raw = "Export 'helperFn' is never imported by other modules";
891 assert_eq!(
892 escape_md(raw),
893 r"Export 'helperFn' is never imported by other modules"
894 );
895 }
896
897 #[test]
898 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
899 for rule_id in PROJECT_LEVEL_RULE_IDS {
900 assert!(
901 is_project_level_rule(rule_id),
902 "{rule_id} must be project-level"
903 );
904 }
905 for rule_id in [
906 "fallow/unused-file",
907 "fallow/unused-export",
908 "fallow/unused-type",
909 "fallow/unused-enum-member",
910 "fallow/unused-class-member",
911 "fallow/unused-store-member",
912 "fallow/unresolved-import",
913 "fallow/unlisted-dependency",
914 "fallow/duplicate-export",
915 "fallow/circular-dependency",
916 "fallow/re-export-cycle",
917 "fallow/boundary-violation",
918 "fallow/stale-suppression",
919 "fallow/private-type-leak",
920 "fallow/high-complexity",
921 "fallow/high-crap-score",
922 ] {
923 assert!(
924 !is_project_level_rule(rule_id),
925 "{rule_id} must NOT be project-level"
926 );
927 }
928 }
929
930 #[test]
931 fn escape_md_double_apply_is_safe() {
932 let raw = "code with `backticks` and *stars*";
933 let once = escape_md(raw);
934 let twice = escape_md(&once);
935 assert!(twice.contains(r"\\"));
936 }
937}