use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::audit_keys::{health_finding_key, remap_key_for_renames};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComplexityMetrics {
pub line: u32,
pub cyclomatic: u16,
pub cognitive: u16,
pub crap: Option<f64>,
}
impl ComplexityMetrics {
#[must_use]
pub const fn of(finding: &fallow_output::ComplexityViolation) -> Self {
Self {
line: finding.line,
cyclomatic: finding.cyclomatic,
cognitive: finding.cognitive,
crap: finding.crap,
}
}
fn same_values(&self, other: &Self) -> bool {
self.cyclomatic == other.cyclomatic
&& self.cognitive == other.cognitive
&& self.crap.map(f64::to_bits) == other.crap.map(f64::to_bits)
}
}
pub type ComplexityBaseline = FxHashMap<String, Vec<ComplexityMetrics>>;
#[must_use]
pub fn complexity_baseline(
report: &fallow_output::HealthReport,
root: &Path,
) -> ComplexityBaseline {
let mut baseline = ComplexityBaseline::default();
for finding in &report.findings {
baseline
.entry(health_finding_key(finding, root))
.or_default()
.push(ComplexityMetrics::of(finding));
}
sort_by_line(&mut baseline);
baseline
}
#[must_use]
pub fn complexity_baseline_keys(baseline: &ComplexityBaseline) -> FxHashSet<String> {
baseline.keys().cloned().collect()
}
#[must_use]
#[expect(
clippy::implicit_hasher,
reason = "fallow standardizes on FxHashMap across audit attribution keys"
)]
pub fn remap_complexity_baseline_for_renames(
baseline: ComplexityBaseline,
renames: &FxHashMap<String, String>,
) -> ComplexityBaseline {
let mut remapped = ComplexityBaseline::default();
for (key, findings) in baseline {
remapped
.entry(remap_key_for_renames(&key, renames))
.or_default()
.extend(findings);
}
sort_by_line(&mut remapped);
remapped
}
fn sort_by_line(baseline: &mut ComplexityBaseline) {
for findings in baseline.values_mut() {
findings.sort_by_key(|metrics| metrics.line);
}
}
fn exceeded_metric_increased(
head: &fallow_output::ComplexityViolation,
base: &ComplexityMetrics,
) -> bool {
let exceeded = head.exceeded;
(exceeded.includes_cyclomatic() && head.cyclomatic > base.cyclomatic)
|| (exceeded.includes_cognitive() && head.cognitive > base.cognitive)
|| (exceeded.includes_crap()
&& match (head.crap, base.crap) {
(Some(head), Some(base)) => head > base,
(Some(_), None) => true,
(None, _) => false,
})
}
#[must_use]
pub fn classify_complexity_findings(
findings: &[fallow_output::HealthFinding],
root: &Path,
base: &ComplexityBaseline,
) -> Vec<bool> {
let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
for (index, finding) in findings.iter().enumerate() {
groups
.entry(health_finding_key(finding, root))
.or_default()
.push(index);
}
let mut introduced = vec![true; findings.len()];
for (key, mut heads) in groups {
let Some(bases) = base.get(&key) else {
continue;
};
heads.sort_by_key(|&index| (findings[index].line, findings[index].col));
let mut unmatched_bases: Vec<Option<&ComplexityMetrics>> = bases.iter().map(Some).collect();
let mut unmatched_heads = Vec::new();
for index in heads {
let head = ComplexityMetrics::of(&findings[index]);
let same = unmatched_bases
.iter_mut()
.find(|slot| slot.is_some_and(|base| base.same_values(&head)));
match same {
Some(slot) => {
*slot = None;
introduced[index] = false;
}
None => unmatched_heads.push(index),
}
}
let mut remaining_bases = unmatched_bases.into_iter().flatten();
for index in unmatched_heads {
if let Some(base) = remaining_bases.next() {
introduced[index] = exceeded_metric_increased(&findings[index], base);
}
}
}
introduced
}
#[must_use]
pub fn complexity_ledger_keys(
findings: &[fallow_output::HealthFinding],
root: &Path,
) -> Vec<String> {
let mut seen: FxHashMap<String, usize> = FxHashMap::default();
findings
.iter()
.map(|finding| {
let key = health_finding_key(finding, root);
let occurrence = seen.entry(key.clone()).or_default();
*occurrence += 1;
if *occurrence == 1 {
key
} else {
format!("{key}#{occurrence}")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_output::{
ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
};
use super::*;
const MAX: u16 = 15;
fn root() -> PathBuf {
PathBuf::from("/project")
}
fn finding(name: &str, line: u32, cyclomatic: u16, cognitive: u16) -> HealthFinding {
HealthFinding::from(ComplexityViolation {
path: root().join("src/index.js"),
name: name.to_string(),
line,
col: 0,
cyclomatic,
cognitive,
line_count: 30,
param_count: 1,
react_hook_count: 0,
react_jsx_max_depth: 0,
react_prop_count: 0,
react_hook_profile: None,
exceeded: ExceededThreshold::from_bools(cyclomatic > MAX, cognitive > MAX, false),
effective_severity: None,
severity: FindingSeverity::High,
crap: None,
coverage_pct: None,
coverage_tier: None,
coverage_source: None,
inherited_from: None,
component_rollup: None,
contributions: Vec::new(),
effective_thresholds: None,
threshold_source: None,
})
}
fn baseline(findings: Vec<HealthFinding>) -> ComplexityBaseline {
complexity_baseline(
&HealthReport {
findings,
..HealthReport::default()
},
&root(),
)
}
fn classify(base: Vec<HealthFinding>, head: &[HealthFinding]) -> Vec<bool> {
classify_complexity_findings(head, &root(), &baseline(base))
}
#[test]
fn identity_key_excludes_category_and_line() {
let key = health_finding_key(&finding("hotspot", 4, 17, 16), &root());
assert_eq!(key, "complexity:src/index.js:hotspot");
}
#[test]
fn issue_cases() {
assert_eq!(
classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 17, 16)]),
[false]
);
assert_eq!(
classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 18, 17)]),
[true]
);
assert_eq!(
classify(vec![finding("f", 1, 18, 17)], &[finding("f", 1, 17, 16)]),
[false]
);
assert_eq!(
classify(vec![finding("f", 1, 17, 16)], &[finding("f", 1, 16, 15)]),
[false]
);
assert_eq!(classify(vec![], &[finding("f", 1, 17, 16)]), [true]);
}
#[test]
fn only_exceeded_metrics_can_introduce() {
assert_eq!(
classify(vec![finding("f", 1, 20, 5)], &[finding("f", 1, 20, 9)]),
[false]
);
assert_eq!(
classify(vec![finding("f", 1, 20, 14)], &[finding("f", 1, 19, 16)]),
[true]
);
}
#[test]
fn line_shift_keeps_the_match() {
assert_eq!(
classify(vec![finding("f", 1, 17, 16)], &[finding("f", 40, 17, 16)]),
[false]
);
}
#[test]
fn crap_without_base_value_counts_as_increase() {
let mut head = finding("f", 1, 16, 5);
head.violation.exceeded = ExceededThreshold::Crap;
head.violation.crap = Some(50.0);
let mut base = finding("f", 1, 16, 5);
base.violation.exceeded = ExceededThreshold::Crap;
assert_eq!(classify(vec![base.clone()], &[head.clone()]), [true]);
base.violation.crap = Some(60.0);
assert_eq!(classify(vec![base], &[head]), [false]);
}
#[test]
fn same_named_findings_match_unchanged_values_first() {
let base = vec![finding("run", 1, 17, 16), finding("run", 30, 21, 20)];
assert_eq!(
classify(
base.clone(),
&[finding("run", 1, 17, 16), finding("run", 30, 21, 20)]
),
[false, false]
);
assert_eq!(
classify(base.clone(), &[finding("run", 1, 21, 20)]),
[false]
);
assert_eq!(
classify(
base.clone(),
&[
finding("run", 1, 30, 30),
finding("run", 20, 17, 16),
finding("run", 50, 21, 20)
]
),
[true, false, false]
);
assert_eq!(
classify(
base,
&[finding("run", 1, 18, 17), finding("run", 30, 21, 20)]
),
[true, false]
);
}
#[test]
fn rename_moves_the_base_findings() {
let base = baseline(vec![finding("f", 1, 17, 16)]);
let renames =
FxHashMap::from_iter([("src/index.js".to_string(), "src/moved.js".to_string())]);
let remapped = remap_complexity_baseline_for_renames(base, &renames);
assert!(remapped.contains_key("complexity:src/moved.js:f"));
assert!(!remapped.contains_key("complexity:src/index.js:f"));
}
#[test]
fn ledger_keys_count_same_named_findings_separately() {
let keys = complexity_ledger_keys(
&[finding("run", 1, 17, 16), finding("run", 30, 17, 16)],
&root(),
);
assert_eq!(
keys,
[
"complexity:src/index.js:run",
"complexity:src/index.js:run#2"
]
);
}
}