use std::fmt::Write as _;
use std::process::ExitCode;
use std::sync::OnceLock;
use serde_json::Value;
static WORKSPACE_MARKER: OnceLock<String> = OnceLock::new();
#[allow(
dead_code,
reason = "called from main.rs bin target; lib target sees no caller"
)]
pub fn set_workspace_marker_from_list(values: &[String]) {
let trimmed: Vec<&str> = values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.collect();
if trimmed.is_empty() {
return;
}
let marker = if let [single] = trimmed.as_slice() {
(*single).to_owned()
} else {
let mut sorted = trimmed.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
sorted.sort();
let joined = sorted.join(",");
format!("w-{}", short_hex_hash(&joined))
};
let _ = WORKSPACE_MARKER.set(marker);
}
fn short_hex_hash(value: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in value.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
format!("{:06x}", (hash & 0x00ff_ffff) as u32)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Provider {
Github,
Gitlab,
}
impl Provider {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Github => "GitHub",
Self::Gitlab => "GitLab",
}
}
}
#[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 fingerprint: String,
}
#[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<_>>();
issues
.sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
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);
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,
})
}
#[must_use]
pub fn render_pr_comment(command: &str, provider: Provider, issues: &[CiIssue]) -> String {
let marker_id = sticky_marker_id();
let marker = format!("<!-- fallow-id: {marker_id} -->");
let max = max_comments();
let title = command_title(command);
let count = issues.len();
let noun = if count == 1 { "finding" } else { "findings" };
let mut out = String::new();
out.push_str(&marker);
out.push('\n');
write!(&mut out, "### Fallow {title}\n\n").expect("write to string");
if count == 0 {
writeln!(
&mut out,
"No {provider} PR/MR findings.",
provider = provider.name()
)
.expect("write to string");
} else {
write!(&mut out, "Found **{count}** {noun}.\n\n").expect("write to string");
let groups = group_by_category(issues);
if groups.len() == 1 {
render_findings_table(&mut out, issues, max, "Details");
} else {
for (category, group_issues) in &groups {
let summary_label = summary_label(category, group_issues.len(), max);
render_findings_table(&mut out, group_issues, max, &summary_label);
}
}
}
out.push_str("\nGenerated by fallow.");
out
}
fn summary_label(category: &str, total: usize, max: usize) -> String {
if total > max {
format!("{category} ({total}, showing {max})")
} else {
format!("{category} ({total})")
}
}
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 category_for_rule(rule_id: &str) -> &'static str {
crate::explain::rule_by_id(rule_id).map_or("Dead code", |def| def.category)
}
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",
];
#[must_use]
pub fn is_project_level_rule(rule_id: &str) -> bool {
PROJECT_LEVEL_RULE_IDS.contains(&rule_id)
}
const CATEGORY_ORDER: [&str; 6] = [
"Dead code",
"Dependencies",
"Duplication",
"Health",
"Architecture",
"Suppressions",
];
fn group_by_category(issues: &[CiIssue]) -> Vec<(&'static str, Vec<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.clone());
}
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 max_comments() -> usize {
std::env::var("FALLOW_MAX_COMMENTS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(50)
}
fn sticky_marker_id() -> String {
if let Ok(value) = std::env::var("FALLOW_COMMENT_ID")
&& !value.trim().is_empty()
{
return value;
}
let suffix = WORKSPACE_MARKER
.get()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(sanitize_marker_segment);
match suffix {
Some(workspace) => format!("fallow-results-{workspace}"),
None => "fallow-results".to_owned(),
}
}
fn sanitize_marker_segment(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_owned()
}
#[must_use]
pub fn print_pr_comment(command: &str, provider: Provider, codeclimate: &Value) -> ExitCode {
let issues =
super::diff_filter::filter_issues_for_summary(issues_from_codeclimate(codeclimate));
println!("{}", render_pr_comment(command, provider, &issues));
ExitCode::SUCCESS
}
#[must_use]
pub fn command_title(command: &str) -> &'static str {
match command {
"dead-code" | "check" => "dead-code report",
"dupes" => "duplication report",
"health" => "health report",
"audit" => "audit report",
"" | "combined" => "combined report",
_ => "report",
}
}
#[must_use]
pub fn escape_md(value: &str) -> String {
let collapsed = value.replace('\n', " ");
let mut out = String::with_capacity(collapsed.len());
for ch in collapsed.chars() {
if matches!(
ch,
'\\' | '`'
| '*'
| '_'
| '['
| ']'
| '('
| ')'
| '!'
| '<'
| '>'
| '#'
| '|'
| '~'
| '&'
) {
out.push('\\');
}
out.push(ch);
}
out.trim().to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[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 sticky_marker_id_default_when_nothing_set() {
let body = render_pr_comment("check", Provider::Github, &[]);
assert!(body.contains("<!-- fallow-id: fallow-results"));
assert!(body.contains("No GitHub PR/MR findings."));
}
#[test]
fn short_hex_hash_is_deterministic_and_six_chars() {
let a = short_hex_hash("api,worker");
assert_eq!(a.len(), 6);
assert_eq!(a, short_hex_hash("api,worker"));
assert_ne!(a, short_hex_hash("admin,web"));
}
#[test]
fn sanitize_marker_segment_collapses_unsafe_chars_to_dashes() {
assert_eq!(sanitize_marker_segment("@fallow/runtime"), "fallow-runtime");
assert_eq!(
sanitize_marker_segment("packages/web ui"),
"packages-web-ui"
);
assert_eq!(sanitize_marker_segment("plain"), "plain");
assert_eq!(
sanitize_marker_segment("--leading-trailing--"),
"leading-trailing"
);
}
#[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_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/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 project_level_rule_ids_each_register_in_explain_registry() {
for rule_id in PROJECT_LEVEL_RULE_IDS {
assert!(
crate::explain::rule_by_id(rule_id).is_some(),
"{rule_id} listed in PROJECT_LEVEL_RULE_IDS but not in explain registry"
);
}
}
#[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!(twice.contains(r"\\"));
}
}