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