use crate::models::{Finding, Severity};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImpactLevel {
Critical,
High,
Medium,
Low,
Negligible,
}
impl std::fmt::Display for ImpactLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImpactLevel::Critical => write!(f, "critical"),
ImpactLevel::High => write!(f, "high"),
ImpactLevel::Medium => write!(f, "medium"),
ImpactLevel::Low => write!(f, "low"),
ImpactLevel::Negligible => write!(f, "negligible"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct MetricsBreakdown {
pub modularity: f64,
pub avg_coupling: Option<f64>,
pub circular_dependencies: i32,
pub bottleneck_count: i32,
pub dead_code_percentage: f64,
pub duplication_percentage: f64,
pub god_class_count: i32,
pub layer_violations: i32,
pub boundary_violations: i32,
pub abstraction_ratio: f64,
pub total_classes: i32,
pub total_functions: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthScoreDelta {
pub before_score: f64,
pub after_score: f64,
pub score_delta: f64,
pub before_grade: String,
pub after_grade: String,
pub grade_improved: bool,
pub structure_delta: f64,
pub quality_delta: f64,
pub architecture_delta: f64,
pub impact_level: ImpactLevel,
pub affected_metric: String,
pub finding_id: Option<String>,
pub finding_severity: Option<Severity>,
}
impl HealthScoreDelta {
pub fn grade_change_str(&self) -> Option<String> {
if self.grade_improved {
Some(format!("{} → {}", self.before_grade, self.after_grade))
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchHealthScoreDelta {
pub before_score: f64,
pub after_score: f64,
pub score_delta: f64,
pub before_grade: String,
pub after_grade: String,
pub grade_improved: bool,
pub findings_count: usize,
pub individual_deltas: Vec<HealthScoreDelta>,
}
fn detector_metric_mapping() -> HashMap<&'static str, (&'static str, &'static str)> {
let mut map = HashMap::new();
map.insert(
"CircularDependencyDetector",
("circular_dependencies", "structure"),
);
map.insert("GodClassDetector", ("god_class_count", "quality"));
map.insert("DeadCodeDetector", ("dead_code_percentage", "quality"));
map.insert("VultureDetector", ("dead_code_percentage", "quality"));
map.insert(
"ArchitecturalBottleneckDetector",
("bottleneck_count", "structure"),
);
map.insert("JscpdDetector", ("duplication_percentage", "quality"));
map.insert(
"DuplicateRustDetector",
("duplication_percentage", "quality"),
);
map.insert("LayerViolationDetector", ("layer_violations", "architecture"));
map.insert(
"BoundaryViolationDetector",
("boundary_violations", "architecture"),
);
map.insert("ModuleCohesionDetector", ("modularity", "structure"));
map.insert(
"InappropriateIntimacyDetector",
("avg_coupling", "structure"),
);
map.insert("FeatureEnvyDetector", ("avg_coupling", "structure"));
map.insert("ShotgunSurgeryDetector", ("avg_coupling", "structure"));
map.insert("MiddleManDetector", ("bottleneck_count", "structure"));
map.insert("DataClumpsDetector", ("avg_coupling", "structure"));
map
}
fn score_to_grade(score: f64) -> String {
if score >= 90.0 {
"A".to_string()
} else if score >= 80.0 {
"B".to_string()
} else if score >= 70.0 {
"C".to_string()
} else if score >= 60.0 {
"D".to_string()
} else {
"F".to_string()
}
}
pub struct HealthScoreDeltaCalculator {
structure_weight: f64,
quality_weight: f64,
architecture_weight: f64,
}
impl Default for HealthScoreDeltaCalculator {
fn default() -> Self {
Self::new()
}
}
impl HealthScoreDeltaCalculator {
pub fn new() -> Self {
Self {
structure_weight: 0.40,
quality_weight: 0.30,
architecture_weight: 0.30,
}
}
pub fn with_weights(structure: f64, quality: f64, architecture: f64) -> Self {
Self {
structure_weight: structure,
quality_weight: quality,
architecture_weight: architecture,
}
}
pub fn calculate_delta(&self, metrics: &MetricsBreakdown, finding: &Finding) -> HealthScoreDelta {
let current_structure = self.score_structure(metrics);
let current_quality = self.score_quality(metrics);
let current_architecture = self.score_architecture(metrics);
let current_overall =
self.calculate_overall(current_structure, current_quality, current_architecture);
let current_grade = score_to_grade(current_overall);
let modified_metrics = self.remove_finding_impact(metrics, finding);
let new_structure = self.score_structure(&modified_metrics);
let new_quality = self.score_quality(&modified_metrics);
let new_architecture = self.score_architecture(&modified_metrics);
let new_overall = self.calculate_overall(new_structure, new_quality, new_architecture);
let new_grade = score_to_grade(new_overall);
let score_delta = new_overall - current_overall;
let structure_delta = new_structure - current_structure;
let quality_delta = new_quality - current_quality;
let architecture_delta = new_architecture - current_architecture;
let affected_metric = self.get_affected_metric(&finding.detector);
let impact_level = self.classify_impact(score_delta, ¤t_grade != &new_grade);
HealthScoreDelta {
before_score: current_overall,
after_score: new_overall,
score_delta,
before_grade: current_grade.clone(),
after_grade: new_grade.clone(),
grade_improved: new_grade < current_grade, structure_delta,
quality_delta,
architecture_delta,
impact_level,
affected_metric,
finding_id: Some(finding.id.clone()),
finding_severity: Some(finding.severity),
}
}
pub fn calculate_batch_delta(
&self,
metrics: &MetricsBreakdown,
findings: &[Finding],
) -> BatchHealthScoreDelta {
if findings.is_empty() {
let current_overall = self.calculate_overall(
self.score_structure(metrics),
self.score_quality(metrics),
self.score_architecture(metrics),
);
let current_grade = score_to_grade(current_overall);
return BatchHealthScoreDelta {
before_score: current_overall,
after_score: current_overall,
score_delta: 0.0,
before_grade: current_grade.clone(),
after_grade: current_grade,
grade_improved: false,
findings_count: 0,
individual_deltas: vec![],
};
}
let current_structure = self.score_structure(metrics);
let current_quality = self.score_quality(metrics);
let current_architecture = self.score_architecture(metrics);
let current_overall =
self.calculate_overall(current_structure, current_quality, current_architecture);
let current_grade = score_to_grade(current_overall);
let individual_deltas: Vec<HealthScoreDelta> = findings
.iter()
.map(|f| self.calculate_delta(metrics, f))
.collect();
let mut modified_metrics = metrics.clone();
for finding in findings {
modified_metrics = self.remove_finding_impact(&modified_metrics, finding);
}
let new_structure = self.score_structure(&modified_metrics);
let new_quality = self.score_quality(&modified_metrics);
let new_architecture = self.score_architecture(&modified_metrics);
let new_overall = self.calculate_overall(new_structure, new_quality, new_architecture);
let new_grade = score_to_grade(new_overall);
BatchHealthScoreDelta {
before_score: current_overall,
after_score: new_overall,
score_delta: new_overall - current_overall,
before_grade: current_grade.clone(),
after_grade: new_grade.clone(),
grade_improved: new_grade < current_grade,
findings_count: findings.len(),
individual_deltas,
}
}
fn remove_finding_impact(
&self,
metrics: &MetricsBreakdown,
finding: &Finding,
) -> MetricsBreakdown {
let mut modified = metrics.clone();
let detector = &finding.detector;
if detector == "CircularDependencyDetector" {
modified.circular_dependencies = (modified.circular_dependencies - 1).max(0);
} else if detector == "GodClassDetector" {
modified.god_class_count = (modified.god_class_count - 1).max(0);
} else if detector == "DeadCodeDetector" || detector == "VultureDetector" {
let total_nodes = modified.total_classes + modified.total_functions;
if total_nodes > 0 {
let per_item_pct = 1.0 / total_nodes as f64;
modified.dead_code_percentage =
(modified.dead_code_percentage - per_item_pct).max(0.0);
}
} else if detector == "ArchitecturalBottleneckDetector" {
modified.bottleneck_count = (modified.bottleneck_count - 1).max(0);
} else if detector == "JscpdDetector" || detector == "DuplicateRustDetector" {
modified.duplication_percentage = (modified.duplication_percentage - 0.005).max(0.0);
} else if detector == "LayerViolationDetector" {
modified.layer_violations = (modified.layer_violations - 1).max(0);
} else if detector == "BoundaryViolationDetector" {
modified.boundary_violations = (modified.boundary_violations - 1).max(0);
} else if detector == "ModuleCohesionDetector" {
modified.modularity = (modified.modularity + 0.02).min(1.0);
} else if detector == "InappropriateIntimacyDetector"
|| detector == "FeatureEnvyDetector"
|| detector == "ShotgunSurgeryDetector"
|| detector == "DataClumpsDetector"
{
if let Some(coupling) = modified.avg_coupling {
modified.avg_coupling = Some((coupling - 0.5).max(0.0));
}
} else if detector == "MiddleManDetector" {
modified.bottleneck_count = (modified.bottleneck_count - 1).max(0);
}
modified
}
fn score_structure(&self, m: &MetricsBreakdown) -> f64 {
let modularity_score = m.modularity * 100.0;
let avg_coupling = m.avg_coupling.unwrap_or(0.0);
let coupling_score = (100.0 - (avg_coupling * 10.0)).max(0.0);
let cycle_penalty = (m.circular_dependencies * 10).min(50) as f64;
let cycle_score = 100.0 - cycle_penalty;
let bottleneck_penalty = (m.bottleneck_count * 5).min(30) as f64;
let bottleneck_score = 100.0 - bottleneck_penalty;
(modularity_score + coupling_score + cycle_score + bottleneck_score) / 4.0
}
fn score_quality(&self, m: &MetricsBreakdown) -> f64 {
let dead_code_score = 100.0 - (m.dead_code_percentage * 100.0);
let duplication_score = 100.0 - (m.duplication_percentage * 100.0);
let god_class_penalty = (m.god_class_count * 15).min(40) as f64;
let god_class_score = 100.0 - god_class_penalty;
(dead_code_score + duplication_score + god_class_score) / 3.0
}
fn score_architecture(&self, m: &MetricsBreakdown) -> f64 {
let layer_penalty = (m.layer_violations * 5).min(50) as f64;
let layer_score = 100.0 - layer_penalty;
let boundary_penalty = (m.boundary_violations * 3).min(40) as f64;
let boundary_score = 100.0 - boundary_penalty;
let abstraction_score = if (0.3..=0.7).contains(&m.abstraction_ratio) {
100.0
} else {
let distance = (m.abstraction_ratio - 0.3)
.abs()
.min((m.abstraction_ratio - 0.7).abs());
(100.0 - (distance * 100.0)).max(50.0)
};
(layer_score + boundary_score + abstraction_score) / 3.0
}
fn calculate_overall(&self, structure: f64, quality: f64, architecture: f64) -> f64 {
structure * self.structure_weight
+ quality * self.quality_weight
+ architecture * self.architecture_weight
}
fn get_affected_metric(&self, detector: &str) -> String {
detector_metric_mapping()
.get(detector)
.map(|(metric, _)| metric.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn classify_impact(&self, score_delta: f64, grade_changed: bool) -> ImpactLevel {
if grade_changed || score_delta > 5.0 {
ImpactLevel::Critical
} else if score_delta > 2.0 {
ImpactLevel::High
} else if score_delta > 0.5 {
ImpactLevel::Medium
} else if score_delta > 0.1 {
ImpactLevel::Low
} else {
ImpactLevel::Negligible
}
}
}
pub fn estimate_fix_impact(metrics: &MetricsBreakdown, finding: &Finding) -> HealthScoreDelta {
let calculator = HealthScoreDeltaCalculator::new();
calculator.calculate_delta(metrics, finding)
}
pub fn estimate_batch_fix_impact(
metrics: &MetricsBreakdown,
findings: &[Finding],
) -> BatchHealthScoreDelta {
let calculator = HealthScoreDeltaCalculator::new();
calculator.calculate_batch_delta(metrics, findings)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_finding(detector: &str) -> Finding {
Finding {
id: "test-1".to_string(),
detector: detector.to_string(),
severity: Severity::High,
title: "Test finding".to_string(),
description: "Test description".to_string(),
affected_files: vec![PathBuf::from("test.py")],
line_start: Some(10),
line_end: Some(20),
suggested_fix: None,
estimated_effort: None,
category: None,
cwe_id: None,
why_it_matters: None,
..Default::default()
}
}
fn create_test_metrics() -> MetricsBreakdown {
MetricsBreakdown {
modularity: 0.7,
avg_coupling: Some(3.0),
circular_dependencies: 2,
bottleneck_count: 3,
dead_code_percentage: 0.05,
duplication_percentage: 0.10,
god_class_count: 2,
layer_violations: 1,
boundary_violations: 2,
abstraction_ratio: 0.5,
total_classes: 50,
total_functions: 200,
}
}
#[test]
fn test_score_to_grade() {
assert_eq!(score_to_grade(95.0), "A");
assert_eq!(score_to_grade(85.0), "B");
assert_eq!(score_to_grade(75.0), "C");
assert_eq!(score_to_grade(65.0), "D");
assert_eq!(score_to_grade(55.0), "F");
}
#[test]
fn test_calculate_delta_circular_dep() {
let calculator = HealthScoreDeltaCalculator::new();
let metrics = create_test_metrics();
let finding = create_test_finding("CircularDependencyDetector");
let delta = calculator.calculate_delta(&metrics, &finding);
assert!(delta.score_delta > 0.0);
assert_eq!(delta.affected_metric, "circular_dependencies");
}
#[test]
fn test_calculate_delta_god_class() {
let calculator = HealthScoreDeltaCalculator::new();
let metrics = create_test_metrics();
let finding = create_test_finding("GodClassDetector");
let delta = calculator.calculate_delta(&metrics, &finding);
assert!(delta.score_delta > 0.0);
assert_eq!(delta.affected_metric, "god_class_count");
}
#[test]
fn test_batch_delta_empty() {
let calculator = HealthScoreDeltaCalculator::new();
let metrics = create_test_metrics();
let delta = calculator.calculate_batch_delta(&metrics, &[]);
assert_eq!(delta.score_delta, 0.0);
assert_eq!(delta.findings_count, 0);
}
#[test]
fn test_batch_delta_multiple() {
let calculator = HealthScoreDeltaCalculator::new();
let metrics = create_test_metrics();
let findings = vec![
create_test_finding("GodClassDetector"),
create_test_finding("CircularDependencyDetector"),
];
let delta = calculator.calculate_batch_delta(&metrics, &findings);
assert!(delta.score_delta > 0.0);
assert_eq!(delta.findings_count, 2);
assert_eq!(delta.individual_deltas.len(), 2);
}
#[test]
fn test_impact_classification() {
let calculator = HealthScoreDeltaCalculator::new();
assert_eq!(calculator.classify_impact(6.0, false), ImpactLevel::Critical);
assert_eq!(calculator.classify_impact(3.0, false), ImpactLevel::High);
assert_eq!(calculator.classify_impact(1.0, false), ImpactLevel::Medium);
assert_eq!(calculator.classify_impact(0.3, false), ImpactLevel::Low);
assert_eq!(
calculator.classify_impact(0.05, false),
ImpactLevel::Negligible
);
assert_eq!(calculator.classify_impact(0.1, true), ImpactLevel::Critical);
}
}