Skip to main content

tokmd_analysis_maintainability/
lib.rs

1//! Maintainability index scoring and Halstead integration helpers.
2
3use tokmd_analysis_types::{ComplexityReport, HalsteadMetrics, MaintainabilityIndex};
4
5/// Compute maintainability index using simplified or full SEI formula.
6///
7/// Simplified:
8/// MI = 171 - 0.23 * CC - 16.2 * ln(LOC)
9///
10/// Full (when Halstead volume is available and positive):
11/// MI = 171 - 5.2 * ln(V) - 0.23 * CC - 16.2 * ln(LOC)
12pub fn compute_maintainability_index(
13    avg_cyclomatic: f64,
14    avg_loc: f64,
15    halstead_volume: Option<f64>,
16) -> Option<MaintainabilityIndex> {
17    if avg_loc <= 0.0 {
18        return None;
19    }
20
21    let avg_loc = round_f64(avg_loc, 2);
22    let (raw_score, avg_halstead_volume) = match halstead_volume {
23        Some(volume) if volume > 0.0 => (
24            171.0 - 5.2 * volume.ln() - 0.23 * avg_cyclomatic - 16.2 * avg_loc.ln(),
25            Some(volume),
26        ),
27        _ => (171.0 - 0.23 * avg_cyclomatic - 16.2 * avg_loc.ln(), None),
28    };
29
30    let score = round_f64(raw_score.max(0.0), 2);
31    Some(MaintainabilityIndex {
32        score,
33        avg_cyclomatic,
34        avg_loc,
35        avg_halstead_volume,
36        grade: grade_for_score(score).to_string(),
37    })
38}
39
40/// Attach Halstead metrics and refresh maintainability index when possible.
41///
42/// The maintainability index is recomputed only when:
43/// - `complexity.maintainability_index` is present, and
44/// - `halstead.volume` is positive.
45pub fn attach_halstead_metrics(complexity: &mut ComplexityReport, halstead: HalsteadMetrics) {
46    if let Some(ref mut mi) = complexity.maintainability_index
47        && halstead.volume > 0.0
48        && let Some(updated) =
49            compute_maintainability_index(mi.avg_cyclomatic, mi.avg_loc, Some(halstead.volume))
50    {
51        *mi = updated;
52    }
53
54    complexity.halstead = Some(halstead);
55}
56
57fn grade_for_score(score: f64) -> &'static str {
58    if score >= 85.0 {
59        "A"
60    } else if score >= 65.0 {
61        "B"
62    } else {
63        "C"
64    }
65}
66
67fn round_f64(val: f64, decimals: u32) -> f64 {
68    let factor = 10f64.powi(decimals as i32);
69    (val * factor).round() / factor
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use tokmd_analysis_types::{ComplexityRisk, FileComplexity, TechnicalDebtRatio};
76
77    #[test]
78    fn compute_simplified_index() {
79        let mi = compute_maintainability_index(10.0, 100.0, None).expect("mi");
80        assert!((mi.score - 94.1).abs() < f64::EPSILON);
81        assert_eq!(mi.grade, "A");
82        assert_eq!(mi.avg_halstead_volume, None);
83    }
84
85    #[test]
86    fn compute_full_index_with_halstead() {
87        let mi = compute_maintainability_index(10.0, 100.0, Some(200.0)).expect("mi");
88        assert!((mi.score - 66.54).abs() < f64::EPSILON);
89        assert_eq!(mi.grade, "B");
90        assert_eq!(mi.avg_halstead_volume, Some(200.0));
91    }
92
93    #[test]
94    fn attach_halstead_recomputes_maintainability() {
95        let mut complexity = sample_complexity();
96        let before = complexity
97            .maintainability_index
98            .as_ref()
99            .map(|mi| mi.score)
100            .expect("maintainability");
101
102        attach_halstead_metrics(
103            &mut complexity,
104            HalsteadMetrics {
105                distinct_operators: 20,
106                distinct_operands: 30,
107                total_operators: 120,
108                total_operands: 240,
109                vocabulary: 50,
110                length: 360,
111                volume: 200.0,
112                difficulty: 8.0,
113                effort: 1600.0,
114                time_seconds: 88.89,
115                estimated_bugs: 0.0667,
116            },
117        );
118
119        let mi = complexity
120            .maintainability_index
121            .as_ref()
122            .expect("maintainability");
123        assert!(mi.score < before);
124        assert_eq!(mi.avg_halstead_volume, Some(200.0));
125        assert_eq!(mi.grade, "B");
126        assert_eq!(complexity.halstead.as_ref().map(|h| h.volume), Some(200.0));
127    }
128
129    #[test]
130    fn attach_halstead_keeps_existing_index_when_volume_is_zero() {
131        let mut complexity = sample_complexity();
132        let before = complexity
133            .maintainability_index
134            .as_ref()
135            .map(|mi| (mi.score, mi.avg_halstead_volume))
136            .expect("maintainability");
137
138        attach_halstead_metrics(
139            &mut complexity,
140            HalsteadMetrics {
141                distinct_operators: 0,
142                distinct_operands: 0,
143                total_operators: 0,
144                total_operands: 0,
145                vocabulary: 0,
146                length: 0,
147                volume: 0.0,
148                difficulty: 0.0,
149                effort: 0.0,
150                time_seconds: 0.0,
151                estimated_bugs: 0.0,
152            },
153        );
154
155        let after = complexity
156            .maintainability_index
157            .as_ref()
158            .map(|mi| (mi.score, mi.avg_halstead_volume))
159            .expect("maintainability");
160        assert_eq!(before, after);
161        assert_eq!(complexity.halstead.as_ref().map(|h| h.volume), Some(0.0));
162    }
163
164    fn sample_complexity() -> ComplexityReport {
165        ComplexityReport {
166            total_functions: 3,
167            avg_function_length: 10.0,
168            max_function_length: 20,
169            avg_cyclomatic: 10.0,
170            max_cyclomatic: 18,
171            avg_cognitive: Some(6.5),
172            max_cognitive: Some(10),
173            avg_nesting_depth: Some(2.0),
174            max_nesting_depth: Some(4),
175            high_risk_files: 1,
176            histogram: None,
177            halstead: None,
178            maintainability_index: compute_maintainability_index(10.0, 100.0, None),
179            technical_debt: Some(TechnicalDebtRatio {
180                ratio: 20.0,
181                complexity_points: 20,
182                code_kloc: 1.0,
183                level: tokmd_analysis_types::TechnicalDebtLevel::Low,
184            }),
185            files: vec![FileComplexity {
186                path: "src/lib.rs".to_string(),
187                module: "src".to_string(),
188                function_count: 3,
189                max_function_length: 20,
190                cyclomatic_complexity: 18,
191                cognitive_complexity: Some(10),
192                max_nesting: Some(4),
193                risk_level: ComplexityRisk::Moderate,
194                functions: None,
195            }],
196        }
197    }
198}