use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::baseline::Baseline;
use crate::rules::boundaries::CrateProfile;
use crate::finding::{Finding, Severity};
use crate::ingest::{CrateInfo, SourceFile, Workspace};
pub const FAIL_WEIGHT: f64 = 10.0;
pub const WARN_WEIGHT: f64 = 3.0;
pub const SCORE_FORMULA_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Grade {
A,
B,
C,
D,
F,
}
impl Grade {
fn from_score(score: f64) -> Self {
if score >= 90.0 {
Self::A
} else if score >= 80.0 {
Self::B
} else if score >= 70.0 {
Self::C
} else if score >= 60.0 {
Self::D
} else {
Self::F
}
}
pub const fn label(self) -> &'static str {
match self {
Self::A => "A",
Self::B => "B",
Self::C => "C",
Self::D => "D",
Self::F => "F",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "f64", into = "f64")]
pub struct DeductionMultiplier(f64);
impl DeductionMultiplier {
pub const MAX: f64 = 10.0;
pub fn value(self) -> f64 {
self.0
}
}
impl Default for DeductionMultiplier {
fn default() -> Self {
Self(1.0)
}
}
impl TryFrom<f64> for DeductionMultiplier {
type Error = String;
fn try_from(value: f64) -> Result<Self, Self::Error> {
if value.is_finite() && value > 0.0 && value <= Self::MAX {
Ok(Self(value))
} else {
Err(format!(
"deduction_multiplier must be finite and within (0.0, {}], got {value}",
Self::MAX
))
}
}
}
impl From<DeductionMultiplier> for f64 {
fn from(multiplier: DeductionMultiplier) -> Self {
multiplier.0
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthScore {
pub score: f64,
pub grade: Grade,
pub total_loc: usize,
pub fail_count: usize,
pub warn_count: usize,
pub deduction: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoreReason {
NoAuthoredLoc,
}
impl std::fmt::Display for ScoreReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoAuthoredLoc => {
write!(
f,
"no authored lines of code were analyzed (nothing to score)"
)
}
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoreOutcome {
Available(HealthScore),
Unavailable(ScoreReason),
}
fn score_from(
fail_count: usize,
warn_count: usize,
deduction: f64,
total_loc: usize,
) -> HealthScore {
let density_deduction = deduction / (total_loc as f64 / 1000.0);
let score = (100.0 - density_deduction).clamp(0.0, 100.0);
HealthScore {
score,
grade: Grade::from_score(score),
total_loc,
fail_count,
warn_count,
deduction: density_deduction,
}
}
#[derive(Debug)]
pub struct LocError {
pub path: PathBuf,
pub source: std::io::Error,
}
impl std::fmt::Display for LocError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path.display(), self.source)
}
}
impl std::error::Error for LocError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
pub fn total_authored_loc(workspace: &Workspace) -> usize {
count_loc(authored_source_files(workspace))
}
pub fn total_authored_loc_checked(workspace: &Workspace) -> Result<usize, LocError> {
authored_source_files(workspace)
.map(|file| {
std::fs::read_to_string(&file.path)
.map(|content| content.lines().count())
.map_err(|err| LocError {
path: file.path.clone(),
source: err,
})
})
.sum()
}
pub fn authored_loc_in(workspace: &Workspace, files: &HashSet<PathBuf>) -> usize {
count_loc(authored_source_files(workspace).filter(|file| {
file.path
.strip_prefix(&workspace.root)
.is_ok_and(|relative| files.contains(relative))
}))
}
fn authored_source_files(workspace: &Workspace) -> impl Iterator<Item = &SourceFile> {
workspace
.crates
.iter()
.flat_map(|krate| krate.source_files.iter())
.filter(|file| file.kind.is_locally_reportable())
}
fn count_loc<'a>(files: impl Iterator<Item = &'a SourceFile>) -> usize {
files
.filter_map(|file| std::fs::read_to_string(&file.path).ok())
.map(|content| content.lines().count())
.sum()
}
fn crate_for_file<'a>(workspace: &'a Workspace, file: &Path) -> Option<&'a CrateInfo> {
workspace
.crates
.iter()
.filter(|krate| file.starts_with(&krate.root))
.max_by_key(|krate| krate.root.as_os_str().len())
}
fn multiplier_for(
workspace: &Workspace,
file: &Path,
crate_profiles: &[CrateProfile],
) -> DeductionMultiplier {
crate_for_file(workspace, file)
.and_then(|krate| {
crate_profiles
.iter()
.find(|profile| profile.crates.iter().any(|name| name == &krate.name))
})
.map_or_else(DeductionMultiplier::default, |profile| {
profile.deduction_multiplier
})
}
pub fn compute(
findings: &[Finding],
total_loc: usize,
workspace: &Workspace,
crate_profiles: &[CrateProfile],
) -> ScoreOutcome {
if total_loc == 0 {
return ScoreOutcome::Unavailable(ScoreReason::NoAuthoredLoc);
}
let (fail_count, warn_count, deduction) = tally_deductions(
findings
.iter()
.filter(|finding| finding.is_gating())
.map(|finding| {
(
finding.severity,
multiplier_for(workspace, &finding.location.file, crate_profiles),
)
}),
);
ScoreOutcome::Available(score_from(fail_count, warn_count, deduction, total_loc))
}
fn score_severity(severity: Severity, fail_count: &mut usize, warn_count: &mut usize) -> Option<f64> {
match severity {
Severity::Fail => {
*fail_count += 1;
Some(FAIL_WEIGHT)
}
Severity::Warn => {
*warn_count += 1;
Some(WARN_WEIGHT)
}
Severity::Info => None,
}
}
fn tally_deductions(
entries: impl Iterator<Item = (Severity, DeductionMultiplier)>,
) -> (usize, usize, f64) {
let mut fail_count = 0;
let mut warn_count = 0;
let mut deduction = 0.0;
for (severity, multiplier) in entries {
let Some(weight) = score_severity(severity, &mut fail_count, &mut warn_count) else {
continue;
};
deduction += weight * multiplier.value();
}
(fail_count, warn_count, deduction)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScoreContext {
pub formula_version: u32,
pub profile_multipliers: BTreeMap<String, DeductionMultiplier>,
}
impl ScoreContext {
pub fn from_profiles(crate_profiles: &[CrateProfile]) -> Self {
let mut profile_multipliers = BTreeMap::new();
for profile in crate_profiles {
for name in &profile.crates {
profile_multipliers
.entry(name.clone())
.or_insert(profile.deduction_multiplier);
}
}
profile_multipliers.retain(|_, multiplier| multiplier.value() != 1.0);
Self {
formula_version: SCORE_FORMULA_VERSION,
profile_multipliers,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotComparableReason {
MissingScoreContext,
FormulaVersionChanged,
ProfilesChanged,
BaselineLocUnavailable,
}
impl NotComparableReason {
pub const fn code(self) -> &'static str {
match self {
Self::MissingScoreContext => "missing_score_context",
Self::FormulaVersionChanged => "formula_version_changed",
Self::ProfilesChanged => "profiles_changed",
Self::BaselineLocUnavailable => "baseline_loc_unavailable",
}
}
}
impl std::fmt::Display for NotComparableReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let text = match self {
Self::MissingScoreContext => {
"the baseline predates score-context tracking (re-save it with --save-baseline)"
}
Self::FormulaVersionChanged => {
"the score formula changed since the baseline was saved (re-save it with --save-baseline)"
}
Self::ProfilesChanged => {
"judge.toml crate profiles changed since the baseline was saved (re-save it with --save-baseline)"
}
Self::BaselineLocUnavailable => {
"the baseline recorded no authored LOC, so its score is unavailable"
}
};
f.write_str(text)
}
}
#[derive(Debug, Clone)]
pub enum Trend {
Comparable {
current: HealthScore,
baseline_score: f64,
baseline_grade: Grade,
},
NotComparable {
current: HealthScore,
reason: NotComparableReason,
},
}
impl Trend {
pub fn current(&self) -> &HealthScore {
match self {
Self::Comparable { current, .. } | Self::NotComparable { current, .. } => current,
}
}
pub fn delta(&self) -> Option<f64> {
match self {
Self::Comparable {
current,
baseline_score,
..
} => Some(current.score - baseline_score),
Self::NotComparable { .. } => None,
}
}
}
pub fn trend(
current: HealthScore,
baseline: &Baseline,
workspace: &Workspace,
crate_profiles: &[CrateProfile],
) -> Trend {
let Some(context) = &baseline.score_context else {
return Trend::NotComparable {
current,
reason: NotComparableReason::MissingScoreContext,
};
};
if context.formula_version != SCORE_FORMULA_VERSION {
return Trend::NotComparable {
current,
reason: NotComparableReason::FormulaVersionChanged,
};
}
if *context != ScoreContext::from_profiles(crate_profiles) {
return Trend::NotComparable {
current,
reason: NotComparableReason::ProfilesChanged,
};
}
if baseline.total_loc == 0 {
return Trend::NotComparable {
current,
reason: NotComparableReason::BaselineLocUnavailable,
};
}
let (fail_count, warn_count, deduction) = tally_deductions(
baseline
.findings
.iter()
.filter(|finding| finding.evidence_class.is_gating())
.map(|finding| {
let file = workspace.root.join(&finding.file);
(finding.severity, multiplier_for(workspace, &file, crate_profiles))
}),
);
let baseline_score = score_from(fail_count, warn_count, deduction, baseline.total_loc);
Trend::Comparable {
current,
baseline_score: baseline_score.score,
baseline_grade: baseline_score.grade,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{Location, OneBasedLine, Origin};
use crate::ingest::{CrateInfo, SourceFile, SourceKind};
use std::path::PathBuf;
fn finding(severity: Severity, file: &str) -> Finding {
Finding {
id: "id".into(),
rule: "rule".into(),
severity,
location: Location {
file: PathBuf::from(file),
line: OneBasedLine::FIRST,
item_path: "item".to_string(),
},
evidence_class: crate::finding::EvidenceClass::DerivedFact,
origin: Origin::Code,
evidence: None,
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
fn workspace_with_crate(root: &str, name: &str) -> Workspace {
Workspace {
root: PathBuf::from(root),
crates: vec![CrateInfo {
name: name.to_string(),
version: "0.1.0".to_string(),
manifest_path: PathBuf::from(root).join("Cargo.toml"),
root: PathBuf::from(root),
source_files: vec![SourceFile {
path: PathBuf::from(root).join("src/lib.rs"),
kind: SourceKind::Authored,
}],
entry_points: Vec::new(),
dependencies: Vec::new(),
}],
}
}
fn multiplier(value: f64) -> DeductionMultiplier {
DeductionMultiplier::try_from(value).unwrap()
}
fn available(outcome: ScoreOutcome) -> HealthScore {
match outcome {
ScoreOutcome::Available(score) => score,
ScoreOutcome::Unavailable(reason) => panic!("score unavailable: {reason}"),
}
}
#[test]
fn no_findings_scores_perfectly() {
let workspace = workspace_with_crate("/repo", "core");
let score = available(compute(&[], 1000, &workspace, &[]));
assert_eq!(score.score, 100.0);
assert_eq!(score.grade, Grade::A);
assert_eq!(score.fail_count, 0);
assert_eq!(score.warn_count, 0);
}
#[test]
fn info_findings_do_not_affect_the_score() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![finding(Severity::Info, "/repo/src/lib.rs")];
let score = available(compute(&findings, 1000, &workspace, &[]));
assert_eq!(score.score, 100.0);
}
#[test]
fn heuristic_findings_do_not_deduct_and_are_not_counted() {
let workspace = workspace_with_crate("/repo", "core");
let mut heuristic_fail = finding(Severity::Fail, "/repo/src/lib.rs");
heuristic_fail.evidence_class = crate::finding::EvidenceClass::Heuristic;
let mut heuristic_warn = finding(Severity::Warn, "/repo/src/lib.rs");
heuristic_warn.evidence_class = crate::finding::EvidenceClass::Heuristic;
let score = available(compute(
&[heuristic_fail, heuristic_warn],
1000,
&workspace,
&[],
));
assert_eq!(score.score, 100.0);
assert_eq!(score.grade, Grade::A);
assert_eq!(score.fail_count, 0);
assert_eq!(score.warn_count, 0);
assert_eq!(score.deduction, 0.0);
}
#[test]
fn fail_and_warn_findings_are_weighted_and_density_normalized() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![
finding(Severity::Fail, "/repo/src/lib.rs"),
finding(Severity::Warn, "/repo/src/lib.rs"),
];
let score = available(compute(&findings, 1000, &workspace, &[]));
assert_eq!(score.fail_count, 1);
assert_eq!(score.warn_count, 1);
assert_eq!(score.deduction, 13.0);
assert_eq!(score.score, 87.0);
assert_eq!(score.grade, Grade::B);
}
#[test]
fn same_findings_score_worse_on_a_smaller_codebase() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![finding(Severity::Fail, "/repo/src/lib.rs")];
let large = available(compute(&findings, 10_000, &workspace, &[]));
let small = available(compute(&findings, 100, &workspace, &[]));
assert!(small.score < large.score);
}
#[test]
fn zero_loc_is_unavailable_not_a_perfect_score() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![finding(Severity::Fail, "/repo/src/lib.rs")];
let outcome = compute(&findings, 0, &workspace, &[]);
assert!(matches!(
outcome,
ScoreOutcome::Unavailable(ScoreReason::NoAuthoredLoc)
));
}
#[test]
fn total_authored_loc_checked_counts_authored_lines() {
let dir = crate::test_util::TempDir::new("health-score-loc-checked");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "fn a() {}\nfn b() {}\n").unwrap();
let mut workspace = workspace_with_crate("/ignored", "fixture");
workspace.root = dir.to_path_buf();
workspace.crates[0].root = dir.to_path_buf();
workspace.crates[0].source_files = vec![SourceFile {
path: dir.join("src/lib.rs"),
kind: SourceKind::Authored,
}];
assert_eq!(total_authored_loc_checked(&workspace).unwrap(), 2);
}
#[test]
fn unreadable_source_file_is_a_loc_error_not_a_smaller_codebase() {
let workspace = workspace_with_crate("/nonexistent-judge-fixture", "core");
let err = total_authored_loc_checked(&workspace).unwrap_err();
assert_eq!(
err.path,
PathBuf::from("/nonexistent-judge-fixture/src/lib.rs")
);
}
#[test]
fn deduction_multiplier_rejects_out_of_range_values() {
assert!(DeductionMultiplier::try_from(-1.0).is_err());
assert!(DeductionMultiplier::try_from(0.0).is_err());
assert!(DeductionMultiplier::try_from(f64::NAN).is_err());
assert!(DeductionMultiplier::try_from(f64::INFINITY).is_err());
assert!(DeductionMultiplier::try_from(f64::NEG_INFINITY).is_err());
assert!(DeductionMultiplier::try_from(10.1).is_err());
assert_eq!(DeductionMultiplier::try_from(0.5).unwrap().value(), 0.5);
assert_eq!(DeductionMultiplier::try_from(10.0).unwrap().value(), 10.0);
assert_eq!(DeductionMultiplier::default().value(), 1.0);
}
#[test]
fn invalid_multiplier_in_judge_toml_is_a_config_error_not_a_score() {
for value in ["-1.0", "0.0", "nan", "inf", "100.0"] {
let source = format!(
"[[crate_profile]]\nname = \"lenient\"\ncrates = [\"parser\"]\ndeduction_multiplier = {value}\n"
);
let result = toml::from_str::<crate::rules::boundaries::BoundaryConfig>(&source);
assert!(result.is_err(), "multiplier {value} should be rejected");
}
}
#[test]
fn authored_loc_in_counts_only_the_given_files() {
let dir = crate::test_util::TempDir::new("health-score-authored-loc-in");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/a.rs"), "fn a() {}\nfn a2() {}\n").unwrap();
std::fs::write(dir.join("src/b.rs"), "fn b() {}\n").unwrap();
let workspace = Workspace {
root: dir.to_path_buf(),
crates: vec![CrateInfo {
name: "fixture".to_string(),
version: "0.1.0".to_string(),
manifest_path: dir.join("Cargo.toml"),
root: dir.to_path_buf(),
source_files: vec![
SourceFile {
path: dir.join("src/a.rs"),
kind: SourceKind::Authored,
},
SourceFile {
path: dir.join("src/b.rs"),
kind: SourceKind::Authored,
},
],
entry_points: Vec::new(),
dependencies: Vec::new(),
}],
};
let touched = HashSet::from([PathBuf::from("src/a.rs")]);
let loc = authored_loc_in(&workspace, &touched);
assert_eq!(loc, 2);
}
#[test]
fn a_crate_named_in_two_profiles_silently_keeps_only_the_first_multiplier() {
let workspace = workspace_with_crate("/repo", "vendor");
let findings = vec![finding(Severity::Fail, "/repo/src/lib.rs")];
let shadowed_override = vec![
CrateProfile {
name: "legacy-lenient".to_string(),
crates: vec!["vendor".to_string()],
deduction_multiplier: multiplier(0.5),
},
CrateProfile {
name: "attempted-strict-override".to_string(),
crates: vec!["vendor".to_string()],
deduction_multiplier: multiplier(2.0),
},
];
let first_profile_only = vec![CrateProfile {
name: "legacy-lenient".to_string(),
crates: vec!["vendor".to_string()],
deduction_multiplier: multiplier(0.5),
}];
let scaled = available(compute(&findings, 1000, &workspace, &shadowed_override));
let lenient_only = available(compute(&findings, 1000, &workspace, &first_profile_only));
assert_eq!(
scaled.deduction, lenient_only.deduction,
"the second profile's 2.0 multiplier is silently shadowed by the first's 0.5 -- \
the score is byte-identical to a config where the second entry was never written \
at all"
);
}
#[test]
fn crate_profile_multiplier_scales_that_crates_deductions() {
let workspace = workspace_with_crate("/repo", "parser");
let findings = vec![finding(Severity::Fail, "/repo/src/lib.rs")];
let profiles = vec![CrateProfile {
name: "lenient".to_string(),
crates: vec!["parser".to_string()],
deduction_multiplier: multiplier(0.5),
}];
let scaled = available(compute(&findings, 1000, &workspace, &profiles));
let unscaled = available(compute(&findings, 1000, &workspace, &[]));
assert_eq!(scaled.deduction, unscaled.deduction * 0.5);
}
#[test]
fn trend_is_zero_for_unchanged_findings_and_profiles() {
let workspace = workspace_with_crate("/repo", "parser");
let profiles = vec![CrateProfile {
name: "lenient".to_string(),
crates: vec!["parser".to_string()],
deduction_multiplier: multiplier(0.5),
}];
let findings = vec![
finding(Severity::Fail, "/repo/src/lib.rs"),
finding(Severity::Warn, "/repo/src/lib.rs"),
];
let baseline = Baseline::new(
&findings,
"abc123".to_string(),
std::collections::HashMap::new(),
1000,
ScoreContext::from_profiles(&profiles),
);
let current = available(compute(&findings, 1000, &workspace, &profiles));
let trend = trend(current, &baseline, &workspace, &profiles);
assert_eq!(trend.delta(), Some(0.0));
assert!(matches!(trend, Trend::Comparable { .. }));
}
#[test]
fn profile_change_makes_the_trend_not_comparable() {
let workspace = workspace_with_crate("/repo", "parser");
let findings = vec![finding(Severity::Fail, "/repo/src/lib.rs")];
let baseline = Baseline::new(
&findings,
"abc123".to_string(),
std::collections::HashMap::new(),
1000,
ScoreContext::from_profiles(&[]),
);
let profiles = vec![CrateProfile {
name: "lenient".to_string(),
crates: vec!["parser".to_string()],
deduction_multiplier: multiplier(0.5),
}];
let current = available(compute(&findings, 1000, &workspace, &profiles));
let trend = trend(current, &baseline, &workspace, &profiles);
assert_eq!(trend.delta(), None);
assert!(matches!(
trend,
Trend::NotComparable {
reason: NotComparableReason::ProfilesChanged,
..
}
));
}
#[test]
fn trend_skips_heuristic_baseline_findings_like_compute_does() {
let workspace = workspace_with_crate("/repo", "core");
let mut heuristic = finding(Severity::Warn, "/repo/src/lib.rs");
heuristic.evidence_class = crate::finding::EvidenceClass::Heuristic;
let findings = vec![finding(Severity::Warn, "/repo/src/lib.rs"), heuristic];
let baseline = Baseline::new(
&findings,
"abc123".to_string(),
std::collections::HashMap::new(),
1000,
ScoreContext::from_profiles(&[]),
);
let current = available(compute(&findings, 1000, &workspace, &[]));
assert_eq!(current.warn_count, 1);
let trend = trend(current, &baseline, &workspace, &[]);
assert_eq!(trend.delta(), Some(0.0));
}
#[test]
fn formula_version_change_makes_the_trend_not_comparable() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![finding(Severity::Warn, "/repo/src/lib.rs")];
let mut baseline = Baseline::new(
&findings,
"abc123".to_string(),
std::collections::HashMap::new(),
1000,
ScoreContext::from_profiles(&[]),
);
baseline.score_context.as_mut().unwrap().formula_version = 1;
let current = available(compute(&findings, 1000, &workspace, &[]));
let trend = trend(current, &baseline, &workspace, &[]);
assert!(matches!(
trend,
Trend::NotComparable {
reason: NotComparableReason::FormulaVersionChanged,
..
}
));
}
#[test]
fn baseline_without_score_context_is_not_comparable() {
let workspace = workspace_with_crate("/repo", "core");
let findings = vec![finding(Severity::Warn, "/repo/src/lib.rs")];
let mut baseline = Baseline::new(
&findings,
"abc123".to_string(),
std::collections::HashMap::new(),
1000,
ScoreContext::from_profiles(&[]),
);
baseline.score_context = None;
let current = available(compute(&findings, 1000, &workspace, &[]));
let trend = trend(current, &baseline, &workspace, &[]);
assert!(matches!(
trend,
Trend::NotComparable {
reason: NotComparableReason::MissingScoreContext,
..
}
));
}
}