use serde::{Deserialize, Serialize};
pub const CERTAIN_WEIGHT: f64 = 50.0; pub const HIGH_WEIGHT: f64 = 30.0; pub const SMELL_WEIGHT: f64 = 20.0;
#[inline]
pub fn log_normalize(count: usize, loc: usize) -> f64 {
if loc == 0 || count == 0 {
return 0.0;
}
let issue_log = (1.0 + count as f64).ln();
let loc_log = (1.0 + loc as f64).ln();
(issue_log / loc_log).min(1.0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthIssue {
pub kind: String,
pub target: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeverityDimension {
pub count: usize,
pub penalty: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<HealthIssue>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HealthDetails {
pub certain: SeverityDimension,
pub high: SeverityDimension,
pub smell: SeverityDimension,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectSize {
pub files: usize,
pub loc: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthScore {
pub health: u8,
pub details: HealthDetails,
pub normalized_density: f64,
pub project_size: ProjectSize,
}
impl Default for HealthScore {
fn default() -> Self {
Self {
health: 100,
details: HealthDetails::default(),
normalized_density: 0.0,
project_size: ProjectSize::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct HealthMetrics {
pub missing_handlers: usize,
pub unregistered_handlers: usize,
pub breaking_cycles: usize,
pub unused_high_confidence: usize,
pub dead_exports: usize,
pub twins_dead_parrots: usize,
pub twins_same_language: usize,
pub barrel_chaos_count: usize,
pub structural_cycles: usize,
pub cascade_imports: usize,
pub duplicate_exports: usize,
pub files: usize,
pub loc: usize,
pub certain_items: Vec<HealthIssue>,
pub high_items: Vec<HealthIssue>,
pub smell_items: Vec<HealthIssue>,
}
pub fn calculate_health_score(metrics: &HealthMetrics) -> HealthScore {
let certain_count =
metrics.missing_handlers + metrics.unregistered_handlers + metrics.breaking_cycles;
let high_count =
metrics.unused_high_confidence + metrics.dead_exports + metrics.twins_dead_parrots;
let smell_count = metrics.twins_same_language
+ metrics.barrel_chaos_count
+ metrics.structural_cycles
+ metrics.cascade_imports
+ (metrics.duplicate_exports / 5);
let certain_norm = log_normalize(certain_count, metrics.loc);
let high_norm = log_normalize(high_count, metrics.loc);
let smell_norm = log_normalize(smell_count, metrics.loc);
let certain_penalty = certain_norm * CERTAIN_WEIGHT;
let high_penalty = high_norm * HIGH_WEIGHT;
let smell_penalty = smell_norm * SMELL_WEIGHT;
let total_penalty = certain_penalty + high_penalty + smell_penalty;
let health = (100.0 - total_penalty).max(0.0).round() as u8;
let total_issues = certain_count + high_count + smell_count;
let normalized_density = log_normalize(total_issues, metrics.loc);
HealthScore {
health,
details: HealthDetails {
certain: SeverityDimension {
count: certain_count,
penalty: certain_penalty,
items: metrics.certain_items.iter().take(10).cloned().collect(),
},
high: SeverityDimension {
count: high_count,
penalty: high_penalty,
items: metrics.high_items.iter().take(10).cloned().collect(),
},
smell: SeverityDimension {
count: smell_count,
penalty: smell_penalty,
items: metrics.smell_items.iter().take(10).cloned().collect(),
},
},
normalized_density,
project_size: ProjectSize {
files: metrics.files,
loc: metrics.loc,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_normalize_zero_loc() {
assert_eq!(log_normalize(10, 0), 0.0);
}
#[test]
fn test_log_normalize_zero_issues() {
assert_eq!(log_normalize(0, 10000), 0.0);
}
#[test]
fn test_log_normalize_scales_with_loc() {
let small_project = log_normalize(10, 1000);
let large_project = log_normalize(10, 100000);
assert!(
small_project > large_project,
"small={} should be > large={}",
small_project,
large_project
);
}
#[test]
fn test_log_normalize_max_one() {
let extreme = log_normalize(1_000_000, 100);
assert!(extreme <= 1.0, "extreme={} should be <= 1.0", extreme);
}
#[test]
fn test_log_normalize_reasonable_values() {
let normal = log_normalize(10, 10_000);
assert!(
normal > 0.0 && normal < 0.5,
"normal={} should be between 0.0 and 0.5",
normal
);
let moderate = log_normalize(100, 10_000);
assert!(
moderate > normal,
"moderate={} should be > normal={}",
moderate,
normal
);
}
#[test]
fn test_health_score_perfect() {
let metrics = HealthMetrics {
loc: 10000,
files: 100,
..Default::default()
};
let score = calculate_health_score(&metrics);
assert_eq!(score.health, 100);
assert_eq!(score.details.certain.count, 0);
assert_eq!(score.details.high.count, 0);
assert_eq!(score.details.smell.count, 0);
}
#[test]
fn test_health_score_certain_dominates() {
let mut metrics = HealthMetrics {
loc: 10000,
files: 100,
..Default::default()
};
metrics.missing_handlers = 5;
let score = calculate_health_score(&metrics);
assert!(
score.health < 100,
"health={} should be < 100",
score.health
);
assert!(
score.details.certain.penalty > 0.0,
"certain.penalty should be > 0"
);
assert_eq!(score.details.high.penalty, 0.0);
assert_eq!(score.details.smell.penalty, 0.0);
}
#[test]
fn test_health_score_smell_minor_impact() {
let mut metrics = HealthMetrics {
loc: 10000,
files: 100,
..Default::default()
};
metrics.twins_same_language = 10;
let score = calculate_health_score(&metrics);
assert!(
score.health >= 80,
"health={} should be >= 80 for smell-only issues",
score.health
);
}
#[test]
fn test_health_score_never_negative() {
let mut metrics = HealthMetrics {
loc: 100, files: 5,
..Default::default()
};
metrics.missing_handlers = 100;
metrics.dead_exports = 100;
metrics.barrel_chaos_count = 100;
let score = calculate_health_score(&metrics);
assert!(score.health <= 100, "health should never exceed 100");
}
#[test]
fn test_weights_sum_to_100() {
assert_eq!(
CERTAIN_WEIGHT + HIGH_WEIGHT + SMELL_WEIGHT,
100.0,
"weights should sum to 100"
);
}
#[test]
fn test_health_score_project_size_matters() {
let small_metrics = HealthMetrics {
missing_handlers: 5,
twins_same_language: 20,
loc: 1000,
files: 10,
..Default::default()
};
let large_metrics = HealthMetrics {
missing_handlers: 5,
twins_same_language: 20,
loc: 100000,
files: 500,
..Default::default()
};
let small_score = calculate_health_score(&small_metrics);
let large_score = calculate_health_score(&large_metrics);
assert!(
large_score.health > small_score.health,
"large={} should be > small={}",
large_score.health,
small_score.health
);
}
#[test]
fn test_duplicate_exports_divided_by_5() {
let mut metrics = HealthMetrics {
loc: 10000,
files: 100,
..Default::default()
};
metrics.duplicate_exports = 4;
let score1 = calculate_health_score(&metrics);
metrics.duplicate_exports = 5;
let score2 = calculate_health_score(&metrics);
assert!(
score1.details.smell.count < score2.details.smell.count,
"5 duplicates should add to smell count"
);
}
#[test]
fn test_health_score_serialization() {
let metrics = HealthMetrics {
missing_handlers: 2,
twins_same_language: 10,
loc: 50000,
files: 200,
..Default::default()
};
let score = calculate_health_score(&metrics);
let json = serde_json::to_string_pretty(&score).unwrap();
assert!(json.contains("\"health\""));
assert!(json.contains("\"details\""));
assert!(json.contains("\"certain\""));
assert!(json.contains("\"normalized_density\""));
}
}