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