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 "architecture" => "architecture report",
531 "dupes" => "duplication report",
532 "health" => "health report",
533 "audit" => "audit report",
534 "security" => "security report",
535 "fix" => "fix report",
536 "" | "combined" => "combined report",
537 _ => "report",
538 }
539}
540
541const fn change_noun(provider: CiProvider) -> &'static str {
543 match provider {
544 CiProvider::Github => "pull request",
545 CiProvider::Gitlab => "merge request",
546 }
547}
548
549const fn changes_tab(provider: CiProvider) -> &'static str {
551 match provider {
552 CiProvider::Github => "Files changed",
553 CiProvider::Gitlab => "Changes",
554 }
555}
556
557#[must_use]
559pub fn escape_md(value: &str) -> String {
560 let value = value.trim();
561 let mut chars = value.chars().peekable();
564 let mut out = String::with_capacity(value.len());
565 while let Some(ch) = chars.next() {
566 let ch = match ch {
567 '\r' => {
568 if chars.peek() == Some(&'\n') {
569 chars.next();
570 }
571 ' '
572 }
573 '\n' => ' ',
574 _ => ch,
575 };
576 if matches!(
577 ch,
578 '\\' | '`'
579 | '*'
580 | '_'
581 | '['
582 | ']'
583 | '('
584 | ')'
585 | '!'
586 | '<'
587 | '>'
588 | '#'
589 | '|'
590 | '~'
591 | '&'
592 ) {
593 out.push('\\');
594 }
595 out.push(ch);
596 }
597 out
598}
599
600#[must_use]
604pub fn markdown_code_span(value: &str) -> String {
605 let longest_run = value
606 .split(|c| c != '`')
607 .map(str::len)
608 .max()
609 .unwrap_or_default();
610 let fence = "`".repeat(longest_run + 1);
611 let needs_padding = value.starts_with('`')
612 || value.ends_with('`')
613 || (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
614 if needs_padding {
615 format!("{fence} {value} {fence}")
616 } else {
617 format!("{fence}{value}{fence}")
618 }
619}
620
621#[must_use]
625pub fn markdown_table_code_span(value: &str) -> String {
626 let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
627 markdown_code_span(&collapsed.replace('|', "\\|"))
628}
629
630#[must_use]
633pub fn markdown_table_text(value: &str) -> String {
634 value
635 .replace("\r\n", " ")
636 .replace(['\n', '\r'], " ")
637 .replace('|', "\\|")
638}
639
640#[must_use]
642pub fn render_review_envelope(
643 input: &ReviewEnvelopeRenderInput<'_>,
644 status_message: Option<&str>,
645) -> ReviewEnvelopeRenderResult {
646 render_review_envelope_with_id(input, None, None, status_message)
647}
648
649#[must_use]
651pub fn render_review_envelope_with_conclusion(
652 input: &ReviewEnvelopeRenderInput<'_>,
653 conclusion: ReviewCheckConclusion,
654 status_message: Option<&str>,
655) -> ReviewEnvelopeRenderResult {
656 render_review_envelope_with_id(input, None, Some(conclusion), status_message)
657}
658
659#[must_use]
661pub fn render_scoped_review_envelope(
662 input: &ReviewEnvelopeRenderInput<'_>,
663 review_id: &ReviewId,
664 status_message: Option<&str>,
665) -> ReviewEnvelopeRenderResult {
666 render_review_envelope_with_id(input, Some(review_id), None, status_message)
667}
668
669#[must_use]
671pub fn render_scoped_review_envelope_with_conclusion(
672 input: &ReviewEnvelopeRenderInput<'_>,
673 review_id: &ReviewId,
674 conclusion: ReviewCheckConclusion,
675 status_message: Option<&str>,
676) -> ReviewEnvelopeRenderResult {
677 render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
678}
679
680fn render_review_envelope_with_id(
681 input: &ReviewEnvelopeRenderInput<'_>,
682 review_id: Option<&ReviewId>,
683 conclusion: Option<ReviewCheckConclusion>,
684 status_message: Option<&str>,
685) -> ReviewEnvelopeRenderResult {
686 let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
687
688 let comments: Vec<ReviewComment> = grouped
689 .groups
690 .iter()
691 .map(|group| {
692 render_review_comment_for_group_with_id(
693 &ReviewCommentRenderInput {
694 provider: input.provider,
695 group,
696 gitlab_diff_refs: input.gitlab_diff_refs,
697 diff_index: input.diff_index,
698 path_prefix: input.path_prefix,
699 include_guidance: input.include_guidance,
700 suggestion_block: input.suggestion_block,
701 guidance_block: input.guidance_block,
702 },
703 review_id,
704 )
705 })
706 .collect();
707
708 let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
709 let summary_text = review_summary_text(
710 input.command,
711 input.provider,
712 comments.len(),
713 status_message,
714 input.trailing_section,
715 );
716 let summary_fp = summary_fingerprint(&summary_text);
717 let summary_marker = review_markers(&summary_fp, review_id);
718 let body = format!("{summary_text}{summary_marker}");
719 let summary = ReviewEnvelopeSummary {
720 body: body.clone(),
721 fingerprint: summary_fp,
722 };
723
724 let truncation = ReviewEnvelopeTruncation {
725 body: comments.iter().any(review_comment_truncated),
726 comment_limit: grouped.truncated,
727 };
728
729 ReviewEnvelopeRenderResult {
730 envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
731 truncation,
732 }
733}
734
735fn review_summary_text(
742 command: &str,
743 provider: CiProvider,
744 comment_count: usize,
745 status_message: Option<&str>,
746 trailing_section: Option<&str>,
747) -> String {
748 let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
749 let section = trailing_section
750 .map(str::trim)
751 .filter(|section| !section.is_empty())
752 .map_or_else(String::new, |section| format!("\n\n{section}"));
753 format!(
754 "### Fallow {}{}\n\n{}{}\n\n<!-- fallow-review -->",
755 command_title(command),
756 status,
757 inline_comment_line(provider, comment_count),
758 section,
759 )
760}
761
762fn inline_comment_line(provider: CiProvider, count: usize) -> String {
763 if count == 0 {
764 return format!(
765 "No findings anchored to the changed lines in this {}.",
766 change_noun(provider)
767 );
768 }
769 format!(
770 "{count} inline comment{} on the changed lines. Open the {} tab to review.",
771 if count == 1 { "" } else { "s" },
772 changes_tab(provider),
773 )
774}
775
776#[derive(Debug, PartialEq, Eq)]
779pub struct GroupedReviewIssues<'a> {
780 pub groups: Vec<Vec<&'a CiIssue>>,
782 pub truncated: bool,
784}
785
786#[must_use]
789pub fn group_review_issues_by_path_line(
790 issues: &[CiIssue],
791 max_groups: usize,
792) -> GroupedReviewIssues<'_> {
793 if max_groups == 0 {
794 return GroupedReviewIssues {
795 groups: Vec::new(),
796 truncated: !issues.is_empty(),
797 };
798 }
799 let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
800 let mut current: Vec<&CiIssue> = Vec::new();
801 let mut current_key: Option<(&str, u64)> = None;
802 for issue in issues {
803 let key = (issue.path.as_str(), issue.line);
804 if Some(key) != current_key {
805 if !current.is_empty() {
806 groups.push(std::mem::take(&mut current));
807 if groups.len() == max_groups {
808 return GroupedReviewIssues {
809 groups,
810 truncated: true,
811 };
812 }
813 }
814 current_key = Some(key);
815 }
816 current.push(issue);
817 }
818 if !current.is_empty() && groups.len() < max_groups {
819 groups.push(current);
820 }
821 GroupedReviewIssues {
822 groups,
823 truncated: false,
824 }
825}
826
827fn review_comment_truncated(comment: &ReviewComment) -> bool {
828 match comment {
829 ReviewComment::GitHub(comment) => comment.truncated,
830 ReviewComment::GitLab(comment) => comment.truncated,
831 }
832}
833
834pub struct ReviewCommentRenderInput<'a, 'group> {
836 pub provider: CiProvider,
838 pub group: &'a [&'group CiIssue],
840 pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
842 pub diff_index: Option<&'a DiffIndex>,
844 pub path_prefix: &'a str,
846 pub include_guidance: bool,
848 pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
851 pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
853}
854
855#[must_use]
857pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
858 render_review_comment_for_group_with_id(input, None)
859}
860
861fn render_review_comment_for_group_with_id(
862 input: &ReviewCommentRenderInput<'_, '_>,
863 review_id: Option<&ReviewId>,
864) -> ReviewComment {
865 assert!(
866 !input.group.is_empty(),
867 "group_review_issues_by_path_line never yields empty"
868 );
869 let representative = input.group[0];
870 let fingerprint = if input.group.len() == 1 {
871 representative.fingerprint.clone()
872 } else {
873 let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
874 composite_fingerprint(&constituents)
875 };
876 let legacy_fingerprint = group_legacy_fingerprint(input.group);
877
878 let content = build_merged_comment_content(input);
879 let marker_line = review_markers(&fingerprint, review_id);
880 let (body, truncated) = cap_body_with_marker(&content, &marker_line);
881
882 build_review_comment(ReviewCommentInput {
883 provider: input.provider,
884 representative,
885 gitlab_diff_refs: input.gitlab_diff_refs,
886 diff_index: input.diff_index,
887 path_prefix: input.path_prefix,
888 body,
889 fingerprint,
890 legacy_fingerprint,
891 truncated,
892 })
893}
894
895fn group_legacy_fingerprint(group: &[&CiIssue]) -> Option<String> {
902 if group.iter().all(|issue| issue.legacy_fingerprint.is_none()) {
903 return None;
904 }
905 if let [issue] = group {
906 return issue.legacy_fingerprint.clone();
907 }
908 let constituents: Vec<&str> = group
909 .iter()
910 .map(|issue| {
911 issue
912 .legacy_fingerprint
913 .as_deref()
914 .unwrap_or(issue.fingerprint.as_str())
915 })
916 .collect();
917 Some(composite_fingerprint(&constituents))
918}
919
920#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
921fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
922 let mut content = String::new();
923 for (index, issue) in input.group.iter().enumerate() {
924 let label = review_label_from_codeclimate(&issue.severity);
925 if index > 0 {
926 content.push_str("\n\n");
927 }
928 write!(
929 content,
930 "**{}** `{}`: {}",
931 label,
932 escape_md(&issue.rule_id),
933 escape_md(&issue.description)
934 )
935 .expect("write to String is infallible");
936 if !issue.other_locations.is_empty() {
937 content.push_str("\n\nOther locations: ");
938 let locations = issue
939 .other_locations
940 .iter()
941 .map(|location| {
942 markdown_code_span(&format!(
943 "{}:{}-{}",
944 apply_path_prefix(input.path_prefix, &location.path),
945 location.line,
946 location.end_line
947 ))
948 })
949 .collect::<Vec<_>>()
950 .join(", ");
951 content.push_str(&locations);
952 }
953 if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
954 content.push_str(&suggestion);
955 }
956 if input.include_guidance
957 && let Some(guidance) = (input.guidance_block)(issue)
958 {
959 content.push_str(&guidance);
960 }
961 }
962 content
963}
964
965struct ReviewCommentInput<'a> {
966 provider: CiProvider,
967 representative: &'a CiIssue,
968 gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
969 diff_index: Option<&'a DiffIndex>,
970 path_prefix: &'a str,
971 body: String,
972 fingerprint: String,
973 legacy_fingerprint: Option<String>,
974 truncated: bool,
975}
976
977fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
978 let ReviewCommentInput {
979 provider,
980 representative,
981 gitlab_diff_refs,
982 diff_index,
983 path_prefix,
984 body,
985 fingerprint,
986 legacy_fingerprint,
987 truncated,
988 } = input;
989 match provider {
990 CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
991 path: apply_path_prefix(path_prefix, &representative.path),
992 line: u32::try_from(representative.line).unwrap_or(u32::MAX),
993 side: GitHubReviewSide::Right,
994 body,
995 fingerprint,
996 legacy_fingerprint,
997 truncated,
998 }),
999 CiProvider::Gitlab => {
1000 let old_rel = diff_index
1003 .and_then(|di| di.old_path_for_root_relative(&representative.path))
1004 .map_or_else(|| representative.path.clone(), Cow::into_owned);
1005 let new_path = apply_path_prefix(path_prefix, &representative.path);
1006 let old_path = apply_path_prefix(path_prefix, &old_rel);
1007 let position = GitLabReviewPosition {
1008 base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
1009 start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
1010 head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
1011 position_type: GitLabReviewPositionType::Text,
1012 old_path,
1013 new_path,
1014 new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
1015 };
1016 ReviewComment::GitLab(GitLabReviewComment {
1017 body,
1018 position,
1019 fingerprint,
1020 legacy_fingerprint,
1021 truncated,
1022 })
1023 }
1024 }
1025}
1026
1027#[must_use]
1031pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
1032 let intact_len = content.len() + marker_line.len();
1033 if intact_len <= MAX_COMMENT_BODY_BYTES {
1034 let mut out = String::with_capacity(intact_len);
1035 out.push_str(content);
1036 out.push_str(marker_line);
1037 return (out, false);
1038 }
1039 let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
1040 let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
1041 let mut cut = budget.min(content.len());
1042 while cut > 0 && !content.is_char_boundary(cut) {
1043 cut -= 1;
1044 }
1045 let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
1046 out.push_str(&content[..cut]);
1047 out.push_str(TRUNCATION_SUFFIX);
1048 out.push_str(marker_line);
1049 (out, true)
1050}
1051
1052#[must_use]
1055pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
1056 match severity_name.as_bytes() {
1057 b"major" | b"critical" | b"blocker" => "error",
1058 _ => "warn",
1059 }
1060}
1061
1062#[must_use]
1065pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
1066 if issues
1067 .iter()
1068 .any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
1069 {
1070 ReviewCheckConclusion::Failure
1071 } else if issues.is_empty() {
1072 ReviewCheckConclusion::Success
1073 } else {
1074 ReviewCheckConclusion::Neutral
1075 }
1076}
1077
1078fn build_review_envelope_output(
1079 provider: CiProvider,
1080 body: String,
1081 summary: ReviewEnvelopeSummary,
1082 comments: Vec<ReviewComment>,
1083 conclusion: ReviewCheckConclusion,
1084) -> ReviewEnvelopeOutput {
1085 match provider {
1086 CiProvider::Github => ReviewEnvelopeOutput {
1087 event: Some(ReviewEnvelopeEvent::Comment),
1088 body,
1089 summary,
1090 comments,
1091 marker_regex: default_marker_regex(),
1092 marker_regex_flags: default_marker_regex_flags(),
1093 meta: ReviewEnvelopeMeta {
1094 schema: ReviewEnvelopeSchema::V3,
1095 provider: ReviewProvider::Github,
1096 check_conclusion: Some(conclusion),
1097 },
1098 },
1099 CiProvider::Gitlab => ReviewEnvelopeOutput {
1100 event: None,
1101 body,
1102 summary,
1103 comments,
1104 marker_regex: default_marker_regex(),
1105 marker_regex_flags: default_marker_regex_flags(),
1106 meta: ReviewEnvelopeMeta {
1107 schema: ReviewEnvelopeSchema::V3,
1108 provider: ReviewProvider::Gitlab,
1109 check_conclusion: None,
1110 },
1111 },
1112 }
1113}
1114
1115fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
1116 let fingerprint = format!("\n\n{MARKER_PREFIX_V3}{fingerprint}{MARKER_SUFFIX_V3}");
1117 match review_id {
1118 Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
1119 None => fingerprint,
1120 }
1121}
1122
1123#[must_use]
1125pub fn summary_fingerprint(body: &str) -> String {
1126 fingerprint_hash(&[body])
1127}
1128
1129#[must_use]
1133pub fn composite_fingerprint(constituents: &[&str]) -> String {
1134 let mut sorted: Vec<&str> = constituents.to_vec();
1135 sorted.sort_unstable();
1136 let joined = sorted.join(":");
1137 format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143 use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
1144
1145 fn category_for_rule(rule_id: &str) -> &'static str {
1146 match rule_id {
1147 "fallow/code-duplication" => "Duplication",
1148 "fallow/high-complexity" => "Health",
1149 "fallow/unused-dependency" => "Dependencies",
1150 _ => "Dead code",
1151 }
1152 }
1153
1154 #[test]
1155 fn command_title_names_each_analysis_command() {
1156 assert_eq!(command_title("dead-code"), "codebase report");
1157 assert_eq!(command_title("architecture"), "architecture report");
1158 assert_eq!(command_title("dupes"), "duplication report");
1159 }
1160
1161 #[test]
1162 fn extracts_issues_from_codeclimate() {
1163 let value = serde_json::json!([{
1164 "check_name": "fallow/unused-export",
1165 "description": "Export x is never imported",
1166 "severity": "minor",
1167 "fingerprint": "abc",
1168 "location": { "path": "src/a.ts", "lines": { "begin": 7 } }
1169 }]);
1170 let issues = issues_from_codeclimate(&value);
1171 assert_eq!(issues.len(), 1);
1172 assert_eq!(issues[0].path, "src/a.ts");
1173 assert_eq!(issues[0].line, 7);
1174 }
1175
1176 #[test]
1177 fn typed_codeclimate_issues_extract_like_json_codeclimate() {
1178 let severities = [
1179 (CodeClimateSeverity::Info, "info"),
1180 (CodeClimateSeverity::Minor, "minor"),
1181 (CodeClimateSeverity::Major, "major"),
1182 (CodeClimateSeverity::Critical, "critical"),
1183 (CodeClimateSeverity::Blocker, "blocker"),
1184 ];
1185 let typed = severities
1186 .iter()
1187 .enumerate()
1188 .map(|(index, (severity, _))| CodeClimateIssue {
1189 kind: CodeClimateIssueKind::Issue,
1190 check_name: format!("fallow/rule-{index}"),
1191 description: format!("Finding {index}"),
1192 categories: vec!["Complexity".to_owned()],
1193 severity: *severity,
1194 fingerprint: format!("fp-{index}"),
1195 location: CodeClimateLocation {
1196 path: format!("src/{index}.ts"),
1197 lines: CodeClimateLines {
1198 begin: u32::try_from(index + 1).expect("small fixture index"),
1199 end: Some(u32::try_from(index + 3).expect("small fixture index")),
1200 },
1201 },
1202 other_locations: vec![CodeClimateLocation {
1203 path: format!("src/peer-{index}.ts"),
1204 lines: CodeClimateLines {
1205 begin: 20,
1206 end: Some(24),
1207 },
1208 }],
1209 owner: None,
1210 group: None,
1211 legacy_fingerprint: None,
1212 })
1213 .collect::<Vec<_>>();
1214 let value = serde_json::to_value(&typed).expect("typed fixture serializes");
1215
1216 assert_eq!(
1217 issues_from_codeclimate_issues(&typed),
1218 issues_from_codeclimate(&value)
1219 );
1220 let normalized = issues_from_codeclimate_issues(&typed);
1221 assert_eq!(normalized[0].end_line, Some(3));
1222 assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
1223 assert_eq!(normalized[0].other_locations[0].line, 20);
1224 assert_eq!(normalized[0].other_locations[0].end_line, 24);
1225 let typed_labels = issues_from_codeclimate_issues(&typed)
1226 .into_iter()
1227 .map(|issue| issue.severity)
1228 .collect::<Vec<_>>();
1229 let expected_labels = severities
1230 .iter()
1231 .map(|(_, label)| (*label).to_owned())
1232 .collect::<Vec<_>>();
1233 assert_eq!(typed_labels, expected_labels);
1234 }
1235
1236 #[test]
1237 fn review_comment_renders_repository_prefixed_peer_ranges() {
1238 let issue = CiIssue {
1239 rule_id: "fallow/code-duplication".to_owned(),
1240 description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
1241 severity: "minor".to_owned(),
1242 path: "src/a.ts".to_owned(),
1243 line: 5,
1244 end_line: Some(15),
1245 other_locations: vec![CiLocation {
1246 path: "src/b.ts".to_owned(),
1247 line: 30,
1248 end_line: 40,
1249 }],
1250 fingerprint: "instance-fingerprint".to_owned(),
1251 legacy_fingerprint: None,
1252 };
1253 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1254 provider: CiProvider::Gitlab,
1255 group: &[&issue],
1256 gitlab_diff_refs: None,
1257 diff_index: None,
1258 path_prefix: "packages/app",
1259 include_guidance: false,
1260 suggestion_block: &|_, _| None,
1261 guidance_block: &|_| None,
1262 });
1263 let ReviewComment::GitLab(comment) = comment else {
1264 panic!("expected GitLab comment");
1265 };
1266
1267 assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
1268 assert!(
1269 comment
1270 .body
1271 .contains("Other locations: `packages/app/src/b.ts:30-40`")
1272 );
1273 }
1274
1275 #[test]
1276 fn renders_default_empty_comment() {
1277 let body = render_pr_comment(&PrCommentRenderInput {
1278 command: "check",
1279 provider: CiProvider::Github,
1280 issues: &[],
1281 marker_id: "fallow-results".to_owned(),
1282 max_comments: 50,
1283 category_for_rule: &category_for_rule,
1284 trailing_section: None,
1285 });
1286 assert!(body.contains("<!-- fallow-id: fallow-results"));
1287 assert!(body.contains("No findings for this pull request."));
1288 }
1289
1290 fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
1291 CiIssue {
1292 rule_id: rule_id.to_owned(),
1293 description: description.to_owned(),
1294 severity: severity.to_owned(),
1295 path: path.to_owned(),
1296 line: 3,
1297 end_line: None,
1298 other_locations: Vec::new(),
1299 fingerprint: path.to_owned(),
1300 legacy_fingerprint: None,
1301 }
1302 }
1303
1304 fn dead_code_issue(line: u64, fingerprint: &str, legacy: Option<&str>) -> CiIssue {
1305 CiIssue {
1306 rule_id: "fallow/unused-export".to_owned(),
1307 description: "Export 'helper' is never imported by other modules".to_owned(),
1308 severity: "major".to_owned(),
1309 path: "src/lib.ts".to_owned(),
1310 line,
1311 end_line: None,
1312 other_locations: Vec::new(),
1313 fingerprint: fingerprint.to_owned(),
1314 legacy_fingerprint: legacy.map(str::to_owned),
1315 }
1316 }
1317
1318 fn github_comment(group: &[&CiIssue]) -> GitHubReviewComment {
1319 let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
1320 provider: CiProvider::Github,
1321 group,
1322 gitlab_diff_refs: None,
1323 diff_index: None,
1324 path_prefix: "",
1325 include_guidance: false,
1326 suggestion_block: &|_, _| None,
1327 guidance_block: &|_| None,
1328 });
1329 let ReviewComment::GitHub(comment) = comment else {
1330 panic!("expected GitHub comment");
1331 };
1332 comment
1333 }
1334
1335 #[test]
1339 fn review_comment_writes_a_v3_marker_and_keeps_the_legacy_fingerprint() {
1340 let issue = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1341
1342 let comment = github_comment(&[&issue]);
1343
1344 assert!(
1345 comment
1346 .body
1347 .ends_with("<!-- fallow-fingerprint:v3: 0123456789abcdef -->"),
1348 "{}",
1349 comment.body
1350 );
1351 assert!(!comment.body.contains("fallow-fingerprint:v2:"));
1352 assert_eq!(comment.fingerprint, "0123456789abcdef");
1353 assert_eq!(
1354 comment.legacy_fingerprint.as_deref(),
1355 Some("fedcba9876543210")
1356 );
1357 let value = serde_json::to_value(&comment).expect("comment serializes");
1358 assert_eq!(value["legacy_fingerprint"], "fedcba9876543210");
1359 }
1360
1361 #[test]
1364 fn review_comment_omits_an_unchanged_legacy_fingerprint() {
1365 let issue = dead_code_issue(3, "0123456789abcdef", None);
1366
1367 let comment = github_comment(&[&issue]);
1368
1369 assert_eq!(comment.legacy_fingerprint, None);
1370 let value = serde_json::to_value(&comment).expect("comment serializes");
1371 assert!(value.get("legacy_fingerprint").is_none());
1372 }
1373
1374 #[test]
1378 fn merged_review_comment_rebuilds_the_legacy_composite() {
1379 let moved = dead_code_issue(3, "0123456789abcdef", Some("fedcba9876543210"));
1380 let kept = dead_code_issue(3, "1111111111111111", None);
1381
1382 let comment = github_comment(&[&moved, &kept]);
1383
1384 assert_eq!(
1385 comment.fingerprint,
1386 composite_fingerprint(&["0123456789abcdef", "1111111111111111"])
1387 );
1388 assert_eq!(
1389 comment.legacy_fingerprint,
1390 Some(composite_fingerprint(&[
1391 "fedcba9876543210",
1392 "1111111111111111"
1393 ]))
1394 );
1395 }
1396
1397 #[test]
1398 fn pr_comment_titles_by_content_and_names_its_only_category() {
1399 let issues = vec![
1400 pr_comment_issue(
1401 "fallow/unresolved-import",
1402 "Import './x' could not be resolved",
1403 "major",
1404 "src/a.ts",
1405 ),
1406 pr_comment_issue(
1407 "fallow/unresolved-import",
1408 "Import './y' could not be resolved",
1409 "major",
1410 "src/b.ts",
1411 ),
1412 ];
1413 let body = render_pr_comment(&PrCommentRenderInput {
1414 command: "dead-code",
1415 provider: CiProvider::Github,
1416 issues: &issues,
1417 marker_id: "fallow-results".to_owned(),
1418 max_comments: 50,
1419 category_for_rule: &category_for_rule,
1420 trailing_section: None,
1421 });
1422 assert!(body.contains("### Fallow codebase report"), "{body}");
1423 assert!(body.contains("**Quality gate failed**"), "{body}");
1424 assert!(body.contains("Found **2** findings."), "{body}");
1425 assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
1426 }
1427
1428 #[test]
1429 fn pr_comment_breakdown_indexes_several_categories() {
1430 let issues = vec![
1431 pr_comment_issue(
1432 "fallow/unresolved-import",
1433 "Import './x' could not be resolved",
1434 "major",
1435 "src/a.ts",
1436 ),
1437 pr_comment_issue(
1438 "fallow/unused-dependency",
1439 "Package 'lodash' is never imported",
1440 "minor",
1441 "package.json",
1442 ),
1443 ];
1444 let body = render_pr_comment(&PrCommentRenderInput {
1445 command: "check",
1446 provider: CiProvider::Github,
1447 issues: &issues,
1448 marker_id: "fallow-results".to_owned(),
1449 max_comments: 50,
1450 category_for_rule: &category_for_rule,
1451 trailing_section: None,
1452 });
1453 assert!(
1454 body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
1455 "{body}"
1456 );
1457 }
1458
1459 #[test]
1460 fn pr_comment_empty_state_speaks_the_provider_language() {
1461 let github = render_pr_comment(&PrCommentRenderInput {
1462 command: "dead-code",
1463 provider: CiProvider::Github,
1464 issues: &[],
1465 marker_id: "fallow-results".to_owned(),
1466 max_comments: 50,
1467 category_for_rule: &category_for_rule,
1468 trailing_section: None,
1469 });
1470 let gitlab = render_pr_comment(&PrCommentRenderInput {
1471 command: "dead-code",
1472 provider: CiProvider::Gitlab,
1473 issues: &[],
1474 marker_id: "fallow-results".to_owned(),
1475 max_comments: 50,
1476 category_for_rule: &category_for_rule,
1477 trailing_section: None,
1478 });
1479 assert!(
1480 github.contains("No findings for this pull request."),
1481 "{github}"
1482 );
1483 assert!(
1484 gitlab.contains("No findings for this merge request."),
1485 "{gitlab}"
1486 );
1487 assert!(
1488 github.starts_with("<!-- fallow-id: fallow-results -->\n"),
1489 "{github}"
1490 );
1491 assert!(github.contains("Generated by fallow."), "{github}");
1492 }
1493
1494 #[test]
1495 fn escape_md_escapes_inline_commonmark_specials() {
1496 let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
1497 let escaped = escape_md(raw);
1498 for ch in [
1499 '*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
1500 ] {
1501 let raw_count = raw.chars().filter(|c| c == &ch).count();
1502 let escaped_count = escaped.matches(&format!("\\{ch}")).count();
1503 assert_eq!(
1504 raw_count, escaped_count,
1505 "char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
1506 );
1507 }
1508 }
1509
1510 #[test]
1511 fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
1512 let raw = "value *suspicious* here";
1513 let escaped = escape_md(raw);
1514 assert!(escaped.contains(r"\&"), "got: {escaped}");
1515 assert!(escaped.contains(r"\#"), "got: {escaped}");
1516 assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
1517 }
1518
1519 #[test]
1520 fn summary_label_foreshadows_truncation() {
1521 assert_eq!(
1522 summary_label("Duplication", 160, 50),
1523 "Duplication (160, showing 50)"
1524 );
1525 assert_eq!(summary_label("Health", 12, 50), "Health (12)");
1526 assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
1527 }
1528
1529 #[test]
1530 fn escape_md_does_not_escape_block_only_markers() {
1531 let raw = "fallow/test-only-dependency package.json:12";
1532 let escaped = escape_md(raw);
1533 assert!(!escaped.contains("\\-"), "should not escape `-`");
1534 assert!(!escaped.contains("\\."), "should not escape `.`");
1535 assert_eq!(escaped, raw);
1536 }
1537
1538 #[test]
1539 fn escape_md_collapses_newlines_to_spaces() {
1540 let raw = "first\nsecond\nthird";
1541 assert_eq!(escape_md(raw), "first second third");
1542 }
1543
1544 #[test]
1545 fn escape_md_collapses_carriage_returns_to_spaces() {
1546 assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
1547 }
1548
1549 #[test]
1550 fn escape_md_trims_surrounding_whitespace() {
1551 assert_eq!(escape_md(" a\u{2003}\r\n"), "a");
1552 assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
1553 }
1554
1555 #[test]
1556 fn escape_md_collapses_crlf_to_one_space() {
1557 let collapsed = escape_md("a\r\nb\rc\nd");
1558 assert_eq!(collapsed, "a b c d");
1559 assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
1560 }
1561
1562 #[test]
1563 fn escape_md_preserves_interior_tabs_and_wide_spaces() {
1564 assert_eq!(escape_md("a\tb"), "a\tb");
1565 assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
1566 }
1567
1568 #[test]
1569 fn escape_md_passes_non_ascii_through_unchanged() {
1570 assert_eq!(escape_md("é🦀"), "é🦀");
1571 }
1572
1573 #[test]
1574 fn markdown_code_span_grows_fence_past_inner_backticks() {
1575 assert_eq!(markdown_code_span("plain"), "`plain`");
1576 assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
1577 assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
1578 }
1579
1580 #[test]
1581 fn markdown_table_code_span_escapes_pipes() {
1582 assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
1583 assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
1584 }
1585
1586 #[test]
1587 fn markdown_table_code_span_collapses_line_endings() {
1588 assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
1589 }
1590
1591 #[test]
1592 fn markdown_table_text_neutralizes_pipes_and_line_endings() {
1593 assert_eq!(markdown_table_text("a|b"), "a\\|b");
1594 assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
1595 }
1596
1597 #[test]
1598 fn escape_md_leaves_safe_chars_unchanged() {
1599 let raw = "Export 'helperFn' is never imported by other modules";
1600 assert_eq!(
1601 escape_md(raw),
1602 r"Export 'helperFn' is never imported by other modules"
1603 );
1604 }
1605
1606 #[test]
1607 fn is_project_level_rule_covers_config_anchored_dependency_findings() {
1608 for rule_id in PROJECT_LEVEL_RULE_IDS {
1609 assert!(
1610 is_project_level_rule(rule_id),
1611 "{rule_id} must be project-level"
1612 );
1613 }
1614 for rule_id in [
1615 "fallow/unused-file",
1616 "fallow/unused-export",
1617 "fallow/unused-type",
1618 "fallow/unused-enum-member",
1619 "fallow/unused-class-member",
1620 "fallow/unused-store-member",
1621 "fallow/unresolved-import",
1622 "fallow/unlisted-dependency",
1623 "fallow/duplicate-export",
1624 "fallow/circular-dependency",
1625 "fallow/re-export-cycle",
1626 "fallow/package-cycle",
1627 "fallow/boundary-violation",
1628 "fallow/stale-suppression",
1629 "fallow/private-type-leak",
1630 "fallow/high-complexity",
1631 "fallow/high-crap-score",
1632 ] {
1633 assert!(
1634 !is_project_level_rule(rule_id),
1635 "{rule_id} must NOT be project-level"
1636 );
1637 }
1638 }
1639
1640 #[test]
1641 fn escape_md_double_apply_is_safe() {
1642 let raw = "code with `backticks` and *stars*";
1643 let once = escape_md(raw);
1644 let twice = escape_md(&once);
1645 assert_eq!(once, r"code with \`backticks\` and \*stars\*");
1646 assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
1647 }
1648
1649 #[test]
1650 fn pr_comment_trailing_section_renders_before_the_footer() {
1651 let body = render_pr_comment(&PrCommentRenderInput {
1652 command: "check",
1653 provider: CiProvider::Github,
1654 issues: &[],
1655 marker_id: "fallow-results".to_owned(),
1656 max_comments: 50,
1657 category_for_rule: &category_for_rule,
1658 trailing_section: Some("\n## Unmatched config patterns\n\n- `@typo/*`"),
1659 });
1660 assert!(
1661 body.ends_with(
1662 "\n\n## Unmatched config patterns\n\n- `@typo/*`\n\nGenerated by fallow."
1663 ),
1664 "{body}"
1665 );
1666 }
1667}