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 pub legacy_fingerprint: Option<String>,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
77pub struct CiLocation {
78 pub path: String,
80 pub line: u64,
82 pub end_line: u64,
84}
85
86pub struct PrCommentRenderInput<'a> {
88 pub command: &'a str,
90 pub provider: CiProvider,
92 pub issues: &'a [CiIssue],
94 pub marker_id: String,
96 pub max_comments: usize,
98 pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
100 pub trailing_section: Option<&'a str>,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct ReviewGitlabDiffRefs {
108 pub base_sha: String,
110 pub start_sha: String,
112 pub head_sha: String,
114}
115
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
118pub struct ReviewEnvelopeTruncation {
119 pub body: bool,
121 pub comment_limit: bool,
123}
124
125#[derive(Debug)]
127pub struct ReviewEnvelopeRenderResult {
128 pub envelope: ReviewEnvelopeOutput,
130 pub truncation: ReviewEnvelopeTruncation,
132}
133
134pub struct ReviewEnvelopeRenderInput<'a> {
136 pub command: &'a str,
138 pub provider: CiProvider,
140 pub issues: &'a [CiIssue],
142 pub diff_index: Option<&'a DiffIndex>,
144 pub path_prefix: &'a str,
146 pub max_comments: usize,
148 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
150 pub include_guidance: bool,
152 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
155 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
157 pub trailing_section: Option<&'a str>,
161}
162
163pub const MARKER_PREFIX_V3: &str = "<!-- fallow-fingerprint:v3: ";
167
168pub const MARKER_SUFFIX_V3: &str = " -->";
170
171pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
176
177pub const MARKER_SUFFIX_V2: &str = " -->";
179
180pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
183const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
184
185#[must_use]
188pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
189 let mut issues = value
190 .as_array()
191 .into_iter()
192 .flatten()
193 .filter_map(issue_from_codeclimate)
194 .collect::<Vec<_>>();
195 sort_ci_issues(&mut issues);
196 issues
197}
198
199#[must_use]
202pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
203 let mut issues = issues
204 .iter()
205 .map(issue_from_codeclimate_issue)
206 .collect::<Vec<_>>();
207 sort_ci_issues(&mut issues);
208 issues
209}
210
211fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
212 let path = value.pointer("/location/path")?.as_str()?.to_string();
213 let line = value
214 .pointer("/location/lines/begin")
215 .and_then(Value::as_u64)
216 .unwrap_or(1);
217 let end_line = value.pointer("/location/lines/end").and_then(Value::as_u64);
218 let mut other_locations = value
219 .get("other_locations")
220 .and_then(Value::as_array)
221 .into_iter()
222 .flatten()
223 .filter_map(|location| {
224 let line = location.pointer("/lines/begin")?.as_u64()?;
225 let end_line = location
226 .pointer("/lines/end")
227 .and_then(Value::as_u64)
228 .filter(|end| *end >= line)
229 .unwrap_or(line);
230 Some(CiLocation {
231 path: location.get("path")?.as_str()?.to_owned(),
232 line,
233 end_line,
234 })
235 })
236 .collect::<Vec<_>>();
237 other_locations.sort();
238 Some(CiIssue {
239 rule_id: value
240 .get("check_name")
241 .and_then(Value::as_str)
242 .unwrap_or("fallow/finding")
243 .to_string(),
244 description: value
245 .get("description")
246 .and_then(Value::as_str)
247 .unwrap_or("Fallow finding")
248 .to_string(),
249 severity: value
250 .get("severity")
251 .and_then(Value::as_str)
252 .unwrap_or("minor")
253 .to_string(),
254 fingerprint: value
255 .get("fingerprint")
256 .and_then(Value::as_str)
257 .unwrap_or("")
258 .to_string(),
259 legacy_fingerprint: None,
260 path,
261 line,
262 end_line,
263 other_locations,
264 })
265}
266
267fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
268 let mut other_locations = issue
269 .other_locations
270 .iter()
271 .map(|location| CiLocation {
272 path: location.path.clone(),
273 line: u64::from(location.lines.begin),
274 end_line: u64::from(
275 location
276 .lines
277 .end
278 .filter(|end| *end >= location.lines.begin)
279 .unwrap_or(location.lines.begin),
280 ),
281 })
282 .collect::<Vec<_>>();
283 other_locations.sort();
284 CiIssue {
285 rule_id: issue.check_name.clone(),
286 description: issue.description.clone(),
287 severity: codeclimate_severity_label(issue.severity).to_owned(),
288 path: issue.location.path.clone(),
289 line: u64::from(issue.location.lines.begin),
290 end_line: issue.location.lines.end.map(u64::from),
291 other_locations,
292 fingerprint: issue.fingerprint.clone(),
293 legacy_fingerprint: issue.legacy_fingerprint.clone(),
294 }
295}
296
297const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
298 match severity {
299 CodeClimateSeverity::Info => "info",
300 CodeClimateSeverity::Minor => "minor",
301 CodeClimateSeverity::Major => "major",
302 CodeClimateSeverity::Critical => "critical",
303 CodeClimateSeverity::Blocker => "blocker",
304 }
305}
306
307fn sort_ci_issues(issues: &mut [CiIssue]) {
308 issues
309 .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
310}
311
312fn fingerprint_hash(parts: &[&str]) -> String {
313 crate::codeclimate_fingerprint_hash(parts)
314}
315
316#[must_use]
323pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
324 render_pr_comment_with_verdict(input, None)
325}
326
327#[must_use]
338#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
339pub fn render_pr_comment_with_verdict(
340 input: &PrCommentRenderInput<'_>,
341 gate: Option<ReviewCheckConclusion>,
342) -> String {
343 let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
344 let title = command_title(input.command);
345 let count = input.issues.len();
346 let noun = if count == 1 { "finding" } else { "findings" };
347 let verdict = most_severe_conclusion(github_check_conclusion(input.issues), gate);
348
349 let mut out = String::new();
350 out.push_str(&marker);
351 out.push('\n');
352 write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
353 write!(&mut out, "**{}**\n\n", pr_comment_verdict(verdict)).expect("write to string");
354 if count == 0 {
355 writeln!(
356 &mut out,
357 "No findings for this {}.",
358 change_noun(input.provider)
359 )
360 .expect("write to string");
361 } else {
362 let groups = group_by_category(input.issues, input.category_for_rule);
363 if groups.len() > 1 {
364 write!(
365 &mut out,
366 "Found **{count}** {noun}: {}.\n\n",
367 category_breakdown(&groups)
368 )
369 .expect("write to string");
370 } else {
371 write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
372 }
373 for (category, group_issues) in &groups {
374 let summary_label = summary_label(category, group_issues.len(), input.max_comments);
375 render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
376 }
377 }
378 if let Some(section) = input
379 .trailing_section
380 .map(str::trim)
381 .filter(|section| !section.is_empty())
382 {
383 if !out.ends_with('\n') {
384 out.push('\n');
385 }
386 out.push('\n');
387 out.push_str(section);
388 out.push('\n');
389 }
390 out.push_str("\nGenerated by fallow.");
391 out
392}
393
394const fn pr_comment_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
396 match conclusion {
397 ReviewCheckConclusion::Failure => "Quality gate failed",
398 ReviewCheckConclusion::Neutral => "Review needed",
399 ReviewCheckConclusion::Success => "Quality gate passed",
400 }
401}
402
403const fn conclusion_rank(conclusion: ReviewCheckConclusion) -> u8 {
404 match conclusion {
405 ReviewCheckConclusion::Success => 0,
406 ReviewCheckConclusion::Neutral => 1,
407 ReviewCheckConclusion::Failure => 2,
408 }
409}
410
411const fn most_severe_conclusion(
412 derived: ReviewCheckConclusion,
413 gate: Option<ReviewCheckConclusion>,
414) -> ReviewCheckConclusion {
415 match gate {
416 Some(gate) if conclusion_rank(gate) > conclusion_rank(derived) => gate,
417 _ => derived,
418 }
419}
420
421pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
424 "fallow/unused-catalog-entry",
425 "fallow/empty-catalog-group",
426 "fallow/unresolved-catalog-reference",
427 "fallow/unused-dependency-override",
428 "fallow/misconfigured-dependency-override",
429 "fallow/unused-dependency",
430 "fallow/unused-dev-dependency",
431 "fallow/unused-optional-dependency",
432 "fallow/type-only-dependency",
433 "fallow/test-only-dependency",
434 "fallow/dev-dependency-in-production",
435];
436
437#[must_use]
440pub fn is_project_level_rule(rule_id: &str) -> bool {
441 PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
442}
443
444const CATEGORY_ORDER: [&str; 10] = [
449 "Dead code",
450 "Dependencies",
451 "Duplication",
452 "Health",
453 "Architecture",
454 "Policy",
455 "Security",
456 "Flags",
457 "Suppressions",
458 "Other",
459];
460
461fn group_by_category<'a>(
462 issues: &'a [CiIssue],
463 category_for_rule: &dyn Fn(&str) -> &'static str,
464) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
465 let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
466 std::collections::BTreeMap::new();
467 for issue in issues {
468 let category = category_for_rule(&issue.rule_id);
469 buckets.entry(category).or_default().push(issue);
470 }
471 let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
472 for category in CATEGORY_ORDER {
473 if let Some(items) = buckets.remove(category) {
474 ordered.push((category, items));
475 }
476 }
477 for (category, items) in buckets {
478 ordered.push((category, items));
479 }
480 ordered
481}
482
483fn category_breakdown(groups: &[(&'static str, Vec<&CiIssue>)]) -> String {
487 groups
488 .iter()
489 .map(|(category, issues)| format!("{category} {}", issues.len()))
490 .collect::<Vec<_>>()
491 .join(", ")
492}
493
494#[must_use]
497pub fn summary_label(category: &str, total: usize, max: usize) -> String {
498 if total > max {
499 format!("{category} ({total}, showing {max})")
500 } else {
501 format!("{category} ({total})")
502 }
503}
504
505#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
506fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
507 writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
508 out.push_str("| Severity | Rule | Location | Description |\n");
509 out.push_str("| --- | --- | --- | --- |\n");
510 for issue in issues.iter().take(max) {
511 writeln!(
512 out,
513 "| {} | `{}` | `{}`:{} | {} |",
514 escape_md(&issue.severity),
515 escape_md(&issue.rule_id),
516 escape_md(&issue.path),
517 issue.line,
518 escape_md(&issue.description),
519 )
520 .expect("write to string");
521 }
522 if issues.len() > max {
523 writeln!(
524 out,
525 "\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
526 issues.len(),
527 )
528 .expect("write to string");
529 }
530 out.push_str("\n</details>\n\n");
531}
532
533#[must_use]
536pub fn command_title(command: &str) -> &'static str {
537 match command {
538 "dead-code" | "check" => "codebase report",
539 "dupes" => "duplication report",
540 "health" => "health report",
541 "audit" => "audit report",
542 "security" => "security report",
543 "fix" => "fix report",
544 "" | "combined" => "combined report",
545 _ => "report",
546 }
547}
548
549const fn change_noun(provider: CiProvider) -> &'static str {
551 match provider {
552 CiProvider::Github => "pull request",
553 CiProvider::Gitlab => "merge request",
554 }
555}
556
557const fn changes_tab(provider: CiProvider) -> &'static str {
559 match provider {
560 CiProvider::Github => "Files changed",
561 CiProvider::Gitlab => "Changes",
562 }
563}
564
565#[must_use]
567pub fn escape_md(value: &str) -> String {
568 let value = value.trim();
569 let mut chars = value.chars().peekable();
572 let mut out = String::with_capacity(value.len());
573 while let Some(ch) = chars.next() {
574 let ch = match ch {
575 '\r' => {
576 if chars.peek() == Some(&'\n') {
577 chars.next();
578 }
579 ' '
580 }
581 '\n' => ' ',
582 _ => ch,
583 };
584 if matches!(
585 ch,
586 '\\' | '`'
587 | '*'
588 | '_'
589 | '['
590 | ']'
591 | '('
592 | ')'
593 | '!'
594 | '<'
595 | '>'
596 | '#'
597 | '|'
598 | '~'
599 | '&'
600 ) {
601 out.push('\\');
602 }
603 out.push(ch);
604 }
605 out
606}
607
608#[must_use]
612pub fn markdown_code_span(value: &str) -> String {
613 let longest_run = value
614 .split(|c| c != '`')
615 .map(str::len)
616 .max()
617 .unwrap_or_default();
618 let fence = "`".repeat(longest_run + 1);
619 let needs_padding = value.starts_with('`')
620 || value.ends_with('`')
621 || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
622 if needs_padding {
623 format!("{fence} {value} {fence}")
624 } else {
625 format!("{fence}{value}{fence}")
626 }
627}
628
629#[must_use]
633pub fn markdown_table_code_span(value: &str) -> String {
634 let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
635 markdown_code_span(&collapsed.replace('|', "\\|"))
636}
637
638#[must_use]
641pub fn markdown_table_text(value: &str) -> String {
642 value
643 .replace("\r\n", " ")
644 .replace(['\n', '\r'], " ")
645 .replace('|', "\\|")
646}
647
648#[must_use]
650pub fn render_review_envelope(
651 input: &ReviewEnvelopeRenderInput<'_>,
652 status_message: Option<&str>,
653) -> ReviewEnvelopeRenderResult {
654 render_review_envelope_with_id(input, None, None, status_message)
655}
656
657#[must_use]
659pub fn render_review_envelope_with_conclusion(
660 input: &ReviewEnvelopeRenderInput<'_>,
661 conclusion: ReviewCheckConclusion,
662 status_message: Option<&str>,
663) -> ReviewEnvelopeRenderResult {
664 render_review_envelope_with_id(input, None, Some(conclusion), status_message)
665}
666
667#[must_use]
669pub fn render_scoped_review_envelope(
670 input: &ReviewEnvelopeRenderInput<'_>,
671 review_id: &ReviewId,
672 status_message: Option<&str>,
673) -> ReviewEnvelopeRenderResult {
674 render_review_envelope_with_id(input, Some(review_id), None, status_message)
675}
676
677#[must_use]
679pub fn render_scoped_review_envelope_with_conclusion(
680 input: &ReviewEnvelopeRenderInput<'_>,
681 review_id: &ReviewId,
682 conclusion: ReviewCheckConclusion,
683 status_message: Option<&str>,
684) -> ReviewEnvelopeRenderResult {
685 render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
686}
687
688fn render_review_envelope_with_id(
689 input: &ReviewEnvelopeRenderInput<'_>,
690 review_id: Option<&ReviewId>,
691 conclusion: Option<ReviewCheckConclusion>,
692 status_message: Option<&str>,
693) -> ReviewEnvelopeRenderResult {
694 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
695
696 let comments: Vec<ReviewComment> = grouped
697 .groups
698 .iter()
699 .map(|group| {
700 render_review_comment_for_group_with_id(
701 &ReviewCommentRenderInput {
702 provider: input.provider,
703 group,
704 gitlab_diff_refs: input.gitlab_diff_refs,
705 diff_index: input.diff_index,
706 path_prefix: input.path_prefix,
707 include_guidance: input.include_guidance,
708 suggestion_block: input.suggestion_block,
709 guidance_block: input.guidance_block,
710 },
711 review_id,
712 )
713 })
714 .collect();
715
716 let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
717 let summary_text = review_summary_text(
718 input.command,
719 input.provider,
720 comments.len(),
721 status_message,
722 input.trailing_section,
723 );
724 let summary_fp = summary_fingerprint(&summary_text);
725 let summary_marker = review_markers(&summary_fp, review_id);
726 let body = format!("{summary_text}{summary_marker}");
727 let summary = ReviewEnvelopeSummary {
728 body: body.clone(),
729 fingerprint: summary_fp,
730 };
731
732 let truncation = ReviewEnvelopeTruncation {
733 body: comments.iter().any(review_comment_truncated),
734 comment_limit: grouped.truncated,
735 };
736
737 ReviewEnvelopeRenderResult {
738 envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
739 truncation,
740 }
741}
742
743fn review_summary_text(
750 command: &str,
751 provider: CiProvider,
752 comment_count: usize,
753 status_message: Option<&str>,
754 trailing_section: Option<&str>,
755) -> String {
756 let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
757 let section = trailing_section
758 .map(str::trim)
759 .filter(|section| !section.is_empty())
760 .map_or_else(String::new, |section| format!("\n\n{section}"));
761 format!(
762 "### Fallow {}{}\n\n{}{}\n\n<!-- fallow-review -->",
763 command_title(command),
764 status,
765 inline_comment_line(provider, comment_count),
766 section,
767 )
768}
769
770fn inline_comment_line(provider: CiProvider, count: usize) -> String {
771 if count == 0 {
772 return format!(
773 "No findings anchored to the changed lines in this {}.",
774 change_noun(provider)
775 );
776 }
777 format!(
778 "{count} inline comment{} on the changed lines. Open the {} tab to review.",
779 if count == 1 { "" } else { "s" },
780 changes_tab(provider),
781 )
782}
783
784#[derive(Debug, PartialEq, Eq)]
787pub struct GroupedReviewIssues<'a> {
788 pub groups: Vec<Vec<&'a CiIssue>>,
790 pub truncated: bool,
792}
793
794#[must_use]
797pub fn group_review_issues_by_path_line(
798 issues: &[CiIssue],
799 max_groups: usize,
800) -> GroupedReviewIssues<'_> {
801 if max_groups == 0 {
802 return GroupedReviewIssues {
803 groups: Vec::new(),
804 truncated: !issues.is_empty(),
805 };
806 }
807 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
808 let mut current: Vec<&CiIssue> = Vec::new();
809 let mut current_key: Option<(&str, u64)> = None;
810 for issue in issues {
811 let key = (issue.path.as_str(), issue.line);
812 if Some(key) != current_key {
813 if !current.is_empty() {
814 groups.push(std::mem::take(&mut current));
815 if groups.len() == max_groups {
816 return GroupedReviewIssues {
817 groups,
818 truncated: true,
819 };
820 }
821 }
822 current_key = Some(key);
823 }
824 current.push(issue);
825 }
826 if !current.is_empty() && groups.len() < max_groups {
827 groups.push(current);
828 }
829 GroupedReviewIssues {
830 groups,
831 truncated: false,
832 }
833}
834
835fn review_comment_truncated(comment: &ReviewComment) -> bool {
836 match comment {
837 ReviewComment::GitHub(comment) => comment.truncated,
838 ReviewComment::GitLab(comment) => comment.truncated,
839 }
840}
841
842pub struct ReviewCommentRenderInput<'a, 'group> {
844 pub provider: CiProvider,
846 pub group: &'a [&'group CiIssue],
848 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
850 pub diff_index: Option<&'a DiffIndex>,
852 pub path_prefix: &'a str,
854 pub include_guidance: bool,
856 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
859 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
861}
862
863#[must_use]
865pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
866 render_review_comment_for_group_with_id(input, None)
867}
868
869fn render_review_comment_for_group_with_id(
870 input: &ReviewCommentRenderInput<'_, '_>,
871 review_id: Option<&ReviewId>,
872) -> ReviewComment {
873 assert!(
874 !input.group.is_empty(),
875 "group_review_issues_by_path_line never yields empty"
876 );
877 let representative = input.group[0];
878 let fingerprint = if input.group.len() == 1 {
879 representative.fingerprint.clone()
880 } else {
881 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
882 composite_fingerprint(&constituents)
883 };
884 let legacy_fingerprint = group_legacy_fingerprint(input.group);
885
886 let content = build_merged_comment_content(input);
887 let marker_line = review_markers(&fingerprint, review_id);
888 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
889
890 build_review_comment(ReviewCommentInput {
891 provider: input.provider,
892 representative,
893 gitlab_diff_refs: input.gitlab_diff_refs,
894 diff_index: input.diff_index,
895 path_prefix: input.path_prefix,
896 body,
897 fingerprint,
898 legacy_fingerprint,
899 truncated,
900 })
901}
902
903fn group_legacy_fingerprint(group: &[&CiIssue]) -> Option<String> {
910 if group.iter().all(|issue| issue.legacy_fingerprint.is_none()) {
911 return None;
912 }
913 if let [issue] = group {
914 return issue.legacy_fingerprint.clone();
915 }
916 let constituents: Vec<&str> = group
917 .iter()
918 .map(|issue| {
919 issue
920 .legacy_fingerprint
921 .as_deref()
922 .unwrap_or(issue.fingerprint.as_str())
923 })
924 .collect();
925 Some(composite_fingerprint(&constituents))
926}
927
928#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
929fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
930 let mut content = String::new();
931 for (index, issue) in input.group.iter().enumerate() {
932 let label = review_label_from_codeclimate(&issue.severity);
933 if index > 0 {
934 content.push_str("\n\n");
935 }
936 write!(
937 content,
938 "**{}** `{}`: {}",
939 label,
940 escape_md(&issue.rule_id),
941 escape_md(&issue.description)
942 )
943 .expect("write to String is infallible");
944 if !issue.other_locations.is_empty() {
945 content.push_str("\n\nOther locations: ");
946 let locations = issue
947 .other_locations
948 .iter()
949 .map(|location| {
950 markdown_code_span(&format!(
951 "{}:{}-{}",
952 apply_path_prefix(input.path_prefix, &location.path),
953 location.line,
954 location.end_line
955 ))
956 })
957 .collect::<Vec<_>>()
958 .join(", ");
959 content.push_str(&locations);
960 }
961 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
962 content.push_str(&suggestion);
963 }
964 if input.include_guidance
965 && let Some(guidance) = (input.guidance_block)(issue)
966 {
967 content.push_str(&guidance);
968 }
969 }
970 content
971}
972
973struct ReviewCommentInput<'a> {
974 provider: CiProvider,
975 representative: &'a CiIssue,
976 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
977 diff_index: Option<&'a DiffIndex>,
978 path_prefix: &'a str,
979 body: String,
980 fingerprint: String,
981 legacy_fingerprint: Option<String>,
982 truncated: bool,
983}
984
985fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
986 let ReviewCommentInput {
987 provider,
988 representative,
989 gitlab_diff_refs,
990 diff_index,
991 path_prefix,
992 body,
993 fingerprint,
994 legacy_fingerprint,
995 truncated,
996 } = input;
997 match provider {
998 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
999 path: apply_path_prefix(path_prefix, &representative.path),
1000 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
1001 side: GitHubReviewSide::Right,
1002 body,
1003 fingerprint,
1004 legacy_fingerprint,
1005 truncated,
1006 }),
1007 CiProvider::Gitlab => {
1008 let old_rel = diff_index
1011 .and_then(|di| di.old_path_for_root_relative(&representative.path))
1012 .map_or_else(|| representative.path.clone(), Cow::into_owned);
1013 let new_path = apply_path_prefix(path_prefix, &representative.path);
1014 let old_path = apply_path_prefix(path_prefix, &old_rel);
1015 let position = GitLabReviewPosition {
1016 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
1017 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
1018 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
1019 position_type: GitLabReviewPositionType::Text,
1020 old_path,
1021 new_path,
1022 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
1023 };
1024 ReviewComment::GitLab(GitLabReviewComment {
1025 body,
1026 position,
1027 fingerprint,
1028 legacy_fingerprint,
1029 truncated,
1030 })
1031 }
1032 }
1033}
1034
1035#[must_use]
1039pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
1040 let intact_len = content.len() + marker_line.len();
1041 if intact_len <= MAX_COMMENT_BODY_BYTES {
1042 let mut out = String::with_capacity(intact_len);
1043 out.push_str(content);
1044 out.push_str(marker_line);
1045 return (out, false);
1046 }
1047 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
1048 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
1049 let mut cut = budget.min(content.len());
1050 while cut > 0 && !content.is_char_boundary(cut) {
1051 cut -= 1;
1052 }
1053 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
1054 out.push_str(&content[..cut]);
1055 out.push_str(TRUNCATION_SUFFIX);
1056 out.push_str(marker_line);
1057 (out, true)
1058}
1059
1060#[must_use]
1063pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
1064 match severity_name.as_bytes() {
1065 b"major" | b"critical" | b"blocker" => "error",
1066 _ => "warn",
1067 }
1068}
1069
1070#[must_use]
1073pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1074 if issues
1075 .iter()
1076 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1077 {
1078 ReviewCheckConclusion::Failure
1079 } else if issues.is_empty() {
1080 ReviewCheckConclusion::Success
1081 } else {
1082 ReviewCheckConclusion::Neutral
1083 }
1084}
1085
1086fn build_review_envelope_output(
1087 provider: CiProvider,
1088 body: String,
1089 summary: ReviewEnvelopeSummary,
1090 comments: Vec<ReviewComment>,
1091 conclusion: ReviewCheckConclusion,
1092) -> ReviewEnvelopeOutput {
1093 match provider {
1094 CiProvider::Github => ReviewEnvelopeOutput {
1095 event: Some(ReviewEnvelopeEvent::Comment),
1096 body,
1097 summary,
1098 comments,
1099 marker_regex: default_marker_regex(),
1100 marker_regex_flags: default_marker_regex_flags(),
1101 meta: ReviewEnvelopeMeta {
1102 schema: ReviewEnvelopeSchema::V3,
1103 provider: ReviewProvider::Github,
1104 check_conclusion: Some(conclusion),
1105 },
1106 },
1107 CiProvider::Gitlab => ReviewEnvelopeOutput {
1108 event: None,
1109 body,
1110 summary,
1111 comments,
1112 marker_regex: default_marker_regex(),
1113 marker_regex_flags: default_marker_regex_flags(),
1114 meta: ReviewEnvelopeMeta {
1115 schema: ReviewEnvelopeSchema::V3,
1116 provider: ReviewProvider::Gitlab,
1117 check_conclusion: None,
1118 },
1119 },
1120 }
1121}
1122
1123fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1124 let fingerprint = format!("\n\n{MARKER_PREFIX_V3}{fingerprint}{MARKER_SUFFIX_V3}");
1125 match review_id {
1126 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1127 None => fingerprint,
1128 }
1129}
1130
1131#[must_use]
1133pub fn summary_fingerprint(body: &str) -> String {
1134 fingerprint_hash(&[body])
1135}
1136
1137#[must_use]
1141pub fn composite_fingerprint(constituents: &[&str]) -> String {
1142 let mut sorted: Vec<&str> = constituents.to_vec();
1143 sorted.sort_unstable();
1144 let joined = sorted.join(":");
1145 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1152
1153 fn category_for_rule(rule_id: &str) -> &'static str {
1154 match rule_id {
1155 "fallow/code-duplication" => "Duplication",
1156 "fallow/high-complexity" => "Health",
1157 "fallow/unused-dependency" => "Dependencies",
1158 _ => "Dead code",
1159 }
1160 }
1161
1162 #[test]
1163 fn extracts_issues_from_codeclimate() {
1164 let value = serde_json::json!([{
1165 "check_name": "fallow/unused-export",
1166 "description": "Export x is never imported",
1167 "severity": "minor",
1168 "fingerprint": "abc",
1169 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1170 }]);
1171 let issues = issues_from_codeclimate(&value);
1172 assert_eq!(issues.len(), 1);
1173 assert_eq!(issues[0].path, "src/a.ts");
1174 assert_eq!(issues[0].line, 7);
1175 }
1176
1177 #[test]
1178 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1179 let severities = [
1180 (CodeClimateSeverity::Info, "info"),
1181 (CodeClimateSeverity::Minor, "minor"),
1182 (CodeClimateSeverity::Major, "major"),
1183 (CodeClimateSeverity::Critical, "critical"),
1184 (CodeClimateSeverity::Blocker, "blocker"),
1185 ];
1186 let typed = severities
1187 .iter()
1188 .enumerate()
1189 .map(|(index, (severity, _))| CodeClimateIssue {
1190 kind: CodeClimateIssueKind::Issue,
1191 check_name: format!("fallow/rule-{index}"),
1192 description: format!("Finding {index}"),
1193 categories: vec!["Complexity".to_owned()],
1194 severity: *severity,
1195 fingerprint: format!("fp-{index}"),
1196 location: CodeClimateLocation {
1197 path: format!("src/{index}.ts"),
1198 lines: CodeClimateLines {
1199 begin: u32::try_from(index + 1).expect("small fixture index"),
1200 end: Some(u32::try_from(index + 3).expect("small fixture index")),
1201 },
1202 },
1203 other_locations: vec![CodeClimateLocation {
1204 path: format!("src/peer-{index}.ts"),
1205 lines: CodeClimateLines {
1206 begin: 20,
1207 end: Some(24),
1208 },
1209 }],
1210 owner: None,
1211 group: None,
1212 legacy_fingerprint: None,
1213 })
1214 .collect::<Vec<_>>();
1215 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1216
1217 assert_eq!(
1218 issues_from_codeclimate_issues(&typed),
1219 issues_from_codeclimate(&value)
1220 );
1221 let normalized = issues_from_codeclimate_issues(&typed);
1222 assert_eq!(normalized[0].end_line, Some(3));
1223 assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1224 assert_eq!(normalized[0].other_locations[0].line, 20);
1225 assert_eq!(normalized[0].other_locations[0].end_line, 24);
1226 let typed_labels = issues_from_codeclimate_issues(&typed)
1227 .into_iter()
1228 .map(|issue| issue.severity)
1229 .collect::<Vec<_>>();
1230 let expected_labels = severities
1231 .iter()
1232 .map(|(_, label)| (*label).to_owned())
1233 .collect::<Vec<_>>();
1234 assert_eq!(typed_labels, expected_labels);
1235 }
1236
1237 #[test]
1238 fn review_comment_renders_repository_prefixed_peer_ranges() {
1239 let issue = CiIssue {
1240 rule_id: "fallow/code-duplication".to_owned(),
1241 description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1242 severity: "minor".to_owned(),
1243 path: "src/a.ts".to_owned(),
1244 line: 5,
1245 end_line: Some(15),
1246 other_locations: vec![CiLocation {
1247 path: "src/b.ts".to_owned(),
1248 line: 30,
1249 end_line: 40,
1250 }],
1251 fingerprint: "instance-fingerprint".to_owned(),
1252 legacy_fingerprint: None,
1253 };
1254 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1255 provider: CiProvider::Gitlab,
1256 group: &[&issue],
1257 gitlab_diff_refs: None,
1258 diff_index: None,
1259 path_prefix: "packages/app",
1260 include_guidance: false,
1261 suggestion_block: &|_, _| None,
1262 guidance_block: &|_| None,
1263 });
1264 let ReviewComment::GitLab(comment) = comment else {
1265 panic!("expected GitLab comment");
1266 };
1267
1268 assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1269 assert!(
1270 comment
1271 .body
1272 .contains("Other locations: `packages/app/src/b.ts:30-40`")
1273 );
1274 }
1275
1276 #[test]
1277 fn renders_default_empty_comment() {
1278 let body = render_pr_comment(&PrCommentRenderInput {
1279 command: "check",
1280 provider: CiProvider::Github,
1281 issues: &[],
1282 marker_id: "fallow-results".to_owned(),
1283 max_comments: 50,
1284 category_for_rule: &category_for_rule,
1285 trailing_section: None,
1286 });
1287 assert!(body.contains("<!-- fallow-id: fallow-results"));
1288 assert!(body.contains("No findings for this pull request."));
1289 }
1290
1291 fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1292 CiIssue {
1293 rule_id: rule_id.to_owned(),
1294 description: description.to_owned(),
1295 severity: severity.to_owned(),
1296 path: path.to_owned(),
1297 line: 3,
1298 end_line: None,
1299 other_locations: Vec::new(),
1300 fingerprint: path.to_owned(),
1301 legacy_fingerprint: None,
1302 }
1303 }
1304
1305 fn dead_code_issue(line: u64, fingerprint: &str, legacy: Option<&str>) -> CiIssue {
1306 CiIssue {
1307 rule_id: "fallow/unused-export".to_owned(),
1308 description: "Export 'helper' is never imported by other modules".to_owned(),
1309 severity: "major".to_owned(),
1310 path: "src/lib.ts".to_owned(),
1311 line,
1312 end_line: None,
1313 other_locations: Vec::new(),
1314 fingerprint: fingerprint.to_owned(),
1315 legacy_fingerprint: legacy.map(str::to_owned),
1316 }
1317 }
1318
1319 fn github_comment(group: &[&CiIssue]) -> GitHubReviewComment {
1320 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1321 provider: CiProvider::Github,
1322 group,
1323 gitlab_diff_refs: None,
1324 diff_index: None,
1325 path_prefix: "",
1326 include_guidance: false,
1327 suggestion_block: &|_, _| None,
1328 guidance_block: &|_| None,
1329 });
1330 let ReviewComment::GitHub(comment) = comment else {
1331 panic!("expected GitHub comment");
1332 };
1333 comment
1334 }
1335
1336 #[test]
1340 fn review_comment_writes_a_v3_marker_and_keeps_the_legacy_fingerprint() {
1341 let issue = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1342
1343 let comment = github_comment(&[&issue]);
1344
1345 assert!(
1346 comment
1347 .body
1348 .ends_with("<!-- fallow-fingerprint:v3: 0123456789abcdef -->"),
1349 "{}",
1350 comment.body
1351 );
1352 assert!(!comment.body.contains("fallow-fingerprint:v2:"));
1353 assert_eq!(comment.fingerprint, "0123456789abcdef");
1354 assert_eq!(
1355 comment.legacy_fingerprint.as_deref(),
1356 Some("fedcba9876543210")
1357 );
1358 let value = serde_json::to_value(&comment).expect("comment serializes");
1359 assert_eq!(value["legacy_fingerprint"], "fedcba9876543210");
1360 }
1361
1362 #[test]
1365 fn review_comment_omits_an_unchanged_legacy_fingerprint() {
1366 let issue = dead_code_issue(3, "0123456789abcdef", None);
1367
1368 let comment = github_comment(&[&issue]);
1369
1370 assert_eq!(comment.legacy_fingerprint, None);
1371 let value = serde_json::to_value(&comment).expect("comment serializes");
1372 assert!(value.get("legacy_fingerprint").is_none());
1373 }
1374
1375 #[test]
1379 fn merged_review_comment_rebuilds_the_legacy_composite() {
1380 let moved = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1381 let kept = dead_code_issue(3, "1111111111111111", None);
1382
1383 let comment = github_comment(&[&moved, &kept]);
1384
1385 assert_eq!(
1386 comment.fingerprint,
1387 composite_fingerprint(&["0123456789abcdef", "1111111111111111"])
1388 );
1389 assert_eq!(
1390 comment.legacy_fingerprint,
1391 Some(composite_fingerprint(&[
1392 "fedcba9876543210",
1393 "1111111111111111"
1394 ]))
1395 );
1396 }
1397
1398 #[test]
1399 fn pr_comment_titles_by_content_and_names_its_only_category() {
1400 let issues = vec![
1401 pr_comment_issue(
1402 "fallow/unresolved-import",
1403 "Import './x' could not be resolved",
1404 "major",
1405 "src/a.ts",
1406 ),
1407 pr_comment_issue(
1408 "fallow/unresolved-import",
1409 "Import './y' could not be resolved",
1410 "major",
1411 "src/b.ts",
1412 ),
1413 ];
1414 let body = render_pr_comment(&PrCommentRenderInput {
1415 command: "dead-code",
1416 provider: CiProvider::Github,
1417 issues: &issues,
1418 marker_id: "fallow-results".to_owned(),
1419 max_comments: 50,
1420 category_for_rule: &category_for_rule,
1421 trailing_section: None,
1422 });
1423 assert!(body.contains("### Fallow codebase report"), "{body}");
1424 assert!(body.contains("**Quality gate failed**"), "{body}");
1425 assert!(body.contains("Found **2** findings."), "{body}");
1426 assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1427 }
1428
1429 #[test]
1430 fn pr_comment_breakdown_indexes_several_categories() {
1431 let issues = vec![
1432 pr_comment_issue(
1433 "fallow/unresolved-import",
1434 "Import './x' could not be resolved",
1435 "major",
1436 "src/a.ts",
1437 ),
1438 pr_comment_issue(
1439 "fallow/unused-dependency",
1440 "Package 'lodash' is never imported",
1441 "minor",
1442 "package.json",
1443 ),
1444 ];
1445 let body = render_pr_comment(&PrCommentRenderInput {
1446 command: "check",
1447 provider: CiProvider::Github,
1448 issues: &issues,
1449 marker_id: "fallow-results".to_owned(),
1450 max_comments: 50,
1451 category_for_rule: &category_for_rule,
1452 trailing_section: None,
1453 });
1454 assert!(
1455 body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1456 "{body}"
1457 );
1458 }
1459
1460 #[test]
1461 fn pr_comment_empty_state_speaks_the_provider_language() {
1462 let github = render_pr_comment(&PrCommentRenderInput {
1463 command: "dead-code",
1464 provider: CiProvider::Github,
1465 issues: &[],
1466 marker_id: "fallow-results".to_owned(),
1467 max_comments: 50,
1468 category_for_rule: &category_for_rule,
1469 trailing_section: None,
1470 });
1471 let gitlab = render_pr_comment(&PrCommentRenderInput {
1472 command: "dead-code",
1473 provider: CiProvider::Gitlab,
1474 issues: &[],
1475 marker_id: "fallow-results".to_owned(),
1476 max_comments: 50,
1477 category_for_rule: &category_for_rule,
1478 trailing_section: None,
1479 });
1480 assert!(
1481 github.contains("No findings for this pull request."),
1482 "{github}"
1483 );
1484 assert!(
1485 gitlab.contains("No findings for this merge request."),
1486 "{gitlab}"
1487 );
1488 assert!(
1489 github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1490 "{github}"
1491 );
1492 assert!(github.contains("Generated by fallow."), "{github}");
1493 }
1494
1495 #[test]
1496 fn escape_md_escapes_inline_commonmark_specials() {
1497 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1498 let escaped = escape_md(raw);
1499 for ch in [
1500 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1501 ] {
1502 let raw_count = raw.chars().filter(|c| c == &ch).count();
1503 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1504 assert_eq!(
1505 raw_count, escaped_count,
1506 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1507 );
1508 }
1509 }
1510
1511 #[test]
1512 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1513 let raw = "value *suspicious* here";
1514 let escaped = escape_md(raw);
1515 assert!(escaped.contains(r"\&"), "got: {escaped}");
1516 assert!(escaped.contains(r"\#"), "got: {escaped}");
1517 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1518 }
1519
1520 #[test]
1521 fn summary_label_foreshadows_truncation() {
1522 assert_eq!(
1523 summary_label("Duplication", 160, 50),
1524 "Duplication (160, showing 50)"
1525 );
1526 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1527 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1528 }
1529
1530 #[test]
1531 fn escape_md_does_not_escape_block_only_markers() {
1532 let raw = "fallow/test-only-dependency package.json:12";
1533 let escaped = escape_md(raw);
1534 assert!(!escaped.contains("\\-"), "should not escape `-`");
1535 assert!(!escaped.contains("\\."), "should not escape `.`");
1536 assert_eq!(escaped, raw);
1537 }
1538
1539 #[test]
1540 fn escape_md_collapses_newlines_to_spaces() {
1541 let raw = "first\nsecond\nthird";
1542 assert_eq!(escape_md(raw), "first second third");
1543 }
1544
1545 #[test]
1546 fn escape_md_collapses_carriage_returns_to_spaces() {
1547 assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1548 }
1549
1550 #[test]
1551 fn escape_md_trims_surrounding_whitespace() {
1552 assert_eq!(escape_md(" a\u{2003}\r\n"), "a");
1553 assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1554 }
1555
1556 #[test]
1557 fn escape_md_collapses_crlf_to_one_space() {
1558 let collapsed = escape_md("a\r\nb\rc\nd");
1559 assert_eq!(collapsed, "a b c d");
1560 assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1561 }
1562
1563 #[test]
1564 fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1565 assert_eq!(escape_md("a\tb"), "a\tb");
1566 assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1567 }
1568
1569 #[test]
1570 fn escape_md_passes_non_ascii_through_unchanged() {
1571 assert_eq!(escape_md("é🦀"), "é🦀");
1572 }
1573
1574 #[test]
1575 fn markdown_code_span_grows_fence_past_inner_backticks() {
1576 assert_eq!(markdown_code_span("plain"), "`plain`");
1577 assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1578 assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1579 }
1580
1581 #[test]
1582 fn markdown_table_code_span_escapes_pipes() {
1583 assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1584 assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1585 }
1586
1587 #[test]
1588 fn markdown_table_code_span_collapses_line_endings() {
1589 assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1590 }
1591
1592 #[test]
1593 fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1594 assert_eq!(markdown_table_text("a|b"), "a\\|b");
1595 assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1596 }
1597
1598 #[test]
1599 fn escape_md_leaves_safe_chars_unchanged() {
1600 let raw = "Export 'helperFn' is never imported by other modules";
1601 assert_eq!(
1602 escape_md(raw),
1603 r"Export 'helperFn' is never imported by other modules"
1604 );
1605 }
1606
1607 #[test]
1608 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1609 for rule_id in PROJECT_LEVEL_RULE_IDS {
1610 assert!(
1611 is_project_level_rule(rule_id),
1612 "{rule_id} must be project-level"
1613 );
1614 }
1615 for rule_id in [
1616 "fallow/unused-file",
1617 "fallow/unused-export",
1618 "fallow/unused-type",
1619 "fallow/unused-enum-member",
1620 "fallow/unused-class-member",
1621 "fallow/unused-store-member",
1622 "fallow/unresolved-import",
1623 "fallow/unlisted-dependency",
1624 "fallow/duplicate-export",
1625 "fallow/circular-dependency",
1626 "fallow/re-export-cycle",
1627 "fallow/package-cycle",
1628 "fallow/boundary-violation",
1629 "fallow/stale-suppression",
1630 "fallow/private-type-leak",
1631 "fallow/high-complexity",
1632 "fallow/high-crap-score",
1633 ] {
1634 assert!(
1635 !is_project_level_rule(rule_id),
1636 "{rule_id} must NOT be project-level"
1637 );
1638 }
1639 }
1640
1641 #[test]
1642 fn escape_md_double_apply_is_safe() {
1643 let raw = "code with `backticks` and *stars*";
1644 let once = escape_md(raw);
1645 let twice = escape_md(&once);
1646 assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1647 assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1648 }
1649
1650 #[test]
1651 fn pr_comment_trailing_section_renders_before_the_footer() {
1652 let body = render_pr_comment(&PrCommentRenderInput {
1653 command: "check",
1654 provider: CiProvider::Github,
1655 issues: &[],
1656 marker_id: "fallow-results".to_owned(),
1657 max_comments: 50,
1658 category_for_rule: &category_for_rule,
1659 trailing_section: Some("\n## Unmatched config patterns\n\n- `@typo/*`"),
1660 });
1661 assert!(
1662 body.ends_with(
1663 "\n\n## Unmatched config patterns\n\n- `@typo/*`\n\nGenerated by fallow."
1664 ),
1665 "{body}"
1666 );
1667 }
1668}