use crate::core::FunctionMetrics;
use crate::priority::DebtType;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum Severity {
Critical,
High,
Medium,
Low,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Critical => write!(f, "CRITICAL"),
Severity::High => write!(f, "HIGH"),
Severity::Medium => write!(f, "MEDIUM"),
Severity::Low => write!(f, "LOW"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreComponents {
pub complexity_score: f64, pub coverage_score: f64, pub structural_score: f64, pub size_score: f64, pub smell_score: f64, }
impl ScoreComponents {
pub fn weighted_total(&self, weights: &ScoreWeights) -> f64 {
let raw_total = self.complexity_score * weights.complexity_weight
+ self.coverage_score * weights.coverage_weight
+ self.structural_score * weights.structural_weight
+ self.size_score * weights.size_weight
+ self.smell_score * weights.smell_weight;
(raw_total / 237.0) * 200.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreWeights {
pub complexity_weight: f64, pub coverage_weight: f64, pub structural_weight: f64, pub size_weight: f64, pub smell_weight: f64, }
impl Default for ScoreWeights {
fn default() -> Self {
Self::balanced()
}
}
impl ScoreWeights {
pub fn balanced() -> Self {
ScoreWeights {
complexity_weight: 1.0,
coverage_weight: 1.0,
structural_weight: 0.8,
size_weight: 0.3, smell_weight: 0.6,
}
}
pub fn quality_focused() -> Self {
ScoreWeights {
complexity_weight: 1.2,
coverage_weight: 1.1,
structural_weight: 0.9,
size_weight: 0.2, smell_weight: 0.7,
}
}
pub fn size_focused() -> Self {
ScoreWeights {
complexity_weight: 0.5,
coverage_weight: 0.4,
structural_weight: 0.6,
size_weight: 1.5, smell_weight: 0.3,
}
}
pub fn test_coverage_focused() -> Self {
ScoreWeights {
complexity_weight: 0.8,
coverage_weight: 1.3, structural_weight: 0.6,
size_weight: 0.2,
smell_weight: 0.5,
}
}
pub fn from_preset(preset: &str) -> Option<Self> {
match preset.to_lowercase().as_str() {
"balanced" => Some(Self::balanced()),
"quality-focused" | "quality_focused" | "quality" => Some(Self::quality_focused()),
"size-focused" | "size_focused" | "legacy" => Some(Self::size_focused()),
"test-coverage" | "test_coverage" | "testing" => Some(Self::test_coverage_focused()),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoringRationale {
pub primary_factors: Vec<String>,
pub bonuses: Vec<String>,
pub context_adjustments: Vec<String>,
}
impl ScoringRationale {
pub fn explain(components: &ScoreComponents, weights: &ScoreWeights) -> Self {
let mut primary = Vec::new();
let mut bonuses = Vec::new();
let mut adjustments = Vec::new();
if components.complexity_score > 40.0 {
primary.push(format!(
"High cyclomatic complexity (+{:.1})",
components.complexity_score
));
}
if components.coverage_score > 30.0 {
primary.push(format!(
"Significant coverage gap (+{:.1})",
components.coverage_score
));
}
if components.structural_score > 30.0 {
primary.push(format!(
"Structural issues (+{:.1})",
components.structural_score
));
}
if components.complexity_score > 40.0 && components.coverage_score > 20.0 {
bonuses.push("Complex + untested: +20 bonus applied".to_string());
}
if components.smell_score > 20.0 {
bonuses.push(format!(
"Code smells detected (+{:.1})",
components.smell_score
));
}
if components.size_score < 10.0 && components.size_score > 0.0 {
adjustments
.push("File size context-adjusted (reduced weight for file type)".to_string());
}
if weights.size_weight < 0.5 {
adjustments.push(format!(
"Size de-emphasized (weight: {:.1})",
weights.size_weight
));
}
ScoringRationale {
primary_factors: primary,
bonuses,
context_adjustments: adjustments,
}
}
}
impl fmt::Display for ScoringRationale {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !self.primary_factors.is_empty() {
writeln!(f, " Primary factors:")?;
for factor in &self.primary_factors {
writeln!(f, " - {}", factor)?;
}
}
if !self.bonuses.is_empty() {
writeln!(f, " Bonuses:")?;
for bonus in &self.bonuses {
writeln!(f, " - {}", bonus)?;
}
}
if !self.context_adjustments.is_empty() {
writeln!(f, " Context adjustments:")?;
for adj in &self.context_adjustments {
writeln!(f, " - {}", adj)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DebtScore {
pub total: f64,
pub components: ScoreComponents,
pub severity: Severity,
pub rationale: ScoringRationale,
}
impl DebtScore {
pub fn calculate(func: &FunctionMetrics, debt_type: &DebtType, weights: &ScoreWeights) -> Self {
let mut components = ScoreComponents {
complexity_score: score_complexity(func, debt_type, weights),
coverage_score: score_coverage_gap(func, debt_type, weights),
structural_score: score_structural_issues(debt_type, weights),
size_score: score_file_size(func, weights),
smell_score: score_code_smells(func, weights),
};
if is_generated_file(&func.file) {
components.size_score *= 0.1;
}
let total = components.weighted_total(weights);
let severity = determine_severity(&components, func, debt_type);
let rationale = ScoringRationale::explain(&components, weights);
DebtScore {
total,
components,
severity,
rationale,
}
}
}
fn score_complexity(_func: &FunctionMetrics, debt_type: &DebtType, _weights: &ScoreWeights) -> f64 {
match debt_type {
DebtType::ComplexityHotspot {
cyclomatic,
cognitive,
} => {
let cyclomatic_score: f64 = match *cyclomatic {
c if c > 30 => 100.0,
c if c > 20 => 80.0,
c if c > 15 => 60.0,
c if c > 10 => 40.0,
c if c > 5 => 20.0,
_ => 0.0,
};
let cognitive_bonus: f64 = match cognitive {
c if *c > 50 => 20.0,
c if *c > 30 => 15.0,
c if *c > 20 => 10.0,
c if *c > 15 => 5.0,
_ => 0.0,
};
(cyclomatic_score + cognitive_bonus).min(100.0)
}
DebtType::TestingGap {
cyclomatic,
cognitive,
..
} => {
let base: f64 = match cyclomatic {
c if *c > 15 => 30.0,
c if *c > 10 => 20.0,
c if *c > 5 => 10.0,
_ => 0.0,
};
let cognitive_bonus: f64 = match cognitive {
c if *c > 30 => 10.0,
c if *c > 15 => 5.0,
_ => 0.0,
};
(base + cognitive_bonus).min(40.0)
}
_ => 0.0,
}
}
fn score_coverage_gap(
_func: &FunctionMetrics,
debt_type: &DebtType,
_weights: &ScoreWeights,
) -> f64 {
match debt_type {
DebtType::TestingGap {
coverage,
cyclomatic,
..
} => {
let gap_percent = (1.0 - coverage) * 100.0;
let base_score = (gap_percent * 0.6).min(60.0);
let complexity_bonus = if *cyclomatic > 15 {
20.0 } else if *cyclomatic > 10 {
10.0 } else {
0.0
};
(base_score + complexity_bonus).min(80.0)
}
_ => 0.0,
}
}
fn score_structural_issues(debt_type: &DebtType, _weights: &ScoreWeights) -> f64 {
match debt_type {
DebtType::GodObject {
methods,
responsibilities,
god_object_score,
..
} => {
let responsibility_score = ((*responsibilities as f64 - 1.0) * 10.0).min(30.0);
let method_score = ((*methods as f64 / 20.0) * 15.0).min(20.0);
let god_score = (god_object_score * 10.0).min(10.0);
(responsibility_score + method_score + god_score).min(60.0)
}
_ => 0.0,
}
}
fn score_file_size(func: &FunctionMetrics, _weights: &ScoreWeights) -> f64 {
let length = func.length;
let threshold: usize = 100; let max_threshold: usize = 200;
if length <= threshold {
0.0
} else if length <= max_threshold {
let ratio = (length - threshold) as f64 / (max_threshold - threshold) as f64;
ratio * 15.0 } else {
let excess = (length - max_threshold) as f64;
(15.0 + (excess / 100.0).min(15.0)).min(30.0)
}
}
fn score_code_smells(func: &FunctionMetrics, _weights: &ScoreWeights) -> f64 {
let mut smell_score = 0.0;
if func.length > 100 {
smell_score += ((func.length as f64 - 100.0) / 20.0).min(15.0);
}
if func.nesting > 3 {
smell_score += ((func.nesting as f64 - 3.0) * 5.0).min(15.0);
}
if let Some(false) = func.is_pure {
smell_score += 10.0;
}
smell_score.min(40.0)
}
fn determine_severity(
components: &ScoreComponents,
_func: &FunctionMetrics,
_debt_type: &DebtType,
) -> Severity {
let total = components.weighted_total(&ScoreWeights::default());
if total > 120.0 || (components.complexity_score > 60.0 && components.coverage_score > 40.0) {
return Severity::Critical;
}
if total > 80.0
|| (components.complexity_score > 40.0 && components.coverage_score > 20.0)
|| components.structural_score > 50.0
{
return Severity::High;
}
if total > 40.0
|| components.complexity_score > 30.0
|| components.coverage_score > 30.0
|| components.structural_score > 30.0
{
return Severity::Medium;
}
Severity::Low
}
fn is_generated_file(path: &Path) -> bool {
let path_str = path.to_string_lossy();
let generated_patterns = [
".generated.rs",
".pb.rs", ".g.rs", "_pb.rs", "generated/",
"/gen/",
];
generated_patterns
.iter()
.any(|pattern| path_str.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_function(
name: &str,
cyclomatic: u32,
cognitive: u32,
length: usize,
) -> FunctionMetrics {
FunctionMetrics {
name: name.to_string(),
file: PathBuf::from("test.rs"),
line: 1,
cyclomatic,
cognitive,
nesting: (cognitive / 10).min(5),
length,
is_test: false,
visibility: Some("pub".to_string()),
is_trait_method: false,
in_test_module: false,
entropy_score: None,
is_pure: Some(false),
purity_confidence: Some(0.5),
purity_reason: None,
call_dependencies: None,
detected_patterns: None,
upstream_callers: None,
downstream_callees: None,
mapping_pattern_result: None,
adjusted_complexity: None,
composition_metrics: None,
language_specific: None,
purity_level: None,
error_swallowing_count: None,
error_swallowing_patterns: None,
entropy_analysis: None,
}
}
#[test]
fn test_complexity_outweighs_size() {
let complex_func = create_test_function("complex_untested", 42, 77, 150);
let complex_score = DebtScore::calculate(
&complex_func,
&DebtType::ComplexityHotspot {
cyclomatic: 42,
cognitive: 77,
},
&ScoreWeights::default(),
);
println!(
"Complex score: {:.1}, severity: {:?}",
complex_score.total, complex_score.severity
);
println!("Components: {:?}", complex_score.components);
assert!(
complex_score.total > 50.0,
"Complex function should have substantial score, got {:.1}",
complex_score.total
);
assert!(
matches!(complex_score.severity, Severity::Critical | Severity::High),
"Complex untested function should be CRITICAL or HIGH, got {:?}",
complex_score.severity
);
}
#[test]
fn test_coverage_gap_multiplier() {
let weights = ScoreWeights::default();
let complex_untested = create_test_function("complex_untested", 20, 35, 100);
let complex_score = DebtScore::calculate(
&complex_untested,
&DebtType::TestingGap {
coverage: 0.4,
cyclomatic: 20,
cognitive: 35,
},
&weights,
);
let simple_untested = create_test_function("simple_untested", 5, 8, 50);
let simple_score = DebtScore::calculate(
&simple_untested,
&DebtType::TestingGap {
coverage: 0.4,
cyclomatic: 5,
cognitive: 8,
},
&weights,
);
assert!(
complex_score.components.coverage_score > simple_score.components.coverage_score,
"Complex untested should have higher coverage score than simple untested"
);
let bonus_diff =
complex_score.components.coverage_score - simple_score.components.coverage_score;
assert!(
(10.0..=20.0).contains(&bonus_diff),
"Bonus should be additive (10-20 points), got {:.1}",
bonus_diff
);
}
#[test]
fn test_severity_determination() {
let high_complexity = create_test_function("high_complexity", 42, 77, 150);
let score = DebtScore::calculate(
&high_complexity,
&DebtType::ComplexityHotspot {
cyclomatic: 42,
cognitive: 77,
},
&ScoreWeights::default(),
);
assert!(
matches!(score.severity, Severity::Critical | Severity::High),
"High complexity should be CRITICAL or HIGH, got {:?}",
score.severity
);
let moderate_complexity = create_test_function("moderate", 12, 20, 80);
let score = DebtScore::calculate(
&moderate_complexity,
&DebtType::ComplexityHotspot {
cyclomatic: 12,
cognitive: 20,
},
&ScoreWeights::default(),
);
assert!(matches!(score.severity, Severity::Medium | Severity::High));
}
#[test]
fn test_preset_weights() {
let balanced = ScoreWeights::balanced();
assert_eq!(balanced.complexity_weight, 1.0);
assert_eq!(balanced.coverage_weight, 1.0);
assert_eq!(balanced.size_weight, 0.3);
let quality = ScoreWeights::quality_focused();
assert_eq!(quality.complexity_weight, 1.2);
assert_eq!(quality.size_weight, 0.2);
let legacy = ScoreWeights::size_focused();
assert_eq!(legacy.size_weight, 1.5);
let testing = ScoreWeights::test_coverage_focused();
assert_eq!(testing.coverage_weight, 1.3);
}
#[test]
fn test_score_normalization() {
let func = create_test_function("test", 30, 50, 200);
let score = DebtScore::calculate(
&func,
&DebtType::ComplexityHotspot {
cyclomatic: 30,
cognitive: 50,
},
&ScoreWeights::default(),
);
assert!(
score.total >= 0.0 && score.total <= 200.0,
"Score should be in 0-200 range, got {}",
score.total
);
}
#[test]
fn test_scoring_on_synthetic_codebase() {
let complex_untested = create_test_function("complex_untested", 42, 77, 150);
let score1 = DebtScore::calculate(
&complex_untested,
&DebtType::TestingGap {
coverage: 0.38,
cyclomatic: 42,
cognitive: 77,
},
&ScoreWeights::default(),
);
let large_simple = create_test_function("large_simple", 3, 5, 2000);
let score2 = DebtScore::calculate(
&large_simple,
&DebtType::Risk {
risk_score: 0.2,
factors: vec!["Long function".to_string()],
},
&ScoreWeights::default(),
);
assert!(
score1.total > score2.total * 1.5,
"Complex untested (score={:.1}) should score much higher than large simple (score={:.1})",
score1.total,
score2.total
);
assert!(
matches!(score1.severity, Severity::Critical | Severity::High),
"Complex untested should be CRITICAL or HIGH, got {:?}",
score1.severity
);
assert!(
matches!(score2.severity, Severity::Low | Severity::Medium),
"Large simple should be LOW or MEDIUM, got {:?}",
score2.severity
);
}
#[test]
fn test_rationale_display() {
let func = create_test_function("test", 25, 40, 150);
let score = DebtScore::calculate(
&func,
&DebtType::TestingGap {
coverage: 0.3,
cyclomatic: 25,
cognitive: 40,
},
&ScoreWeights::default(),
);
let rationale_str = format!("{}", score.rationale);
assert!(
!rationale_str.is_empty(),
"Rationale should produce non-empty output"
);
}
#[test]
fn test_generated_code_detection() {
assert!(is_generated_file(Path::new("src/proto/api.pb.rs")));
assert!(is_generated_file(Path::new("src/generated/schema.rs")));
assert!(is_generated_file(Path::new("src/parser.g.rs")));
assert!(is_generated_file(Path::new("src/models_pb.rs")));
assert!(!is_generated_file(Path::new("src/main.rs")));
assert!(!is_generated_file(Path::new("src/lib.rs")));
assert!(!is_generated_file(Path::new("src/utils/helpers.rs")));
}
#[test]
fn test_generated_code_scoring_reduction() {
let mut generated_func = create_test_function("generated_fn", 10, 15, 500);
generated_func.file = PathBuf::from("src/proto/api.pb.rs");
let score = DebtScore::calculate(
&generated_func,
&DebtType::Risk {
risk_score: 0.5,
factors: vec!["Long function".to_string()],
},
&ScoreWeights::default(),
);
assert!(
score.components.size_score < 3.0,
"Generated code size score should be reduced to ~10%, got {:.1}",
score.components.size_score
);
let mut normal_func = create_test_function("normal_fn", 10, 15, 500);
normal_func.file = PathBuf::from("src/processor.rs");
let normal_score = DebtScore::calculate(
&normal_func,
&DebtType::Risk {
risk_score: 0.5,
factors: vec!["Long function".to_string()],
},
&ScoreWeights::default(),
);
assert!(
normal_score.components.size_score > score.components.size_score * 5.0,
"Normal file should have much higher size score than generated file"
);
}
}