use std::borrow::Cow;
use std::fmt::Write as _;
use crate::{
CodeClimateIssue, CodeClimateSeverity, DiffIndex, GitHubReviewComment, GitHubReviewSide,
GitLabReviewComment, GitLabReviewPosition, GitLabReviewPositionType, ReviewCheckConclusion,
ReviewComment, ReviewEnvelopeEvent, ReviewEnvelopeMeta, ReviewEnvelopeOutput,
ReviewEnvelopeSchema, ReviewEnvelopeSummary, ReviewId, ReviewProvider, default_marker_regex,
default_marker_regex_flags, review_id_marker,
};
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CiProvider {
Github,
Gitlab,
}
impl CiProvider {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Github => "GitHub",
Self::Gitlab => "GitLab",
}
}
}
#[must_use]
pub fn apply_path_prefix(prefix: &str, path: &str) -> String {
if prefix.is_empty() {
return path.to_owned();
}
format!("{prefix}/{path}")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CiIssue {
pub rule_id: String,
pub description: String,
pub severity: String,
pub path: String,
pub line: u64,
pub end_line: Option<u64>,
pub other_locations: Vec<CiLocation>,
pub fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct CiLocation {
pub path: String,
pub line: u64,
pub end_line: u64,
}
pub struct PrCommentRenderInput<'a> {
pub command: &'a str,
pub provider: CiProvider,
pub issues: &'a [CiIssue],
pub marker_id: String,
pub max_comments: usize,
pub category_for_rule: &'a dyn Fn(&str) -> &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReviewGitlabDiffRefs {
pub base_sha: String,
pub start_sha: String,
pub head_sha: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ReviewEnvelopeTruncation {
pub body: bool,
pub comment_limit: bool,
}
#[derive(Debug)]
pub struct ReviewEnvelopeRenderResult {
pub envelope: ReviewEnvelopeOutput,
pub truncation: ReviewEnvelopeTruncation,
}
pub struct ReviewEnvelopeRenderInput<'a> {
pub command: &'a str,
pub provider: CiProvider,
pub issues: &'a [CiIssue],
pub diff_index: Option<&'a DiffIndex>,
pub path_prefix: &'a str,
pub max_comments: usize,
pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
pub include_guidance: bool,
pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
}
pub const MARKER_PREFIX_V2: &str = "<!-- fallow-fingerprint:v2: ";
pub const MARKER_SUFFIX_V2: &str = " -->";
pub const MAX_COMMENT_BODY_BYTES: usize = 65_536;
const TRUNCATION_SUFFIX: &str = "\n\n<!-- fallow-truncated -->\n> Body truncated by fallow.";
#[must_use]
pub fn issues_from_codeclimate(value: &Value) -> Vec<CiIssue> {
let mut issues = value
.as_array()
.into_iter()
.flatten()
.filter_map(issue_from_codeclimate)
.collect::<Vec<_>>();
sort_ci_issues(&mut issues);
issues
}
#[must_use]
pub fn issues_from_codeclimate_issues(issues: &[CodeClimateIssue]) -> Vec<CiIssue> {
let mut issues = issues
.iter()
.map(issue_from_codeclimate_issue)
.collect::<Vec<_>>();
sort_ci_issues(&mut issues);
issues
}
fn issue_from_codeclimate(value: &Value) -> Option<CiIssue> {
let path = value.pointer("/location/path")?.as_str()?.to_string();
let line = value
.pointer("/location/lines/begin")
.and_then(Value::as_u64)
.unwrap_or(1);
let end_line = value.pointer("/location/lines/end").and_then(Value::as_u64);
let mut other_locations = value
.get("other_locations")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|location| {
let line = location.pointer("/lines/begin")?.as_u64()?;
let end_line = location
.pointer("/lines/end")
.and_then(Value::as_u64)
.filter(|end| *end >= line)
.unwrap_or(line);
Some(CiLocation {
path: location.get("path")?.as_str()?.to_owned(),
line,
end_line,
})
})
.collect::<Vec<_>>();
other_locations.sort();
Some(CiIssue {
rule_id: value
.get("check_name")
.and_then(Value::as_str)
.unwrap_or("fallow/finding")
.to_string(),
description: value
.get("description")
.and_then(Value::as_str)
.unwrap_or("Fallow finding")
.to_string(),
severity: value
.get("severity")
.and_then(Value::as_str)
.unwrap_or("minor")
.to_string(),
fingerprint: value
.get("fingerprint")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
path,
line,
end_line,
other_locations,
})
}
fn issue_from_codeclimate_issue(issue: &CodeClimateIssue) -> CiIssue {
let mut other_locations = issue
.other_locations
.iter()
.map(|location| CiLocation {
path: location.path.clone(),
line: u64::from(location.lines.begin),
end_line: u64::from(
location
.lines
.end
.filter(|end| *end >= location.lines.begin)
.unwrap_or(location.lines.begin),
),
})
.collect::<Vec<_>>();
other_locations.sort();
CiIssue {
rule_id: issue.check_name.clone(),
description: issue.description.clone(),
severity: codeclimate_severity_label(issue.severity).to_owned(),
path: issue.location.path.clone(),
line: u64::from(issue.location.lines.begin),
end_line: issue.location.lines.end.map(u64::from),
other_locations,
fingerprint: issue.fingerprint.clone(),
}
}
const fn codeclimate_severity_label(severity: CodeClimateSeverity) -> &'static str {
match severity {
CodeClimateSeverity::Info => "info",
CodeClimateSeverity::Minor => "minor",
CodeClimateSeverity::Major => "major",
CodeClimateSeverity::Critical => "critical",
CodeClimateSeverity::Blocker => "blocker",
}
}
fn sort_ci_issues(issues: &mut [CiIssue]) {
issues
.sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
}
fn fingerprint_hash(parts: &[&str]) -> String {
crate::codeclimate_fingerprint_hash(parts)
}
#[must_use]
pub fn render_pr_comment(input: &PrCommentRenderInput<'_>) -> String {
render_pr_comment_with_verdict(input, None)
}
#[must_use]
#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
pub fn render_pr_comment_with_verdict(
input: &PrCommentRenderInput<'_>,
gate: Option<ReviewCheckConclusion>,
) -> String {
let marker = format!("<!-- fallow-id: {} -->", input.marker_id);
let title = command_title(input.command);
let count = input.issues.len();
let noun = if count == 1 { "finding" } else { "findings" };
let verdict = most_severe_conclusion(github_check_conclusion(input.issues), gate);
let mut out = String::new();
out.push_str(&marker);
out.push('\n');
write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
write!(&mut out, "**{}**\n\n", pr_comment_verdict(verdict)).expect("write to string");
if count == 0 {
writeln!(
&mut out,
"No findings for this {}.",
change_noun(input.provider)
)
.expect("write to string");
} else {
let groups = group_by_category(input.issues, input.category_for_rule);
if groups.len() > 1 {
write!(
&mut out,
"Found **{count}** {noun}: {}.\n\n",
category_breakdown(&groups)
)
.expect("write to string");
} else {
write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
}
for (category, group_issues) in &groups {
let summary_label = summary_label(category, group_issues.len(), input.max_comments);
render_findings_table(&mut out, group_issues, input.max_comments, &summary_label);
}
}
out.push_str("\nGenerated by fallow.");
out
}
const fn pr_comment_verdict(conclusion: ReviewCheckConclusion) -> &'static str {
match conclusion {
ReviewCheckConclusion::Failure => "Quality gate failed",
ReviewCheckConclusion::Neutral => "Review needed",
ReviewCheckConclusion::Success => "Quality gate passed",
}
}
const fn conclusion_rank(conclusion: ReviewCheckConclusion) -> u8 {
match conclusion {
ReviewCheckConclusion::Success => 0,
ReviewCheckConclusion::Neutral => 1,
ReviewCheckConclusion::Failure => 2,
}
}
const fn most_severe_conclusion(
derived: ReviewCheckConclusion,
gate: Option<ReviewCheckConclusion>,
) -> ReviewCheckConclusion {
match gate {
Some(gate) if conclusion_rank(gate) > conclusion_rank(derived) => gate,
_ => derived,
}
}
pub const PROJECT_LEVEL_RULE_IDS: &[&str] = &[
"fallow/unused-catalog-entry",
"fallow/empty-catalog-group",
"fallow/unresolved-catalog-reference",
"fallow/unused-dependency-override",
"fallow/misconfigured-dependency-override",
"fallow/unused-dependency",
"fallow/unused-dev-dependency",
"fallow/unused-optional-dependency",
"fallow/type-only-dependency",
"fallow/test-only-dependency",
"fallow/dev-dependency-in-production",
];
#[must_use]
pub fn is_project_level_rule(rule_id: &str) -> bool {
PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
}
const CATEGORY_ORDER: [&str; 10] = [
"Dead code",
"Dependencies",
"Duplication",
"Health",
"Architecture",
"Policy",
"Security",
"Flags",
"Suppressions",
"Other",
];
fn group_by_category<'a>(
issues: &'a [CiIssue],
category_for_rule: &dyn Fn(&str) -> &'static str,
) -> Vec<(&'static str, Vec<&'a CiIssue>)> {
let mut buckets: std::collections::BTreeMap<&'static str, Vec<&CiIssue>> =
std::collections::BTreeMap::new();
for issue in issues {
let category = category_for_rule(&issue.rule_id);
buckets.entry(category).or_default().push(issue);
}
let mut ordered: Vec<(&'static str, Vec<&CiIssue>)> = Vec::with_capacity(buckets.len());
for category in CATEGORY_ORDER {
if let Some(items) = buckets.remove(category) {
ordered.push((category, items));
}
}
for (category, items) in buckets {
ordered.push((category, items));
}
ordered
}
fn category_breakdown(groups: &[(&'static str, Vec<&CiIssue>)]) -> String {
groups
.iter()
.map(|(category, issues)| format!("{category} {}", issues.len()))
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn summary_label(category: &str, total: usize, max: usize) -> String {
if total > max {
format!("{category} ({total}, showing {max})")
} else {
format!("{category} ({total})")
}
}
#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
fn render_findings_table(out: &mut String, issues: &[&CiIssue], max: usize, summary: &str) {
writeln!(out, "<details>\n<summary>{summary}</summary>\n").expect("write to string");
out.push_str("| Severity | Rule | Location | Description |\n");
out.push_str("| --- | --- | --- | --- |\n");
for issue in issues.iter().take(max) {
writeln!(
out,
"| {} | `{}` | `{}`:{} | {} |",
escape_md(&issue.severity),
escape_md(&issue.rule_id),
escape_md(&issue.path),
issue.line,
escape_md(&issue.description),
)
.expect("write to string");
}
if issues.len() > max {
writeln!(
out,
"\nShowing {max} of {} findings. Run fallow locally or inspect the CI output for the full report.",
issues.len(),
)
.expect("write to string");
}
out.push_str("\n</details>\n\n");
}
#[must_use]
pub fn command_title(command: &str) -> &'static str {
match command {
"dead-code" | "check" => "codebase report",
"dupes" => "duplication report",
"health" => "health report",
"audit" => "audit report",
"security" => "security report",
"fix" => "fix report",
"" | "combined" => "combined report",
_ => "report",
}
}
const fn change_noun(provider: CiProvider) -> &'static str {
match provider {
CiProvider::Github => "pull request",
CiProvider::Gitlab => "merge request",
}
}
const fn changes_tab(provider: CiProvider) -> &'static str {
match provider {
CiProvider::Github => "Files changed",
CiProvider::Gitlab => "Changes",
}
}
#[must_use]
pub fn escape_md(value: &str) -> String {
let value = value.trim();
let mut chars = value.chars().peekable();
let mut out = String::with_capacity(value.len());
while let Some(ch) = chars.next() {
let ch = match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
' '
}
'\n' => ' ',
_ => ch,
};
if matches!(
ch,
'\\' | '`'
| '*'
| '_'
| '['
| ']'
| '('
| ')'
| '!'
| '<'
| '>'
| '#'
| '|'
| '~'
| '&'
) {
out.push('\\');
}
out.push(ch);
}
out
}
#[must_use]
pub fn markdown_code_span(value: &str) -> String {
let longest_run = value
.split(|c| c != '`')
.map(str::len)
.max()
.unwrap_or_default();
let fence = "`".repeat(longest_run + 1);
let needs_padding = value.starts_with('`')
|| value.ends_with('`')
|| (value.starts_with(' ') && value.ends_with(' ') && !value.chars().all(|c| c == ' '));
if needs_padding {
format!("{fence} {value} {fence}")
} else {
format!("{fence}{value}{fence}")
}
}
#[must_use]
pub fn markdown_table_code_span(value: &str) -> String {
let collapsed = value.replace("\r\n", " ").replace(['\n', '\r'], " ");
markdown_code_span(&collapsed.replace('|', "\\|"))
}
#[must_use]
pub fn markdown_table_text(value: &str) -> String {
value
.replace("\r\n", " ")
.replace(['\n', '\r'], " ")
.replace('|', "\\|")
}
#[must_use]
pub fn render_review_envelope(input: &ReviewEnvelopeRenderInput<'_>) -> ReviewEnvelopeRenderResult {
render_review_envelope_with_id(input, None, None, None)
}
#[must_use]
pub fn render_review_envelope_with_conclusion(
input: &ReviewEnvelopeRenderInput<'_>,
conclusion: ReviewCheckConclusion,
status_message: Option<&str>,
) -> ReviewEnvelopeRenderResult {
render_review_envelope_with_id(input, None, Some(conclusion), status_message)
}
#[must_use]
pub fn render_scoped_review_envelope(
input: &ReviewEnvelopeRenderInput<'_>,
review_id: &ReviewId,
) -> ReviewEnvelopeRenderResult {
render_review_envelope_with_id(input, Some(review_id), None, None)
}
#[must_use]
pub fn render_scoped_review_envelope_with_conclusion(
input: &ReviewEnvelopeRenderInput<'_>,
review_id: &ReviewId,
conclusion: ReviewCheckConclusion,
status_message: Option<&str>,
) -> ReviewEnvelopeRenderResult {
render_review_envelope_with_id(input, Some(review_id), Some(conclusion), status_message)
}
fn render_review_envelope_with_id(
input: &ReviewEnvelopeRenderInput<'_>,
review_id: Option<&ReviewId>,
conclusion: Option<ReviewCheckConclusion>,
status_message: Option<&str>,
) -> ReviewEnvelopeRenderResult {
let grouped = group_review_issues_by_path_line(input.issues, input.max_comments);
let comments: Vec<ReviewComment> = grouped
.groups
.iter()
.map(|group| {
render_review_comment_for_group_with_id(
&ReviewCommentRenderInput {
provider: input.provider,
group,
gitlab_diff_refs: input.gitlab_diff_refs,
diff_index: input.diff_index,
path_prefix: input.path_prefix,
include_guidance: input.include_guidance,
suggestion_block: input.suggestion_block,
guidance_block: input.guidance_block,
},
review_id,
)
})
.collect();
let conclusion = conclusion.unwrap_or_else(|| github_check_conclusion(input.issues));
let summary_text = review_summary_text(
input.command,
input.provider,
comments.len(),
status_message,
);
let summary_fp = summary_fingerprint(&summary_text);
let summary_marker = review_markers(&summary_fp, review_id);
let body = format!("{summary_text}{summary_marker}");
let summary = ReviewEnvelopeSummary {
body: body.clone(),
fingerprint: summary_fp,
};
let truncation = ReviewEnvelopeTruncation {
body: comments.iter().any(review_comment_truncated),
comment_limit: grouped.truncated,
};
ReviewEnvelopeRenderResult {
envelope: build_review_envelope_output(input.provider, body, summary, comments, conclusion),
truncation,
}
}
fn review_summary_text(
command: &str,
provider: CiProvider,
comment_count: usize,
status_message: Option<&str>,
) -> String {
let status = status_message.map_or_else(String::new, |message| format!("\n\n> {message}"));
format!(
"### Fallow {}{}\n\n{}\n\n<!-- fallow-review -->",
command_title(command),
status,
inline_comment_line(provider, comment_count),
)
}
fn inline_comment_line(provider: CiProvider, count: usize) -> String {
if count == 0 {
return format!(
"No findings anchored to the changed lines in this {}.",
change_noun(provider)
);
}
format!(
"{count} inline comment{} on the changed lines. Open the {} tab to review.",
if count == 1 { "" } else { "s" },
changes_tab(provider),
)
}
#[derive(Debug, PartialEq, Eq)]
pub struct GroupedReviewIssues<'a> {
pub groups: Vec<Vec<&'a CiIssue>>,
pub truncated: bool,
}
#[must_use]
pub fn group_review_issues_by_path_line(
issues: &[CiIssue],
max_groups: usize,
) -> GroupedReviewIssues<'_> {
if max_groups == 0 {
return GroupedReviewIssues {
groups: Vec::new(),
truncated: !issues.is_empty(),
};
}
let mut groups: Vec<Vec<&CiIssue>> = Vec::with_capacity(max_groups.min(issues.len()));
let mut current: Vec<&CiIssue> = Vec::new();
let mut current_key: Option<(&str, u64)> = None;
for issue in issues {
let key = (issue.path.as_str(), issue.line);
if Some(key) != current_key {
if !current.is_empty() {
groups.push(std::mem::take(&mut current));
if groups.len() == max_groups {
return GroupedReviewIssues {
groups,
truncated: true,
};
}
}
current_key = Some(key);
}
current.push(issue);
}
if !current.is_empty() && groups.len() < max_groups {
groups.push(current);
}
GroupedReviewIssues {
groups,
truncated: false,
}
}
fn review_comment_truncated(comment: &ReviewComment) -> bool {
match comment {
ReviewComment::GitHub(comment) => comment.truncated,
ReviewComment::GitLab(comment) => comment.truncated,
}
}
pub struct ReviewCommentRenderInput<'a, 'group> {
pub provider: CiProvider,
pub group: &'a [&'group CiIssue],
pub gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
pub diff_index: Option<&'a DiffIndex>,
pub path_prefix: &'a str,
pub include_guidance: bool,
pub suggestion_block: &'a dyn Fn(CiProvider, &CiIssue) -> Option<String>,
pub guidance_block: &'a dyn Fn(&CiIssue) -> Option<String>,
}
#[must_use]
pub fn render_review_comment_for_group(input: &ReviewCommentRenderInput<'_, '_>) -> ReviewComment {
render_review_comment_for_group_with_id(input, None)
}
fn render_review_comment_for_group_with_id(
input: &ReviewCommentRenderInput<'_, '_>,
review_id: Option<&ReviewId>,
) -> ReviewComment {
assert!(
!input.group.is_empty(),
"group_review_issues_by_path_line never yields empty"
);
let representative = input.group[0];
let fingerprint = if input.group.len() == 1 {
representative.fingerprint.clone()
} else {
let constituents: Vec<&str> = input.group.iter().map(|i| i.fingerprint.as_str()).collect();
composite_fingerprint(&constituents)
};
let content = build_merged_comment_content(input);
let marker_line = review_markers(&fingerprint, review_id);
let (body, truncated) = cap_body_with_marker(&content, &marker_line);
build_review_comment(ReviewCommentInput {
provider: input.provider,
representative,
gitlab_diff_refs: input.gitlab_diff_refs,
diff_index: input.diff_index,
path_prefix: input.path_prefix,
body,
fingerprint,
truncated,
})
}
#[expect(clippy::expect_used, reason = "formatting into String is infallible")]
fn build_merged_comment_content(input: &ReviewCommentRenderInput<'_, '_>) -> String {
let mut content = String::new();
for (index, issue) in input.group.iter().enumerate() {
let label = review_label_from_codeclimate(&issue.severity);
if index > 0 {
content.push_str("\n\n");
}
write!(
content,
"**{}** `{}`: {}",
label,
escape_md(&issue.rule_id),
escape_md(&issue.description)
)
.expect("write to String is infallible");
if !issue.other_locations.is_empty() {
content.push_str("\n\nOther locations: ");
let locations = issue
.other_locations
.iter()
.map(|location| {
markdown_code_span(&format!(
"{}:{}-{}",
apply_path_prefix(input.path_prefix, &location.path),
location.line,
location.end_line
))
})
.collect::<Vec<_>>()
.join(", ");
content.push_str(&locations);
}
if let Some(suggestion) = (input.suggestion_block)(input.provider, issue) {
content.push_str(&suggestion);
}
if input.include_guidance
&& let Some(guidance) = (input.guidance_block)(issue)
{
content.push_str(&guidance);
}
}
content
}
struct ReviewCommentInput<'a> {
provider: CiProvider,
representative: &'a CiIssue,
gitlab_diff_refs: Option<&'a ReviewGitlabDiffRefs>,
diff_index: Option<&'a DiffIndex>,
path_prefix: &'a str,
body: String,
fingerprint: String,
truncated: bool,
}
fn build_review_comment(input: ReviewCommentInput<'_>) -> ReviewComment {
let ReviewCommentInput {
provider,
representative,
gitlab_diff_refs,
diff_index,
path_prefix,
body,
fingerprint,
truncated,
} = input;
match provider {
CiProvider::Github => ReviewComment::GitHub(GitHubReviewComment {
path: apply_path_prefix(path_prefix, &representative.path),
line: u32::try_from(representative.line).unwrap_or(u32::MAX),
side: GitHubReviewSide::Right,
body,
fingerprint,
truncated,
}),
CiProvider::Gitlab => {
let old_rel = diff_index
.and_then(|di| di.old_path_for_root_relative(&representative.path))
.map_or_else(|| representative.path.clone(), Cow::into_owned);
let new_path = apply_path_prefix(path_prefix, &representative.path);
let old_path = apply_path_prefix(path_prefix, &old_rel);
let position = GitLabReviewPosition {
base_sha: gitlab_diff_refs.map(|r| r.base_sha.clone()),
start_sha: gitlab_diff_refs.map(|r| r.start_sha.clone()),
head_sha: gitlab_diff_refs.map(|r| r.head_sha.clone()),
position_type: GitLabReviewPositionType::Text,
old_path,
new_path,
new_line: u32::try_from(representative.line).unwrap_or(u32::MAX),
};
ReviewComment::GitLab(GitLabReviewComment {
body,
position,
fingerprint,
truncated,
})
}
}
}
#[must_use]
pub fn cap_body_with_marker(content: &str, marker_line: &str) -> (String, bool) {
let intact_len = content.len() + marker_line.len();
if intact_len <= MAX_COMMENT_BODY_BYTES {
let mut out = String::with_capacity(intact_len);
out.push_str(content);
out.push_str(marker_line);
return (out, false);
}
let reserved = marker_line.len() + TRUNCATION_SUFFIX.len();
let budget = MAX_COMMENT_BODY_BYTES.saturating_sub(reserved);
let mut cut = budget.min(content.len());
while cut > 0 && !content.is_char_boundary(cut) {
cut -= 1;
}
let mut out = String::with_capacity(MAX_COMMENT_BODY_BYTES);
out.push_str(&content[..cut]);
out.push_str(TRUNCATION_SUFFIX);
out.push_str(marker_line);
(out, true)
}
#[must_use]
pub const fn review_label_from_codeclimate(severity_name: &str) -> &'static str {
match severity_name.as_bytes() {
b"major" | b"critical" | b"blocker" => "error",
_ => "warn",
}
}
#[must_use]
pub fn github_check_conclusion(issues: &[CiIssue]) -> ReviewCheckConclusion {
if issues
.iter()
.any(|issue| matches!(issue.severity.as_str(), "major" | "critical" | "blocker"))
{
ReviewCheckConclusion::Failure
} else if issues.is_empty() {
ReviewCheckConclusion::Success
} else {
ReviewCheckConclusion::Neutral
}
}
fn build_review_envelope_output(
provider: CiProvider,
body: String,
summary: ReviewEnvelopeSummary,
comments: Vec<ReviewComment>,
conclusion: ReviewCheckConclusion,
) -> ReviewEnvelopeOutput {
match provider {
CiProvider::Github => ReviewEnvelopeOutput {
event: Some(ReviewEnvelopeEvent::Comment),
body,
summary,
comments,
marker_regex: default_marker_regex(),
marker_regex_flags: default_marker_regex_flags(),
meta: ReviewEnvelopeMeta {
schema: ReviewEnvelopeSchema::V3,
provider: ReviewProvider::Github,
check_conclusion: Some(conclusion),
},
},
CiProvider::Gitlab => ReviewEnvelopeOutput {
event: None,
body,
summary,
comments,
marker_regex: default_marker_regex(),
marker_regex_flags: default_marker_regex_flags(),
meta: ReviewEnvelopeMeta {
schema: ReviewEnvelopeSchema::V3,
provider: ReviewProvider::Gitlab,
check_conclusion: None,
},
},
}
}
fn review_markers(fingerprint: &str, review_id: Option<&ReviewId>) -> String {
let fingerprint = format!("\n\n{MARKER_PREFIX_V2}{fingerprint}{MARKER_SUFFIX_V2}");
match review_id {
Some(review_id) => format!("{fingerprint}\n{}", review_id_marker(review_id)),
None => fingerprint,
}
}
#[must_use]
pub fn summary_fingerprint(body: &str) -> String {
fingerprint_hash(&[body])
}
#[must_use]
pub fn composite_fingerprint(constituents: &[&str]) -> String {
let mut sorted: Vec<&str> = constituents.to_vec();
sorted.sort_unstable();
let joined = sorted.join(":");
format!("merged:{}", fingerprint_hash(&[joined.as_str()]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CodeClimateIssueKind, CodeClimateLines, CodeClimateLocation};
fn category_for_rule(rule_id: &str) -> &'static str {
match rule_id {
"fallow/code-duplication" => "Duplication",
"fallow/high-complexity" => "Health",
"fallow/unused-dependency" => "Dependencies",
_ => "Dead code",
}
}
#[test]
fn extracts_issues_from_codeclimate() {
let value = serde_json::json!([{
"check_name": "fallow/unused-export",
"description": "Export x is never imported",
"severity": "minor",
"fingerprint": "abc",
"location": { "path": "src/a.ts", "lines": { "begin": 7 } }
}]);
let issues = issues_from_codeclimate(&value);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].path, "src/a.ts");
assert_eq!(issues[0].line, 7);
}
#[test]
fn typed_codeclimate_issues_extract_like_json_codeclimate() {
let severities = [
(CodeClimateSeverity::Info, "info"),
(CodeClimateSeverity::Minor, "minor"),
(CodeClimateSeverity::Major, "major"),
(CodeClimateSeverity::Critical, "critical"),
(CodeClimateSeverity::Blocker, "blocker"),
];
let typed = severities
.iter()
.enumerate()
.map(|(index, (severity, _))| CodeClimateIssue {
kind: CodeClimateIssueKind::Issue,
check_name: format!("fallow/rule-{index}"),
description: format!("Finding {index}"),
categories: vec!["Complexity".to_owned()],
severity: *severity,
fingerprint: format!("fp-{index}"),
location: CodeClimateLocation {
path: format!("src/{index}.ts"),
lines: CodeClimateLines {
begin: u32::try_from(index + 1).expect("small fixture index"),
end: Some(u32::try_from(index + 3).expect("small fixture index")),
},
},
other_locations: vec![CodeClimateLocation {
path: format!("src/peer-{index}.ts"),
lines: CodeClimateLines {
begin: 20,
end: Some(24),
},
}],
owner: None,
group: None,
})
.collect::<Vec<_>>();
let value = serde_json::to_value(&typed).expect("typed fixture serializes");
assert_eq!(
issues_from_codeclimate_issues(&typed),
issues_from_codeclimate(&value)
);
let normalized = issues_from_codeclimate_issues(&typed);
assert_eq!(normalized[0].end_line, Some(3));
assert_eq!(normalized[0].other_locations[0].path, "src/peer-0.ts");
assert_eq!(normalized[0].other_locations[0].line, 20);
assert_eq!(normalized[0].other_locations[0].end_line, 24);
let typed_labels = issues_from_codeclimate_issues(&typed)
.into_iter()
.map(|issue| issue.severity)
.collect::<Vec<_>>();
let expected_labels = severities
.iter()
.map(|(_, label)| (*label).to_owned())
.collect::<Vec<_>>();
assert_eq!(typed_labels, expected_labels);
}
#[test]
fn review_comment_renders_repository_prefixed_peer_ranges() {
let issue = CiIssue {
rule_id: "fallow/code-duplication".to_owned(),
description: "Code clone dup:abcd1234 (11 lines, 2 instances)".to_owned(),
severity: "minor".to_owned(),
path: "src/a.ts".to_owned(),
line: 5,
end_line: Some(15),
other_locations: vec![CiLocation {
path: "src/b.ts".to_owned(),
line: 30,
end_line: 40,
}],
fingerprint: "instance-fingerprint".to_owned(),
};
let comment = render_review_comment_for_group(&ReviewCommentRenderInput {
provider: CiProvider::Gitlab,
group: &[&issue],
gitlab_diff_refs: None,
diff_index: None,
path_prefix: "packages/app",
include_guidance: false,
suggestion_block: &|_, _| None,
guidance_block: &|_| None,
});
let ReviewComment::GitLab(comment) = comment else {
panic!("expected GitLab comment");
};
assert_eq!(comment.position.new_path, "packages/app/src/a.ts");
assert!(
comment
.body
.contains("Other locations: `packages/app/src/b.ts:30-40`")
);
}
#[test]
fn renders_default_empty_comment() {
let body = render_pr_comment(&PrCommentRenderInput {
command: "check",
provider: CiProvider::Github,
issues: &[],
marker_id: "fallow-results".to_owned(),
max_comments: 50,
category_for_rule: &category_for_rule,
});
assert!(body.contains("<!-- fallow-id: fallow-results"));
assert!(body.contains("No findings for this pull request."));
}
fn pr_comment_issue(rule_id: &str, description: &str, severity: &str, path: &str) -> CiIssue {
CiIssue {
rule_id: rule_id.to_owned(),
description: description.to_owned(),
severity: severity.to_owned(),
path: path.to_owned(),
line: 3,
end_line: None,
other_locations: Vec::new(),
fingerprint: path.to_owned(),
}
}
#[test]
fn pr_comment_titles_by_content_and_names_its_only_category() {
let issues = vec![
pr_comment_issue(
"fallow/unresolved-import",
"Import './x' could not be resolved",
"major",
"src/a.ts",
),
pr_comment_issue(
"fallow/unresolved-import",
"Import './y' could not be resolved",
"major",
"src/b.ts",
),
];
let body = render_pr_comment(&PrCommentRenderInput {
command: "dead-code",
provider: CiProvider::Github,
issues: &issues,
marker_id: "fallow-results".to_owned(),
max_comments: 50,
category_for_rule: &category_for_rule,
});
assert!(body.contains("### Fallow codebase report"), "{body}");
assert!(body.contains("**Quality gate failed**"), "{body}");
assert!(body.contains("Found **2** findings."), "{body}");
assert!(body.contains("<summary>Dead code (2)</summary>"), "{body}");
}
#[test]
fn pr_comment_breakdown_indexes_several_categories() {
let issues = vec![
pr_comment_issue(
"fallow/unresolved-import",
"Import './x' could not be resolved",
"major",
"src/a.ts",
),
pr_comment_issue(
"fallow/unused-dependency",
"Package 'lodash' is never imported",
"minor",
"package.json",
),
];
let body = render_pr_comment(&PrCommentRenderInput {
command: "check",
provider: CiProvider::Github,
issues: &issues,
marker_id: "fallow-results".to_owned(),
max_comments: 50,
category_for_rule: &category_for_rule,
});
assert!(
body.contains("Found **2** findings: Dead code 1, Dependencies 1."),
"{body}"
);
}
#[test]
fn pr_comment_empty_state_speaks_the_provider_language() {
let github = render_pr_comment(&PrCommentRenderInput {
command: "dead-code",
provider: CiProvider::Github,
issues: &[],
marker_id: "fallow-results".to_owned(),
max_comments: 50,
category_for_rule: &category_for_rule,
});
let gitlab = render_pr_comment(&PrCommentRenderInput {
command: "dead-code",
provider: CiProvider::Gitlab,
issues: &[],
marker_id: "fallow-results".to_owned(),
max_comments: 50,
category_for_rule: &category_for_rule,
});
assert!(
github.contains("No findings for this pull request."),
"{github}"
);
assert!(
gitlab.contains("No findings for this merge request."),
"{gitlab}"
);
assert!(
github.starts_with("<!-- fallow-id: fallow-results -->\n"),
"{github}"
);
assert!(github.contains("Generated by fallow."), "{github}");
}
#[test]
fn escape_md_escapes_inline_commonmark_specials() {
let raw = "foo*bar_baz [a](u) `c` <h> #x !i ~s | p";
let escaped = escape_md(raw);
for ch in [
'*', '_', '[', ']', '(', ')', '`', '<', '>', '#', '!', '~', '|',
] {
let raw_count = raw.chars().filter(|c| c == &ch).count();
let escaped_count = escaped.matches(&format!("\\{ch}")).count();
assert_eq!(
raw_count, escaped_count,
"char {ch:?}: raw {raw_count} occurrences, escaped {escaped_count} in {escaped:?}"
);
}
}
#[test]
fn escape_md_escapes_ampersand_to_block_numeric_entity_bypass() {
let raw = "value *suspicious* here";
let escaped = escape_md(raw);
assert!(escaped.contains(r"\&"), "got: {escaped}");
assert!(escaped.contains(r"\#"), "got: {escaped}");
assert!(!escaped.contains(" *suspicious"), "got: {escaped}");
}
#[test]
fn summary_label_foreshadows_truncation() {
assert_eq!(
summary_label("Duplication", 160, 50),
"Duplication (160, showing 50)"
);
assert_eq!(summary_label("Health", 12, 50), "Health (12)");
assert_eq!(summary_label("Dependencies", 50, 50), "Dependencies (50)");
}
#[test]
fn escape_md_does_not_escape_block_only_markers() {
let raw = "fallow/test-only-dependency package.json:12";
let escaped = escape_md(raw);
assert!(!escaped.contains("\\-"), "should not escape `-`");
assert!(!escaped.contains("\\."), "should not escape `.`");
assert_eq!(escaped, raw);
}
#[test]
fn escape_md_collapses_newlines_to_spaces() {
let raw = "first\nsecond\nthird";
assert_eq!(escape_md(raw), "first second third");
}
#[test]
fn escape_md_collapses_carriage_returns_to_spaces() {
assert_eq!(escape_md("first\r\nsecond\rthird"), "first second third");
}
#[test]
fn escape_md_trims_surrounding_whitespace() {
assert_eq!(escape_md(" a\u{2003}\r\n"), "a");
assert_eq!(escape_md(" \t\r\n\u{2003} "), "");
}
#[test]
fn escape_md_collapses_crlf_to_one_space() {
let collapsed = escape_md("a\r\nb\rc\nd");
assert_eq!(collapsed, "a b c d");
assert_eq!(collapsed.len(), 7, "CRLF must not expand to two spaces");
}
#[test]
fn escape_md_preserves_interior_tabs_and_wide_spaces() {
assert_eq!(escape_md("a\tb"), "a\tb");
assert_eq!(escape_md("a\u{2003}b"), "a\u{2003}b");
}
#[test]
fn escape_md_passes_non_ascii_through_unchanged() {
assert_eq!(escape_md("é🦀"), "é🦀");
}
#[test]
fn markdown_code_span_grows_fence_past_inner_backticks() {
assert_eq!(markdown_code_span("plain"), "`plain`");
assert_eq!(markdown_code_span("has`tick"), "``has`tick``");
assert_eq!(markdown_code_span("`leading"), "`` `leading ``");
}
#[test]
fn markdown_table_code_span_escapes_pipes() {
assert_eq!(markdown_table_code_span("a|b"), "`a\\|b`");
assert_eq!(markdown_table_code_span("x`|y"), "``x`\\|y``");
}
#[test]
fn markdown_table_code_span_collapses_line_endings() {
assert_eq!(markdown_table_code_span("a\r\nb\rc\nd"), "`a b c d`");
}
#[test]
fn markdown_table_text_neutralizes_pipes_and_line_endings() {
assert_eq!(markdown_table_text("a|b"), "a\\|b");
assert_eq!(markdown_table_text("a\r\nb\rc\nd"), "a b c d");
}
#[test]
fn escape_md_leaves_safe_chars_unchanged() {
let raw = "Export 'helperFn' is never imported by other modules";
assert_eq!(
escape_md(raw),
r"Export 'helperFn' is never imported by other modules"
);
}
#[test]
fn is_project_level_rule_covers_config_anchored_dependency_findings() {
for rule_id in PROJECT_LEVEL_RULE_IDS {
assert!(
is_project_level_rule(rule_id),
"{rule_id} must be project-level"
);
}
for rule_id in [
"fallow/unused-file",
"fallow/unused-export",
"fallow/unused-type",
"fallow/unused-enum-member",
"fallow/unused-class-member",
"fallow/unused-store-member",
"fallow/unresolved-import",
"fallow/unlisted-dependency",
"fallow/duplicate-export",
"fallow/circular-dependency",
"fallow/re-export-cycle",
"fallow/boundary-violation",
"fallow/stale-suppression",
"fallow/private-type-leak",
"fallow/high-complexity",
"fallow/high-crap-score",
] {
assert!(
!is_project_level_rule(rule_id),
"{rule_id} must NOT be project-level"
);
}
}
#[test]
fn escape_md_double_apply_is_safe() {
let raw = "code with `backticks` and *stars*";
let once = escape_md(raw);
let twice = escape_md(&once);
assert_eq!(once, r"code with \`backticks\` and \*stars\*");
assert_eq!(twice, r"code with \\\`backticks\\\` and \\\*stars\\\*");
}
}