Skip to main content

entrenar/quality/
pmat.rs

1//! PMAT Code Quality Metrics (ENT-005)
2//!
3//! Provides structured code quality metrics following PMAT methodology:
4//! - Line coverage from cargo-llvm-cov
5//! - Mutation score from cargo-mutants
6//! - Clippy warning counts
7//! - PMAT grade (A/B/C/D/F)
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Errors for quality metrics parsing
14#[derive(Debug, Error)]
15pub enum QualityError {
16    #[error("Failed to parse coverage output: {0}")]
17    CoverageParseError(String),
18
19    #[error("Failed to parse mutation output: {0}")]
20    MutationParseError(String),
21
22    #[error("Invalid metric value: {0}")]
23    InvalidMetric(String),
24}
25
26/// Result type for quality operations
27pub type Result<T> = std::result::Result<T, QualityError>;
28
29/// PMAT quality grade
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub enum PmatGrade {
32    /// Excellent: coverage >= 95%, mutation >= 85%
33    A,
34    /// Good: coverage >= 85%, mutation >= 75%
35    B,
36    /// Acceptable: coverage >= 75%, mutation >= 65%
37    C,
38    /// Poor: coverage >= 60%, mutation >= 50%
39    D,
40    /// Failing: below D thresholds
41    F,
42}
43
44impl PmatGrade {
45    /// Calculate grade from coverage and mutation scores
46    pub fn from_scores(coverage: f64, mutation: f64) -> Self {
47        if coverage >= 95.0 && mutation >= 85.0 {
48            Self::A
49        } else if coverage >= 85.0 && mutation >= 75.0 {
50            Self::B
51        } else if coverage >= 75.0 && mutation >= 65.0 {
52            Self::C
53        } else if coverage >= 60.0 && mutation >= 50.0 {
54            Self::D
55        } else {
56            Self::F
57        }
58    }
59
60    /// Returns true if this grade meets or exceeds the target
61    pub fn meets_target(&self, target: Self) -> bool {
62        self.as_numeric() >= target.as_numeric()
63    }
64
65    /// Convert grade to numeric value for comparison (A=4, B=3, C=2, D=1, F=0)
66    fn as_numeric(&self) -> u8 {
67        match self {
68            Self::A => 4,
69            Self::B => 3,
70            Self::C => 2,
71            Self::D => 1,
72            Self::F => 0,
73        }
74    }
75}
76
77impl std::fmt::Display for PmatGrade {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::A => write!(f, "A"),
81            Self::B => write!(f, "B"),
82            Self::C => write!(f, "C"),
83            Self::D => write!(f, "D"),
84            Self::F => write!(f, "F"),
85        }
86    }
87}
88
89/// Code quality metrics from PMAT analysis
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct CodeQualityMetrics {
92    /// Line coverage percentage (0.0 - 100.0)
93    pub coverage_percent: f64,
94
95    /// Mutation testing score percentage (0.0 - 100.0)
96    pub mutation_score: f64,
97
98    /// Number of clippy warnings
99    pub clippy_warnings: u32,
100
101    /// Computed PMAT grade
102    pub pmat_grade: PmatGrade,
103
104    /// Timestamp when metrics were collected
105    pub timestamp: DateTime<Utc>,
106}
107
108impl CodeQualityMetrics {
109    /// Create new metrics with current timestamp
110    pub fn new(coverage_percent: f64, mutation_score: f64, clippy_warnings: u32) -> Self {
111        let pmat_grade = PmatGrade::from_scores(coverage_percent, mutation_score);
112        Self {
113            coverage_percent,
114            mutation_score,
115            clippy_warnings,
116            pmat_grade,
117            timestamp: Utc::now(),
118        }
119    }
120
121    /// Create metrics with explicit timestamp
122    pub fn with_timestamp(
123        coverage_percent: f64,
124        mutation_score: f64,
125        clippy_warnings: u32,
126        timestamp: DateTime<Utc>,
127    ) -> Self {
128        let pmat_grade = PmatGrade::from_scores(coverage_percent, mutation_score);
129        Self { coverage_percent, mutation_score, clippy_warnings, pmat_grade, timestamp }
130    }
131
132    /// Parse metrics from cargo-llvm-cov and cargo-mutants output
133    ///
134    /// # Arguments
135    ///
136    /// * `coverage` - JSON output from `cargo llvm-cov --json`
137    /// * `mutants` - JSON output from `cargo mutants --json`
138    ///
139    /// # Example
140    ///
141    /// ```ignore
142    /// let coverage_json = r#"{"data":[{"totals":{"lines":{"percent":85.5}}}]}"#;
143    /// let mutants_json = r#"{"total_mutants":100,"caught":75}"#;
144    /// let metrics = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0)?;
145    /// ```
146    pub fn from_cargo_output(coverage: &str, mutants: &str, clippy_warnings: u32) -> Result<Self> {
147        let coverage_percent = Self::parse_coverage(coverage)?;
148        let mutation_score = Self::parse_mutants(mutants)?;
149
150        Ok(Self::new(coverage_percent, mutation_score, clippy_warnings))
151    }
152
153    /// Parse coverage percentage from cargo-llvm-cov JSON output
154    fn parse_coverage(json: &str) -> Result<f64> {
155        // cargo llvm-cov --json format:
156        // {"data":[{"totals":{"lines":{"percent":85.5}}}]}
157        let value: serde_json::Value = serde_json::from_str(json)
158            .map_err(|e| QualityError::CoverageParseError(e.to_string()))?;
159
160        value
161            .get("data")
162            .and_then(|d| d.get(0))
163            .and_then(|d| d.get("totals"))
164            .and_then(|t| t.get("lines"))
165            .and_then(|l| l.get("percent"))
166            .and_then(serde_json::Value::as_f64)
167            .ok_or_else(|| {
168                QualityError::CoverageParseError("Missing lines.percent field".to_string())
169            })
170    }
171
172    /// Parse mutation score from cargo-mutants JSON output
173    fn parse_mutants(json: &str) -> Result<f64> {
174        // cargo mutants --json format:
175        // {"total_mutants":100,"caught":75,"missed":20,"timeout":5}
176        let value: serde_json::Value = serde_json::from_str(json)
177            .map_err(|e| QualityError::MutationParseError(e.to_string()))?;
178
179        let total =
180            value.get("total_mutants").and_then(serde_json::Value::as_u64).ok_or_else(|| {
181                QualityError::MutationParseError("Missing total_mutants field".to_string())
182            })?;
183
184        if total == 0 {
185            return Ok(0.0);
186        }
187
188        let caught = value
189            .get("caught")
190            .and_then(serde_json::Value::as_u64)
191            .ok_or_else(|| QualityError::MutationParseError("Missing caught field".to_string()))?;
192
193        Ok((caught as f64 / total as f64) * 100.0)
194    }
195
196    /// Check if metrics meet minimum thresholds
197    ///
198    /// # Arguments
199    ///
200    /// * `min_coverage` - Minimum coverage percentage (0.0 - 100.0)
201    /// * `min_mutation` - Minimum mutation score percentage (0.0 - 100.0)
202    ///
203    /// # Example
204    ///
205    /// ```
206    /// use entrenar::quality::CodeQualityMetrics;
207    ///
208    /// let metrics = CodeQualityMetrics::new(90.0, 80.0, 0);
209    /// assert!(metrics.meets_threshold(85.0, 75.0));
210    /// assert!(!metrics.meets_threshold(95.0, 85.0));
211    /// ```
212    pub fn meets_threshold(&self, min_coverage: f64, min_mutation: f64) -> bool {
213        self.coverage_percent >= min_coverage && self.mutation_score >= min_mutation
214    }
215
216    /// Check if metrics meet the target PMAT grade
217    pub fn meets_grade(&self, target: PmatGrade) -> bool {
218        self.pmat_grade.meets_target(target)
219    }
220
221    /// Returns true if there are no clippy warnings
222    pub fn is_clippy_clean(&self) -> bool {
223        self.clippy_warnings == 0
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_pmat_grade_from_scores_a() {
233        assert_eq!(PmatGrade::from_scores(95.0, 85.0), PmatGrade::A);
234        assert_eq!(PmatGrade::from_scores(100.0, 100.0), PmatGrade::A);
235        assert_eq!(PmatGrade::from_scores(99.0, 90.0), PmatGrade::A);
236    }
237
238    #[test]
239    fn test_pmat_grade_from_scores_b() {
240        assert_eq!(PmatGrade::from_scores(85.0, 75.0), PmatGrade::B);
241        assert_eq!(PmatGrade::from_scores(94.9, 84.9), PmatGrade::B);
242        assert_eq!(PmatGrade::from_scores(90.0, 80.0), PmatGrade::B);
243    }
244
245    #[test]
246    fn test_pmat_grade_from_scores_c() {
247        assert_eq!(PmatGrade::from_scores(75.0, 65.0), PmatGrade::C);
248        assert_eq!(PmatGrade::from_scores(84.9, 74.9), PmatGrade::C);
249    }
250
251    #[test]
252    fn test_pmat_grade_from_scores_d() {
253        assert_eq!(PmatGrade::from_scores(60.0, 50.0), PmatGrade::D);
254        assert_eq!(PmatGrade::from_scores(74.9, 64.9), PmatGrade::D);
255    }
256
257    #[test]
258    fn test_pmat_grade_from_scores_f() {
259        assert_eq!(PmatGrade::from_scores(59.9, 49.9), PmatGrade::F);
260        assert_eq!(PmatGrade::from_scores(0.0, 0.0), PmatGrade::F);
261        assert_eq!(PmatGrade::from_scores(90.0, 40.0), PmatGrade::F);
262    }
263
264    #[test]
265    fn test_pmat_grade_meets_target() {
266        assert!(PmatGrade::A.meets_target(PmatGrade::A));
267        assert!(PmatGrade::A.meets_target(PmatGrade::B));
268        assert!(PmatGrade::B.meets_target(PmatGrade::C));
269        assert!(!PmatGrade::B.meets_target(PmatGrade::A));
270        assert!(!PmatGrade::F.meets_target(PmatGrade::D));
271    }
272
273    #[test]
274    fn test_pmat_grade_display() {
275        assert_eq!(format!("{}", PmatGrade::A), "A");
276        assert_eq!(format!("{}", PmatGrade::F), "F");
277    }
278
279    #[test]
280    fn test_code_quality_metrics_new() {
281        let metrics = CodeQualityMetrics::new(90.0, 80.0, 5);
282
283        assert!((metrics.coverage_percent - 90.0).abs() < f64::EPSILON);
284        assert!((metrics.mutation_score - 80.0).abs() < f64::EPSILON);
285        assert_eq!(metrics.clippy_warnings, 5);
286        assert_eq!(metrics.pmat_grade, PmatGrade::B);
287    }
288
289    #[test]
290    fn test_code_quality_metrics_meets_threshold() {
291        let metrics = CodeQualityMetrics::new(90.0, 80.0, 0);
292
293        // Should pass these thresholds
294        assert!(metrics.meets_threshold(85.0, 75.0));
295        assert!(metrics.meets_threshold(90.0, 80.0));
296
297        // Should fail these thresholds
298        assert!(!metrics.meets_threshold(95.0, 85.0));
299        assert!(!metrics.meets_threshold(90.0, 85.0));
300        assert!(!metrics.meets_threshold(95.0, 80.0));
301    }
302
303    #[test]
304    fn test_code_quality_metrics_meets_threshold_edge_cases() {
305        // Exactly at threshold
306        let metrics = CodeQualityMetrics::new(85.0, 75.0, 0);
307        assert!(metrics.meets_threshold(85.0, 75.0));
308
309        // Just below threshold
310        let metrics = CodeQualityMetrics::new(84.99, 74.99, 0);
311        assert!(!metrics.meets_threshold(85.0, 75.0));
312    }
313
314    #[test]
315    fn test_code_quality_metrics_meets_grade() {
316        let metrics = CodeQualityMetrics::new(90.0, 80.0, 0);
317
318        assert!(metrics.meets_grade(PmatGrade::B));
319        assert!(metrics.meets_grade(PmatGrade::C));
320        assert!(!metrics.meets_grade(PmatGrade::A));
321    }
322
323    #[test]
324    fn test_code_quality_metrics_is_clippy_clean() {
325        let clean = CodeQualityMetrics::new(90.0, 80.0, 0);
326        let warnings = CodeQualityMetrics::new(90.0, 80.0, 5);
327
328        assert!(clean.is_clippy_clean());
329        assert!(!warnings.is_clippy_clean());
330    }
331
332    #[test]
333    fn test_from_cargo_output_valid() {
334        let coverage_json = r#"{"data":[{"totals":{"lines":{"percent":85.5}}}]}"#;
335        let mutants_json = r#"{"total_mutants":100,"caught":75,"missed":20,"timeout":5}"#;
336
337        let metrics = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0)
338            .expect("operation should succeed");
339
340        assert!((metrics.coverage_percent - 85.5).abs() < f64::EPSILON);
341        assert!((metrics.mutation_score - 75.0).abs() < f64::EPSILON);
342        // 85.5% coverage >= 85% and 75% mutation >= 75% = Grade B
343        assert_eq!(metrics.pmat_grade, PmatGrade::B);
344    }
345
346    #[test]
347    fn test_from_cargo_output_perfect_scores() {
348        let coverage_json = r#"{"data":[{"totals":{"lines":{"percent":100.0}}}]}"#;
349        let mutants_json = r#"{"total_mutants":50,"caught":50,"missed":0,"timeout":0}"#;
350
351        let metrics = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0)
352            .expect("operation should succeed");
353
354        assert!((metrics.coverage_percent - 100.0).abs() < f64::EPSILON);
355        assert!((metrics.mutation_score - 100.0).abs() < f64::EPSILON);
356        assert_eq!(metrics.pmat_grade, PmatGrade::A);
357    }
358
359    #[test]
360    fn test_from_cargo_output_zero_mutants() {
361        let coverage_json = r#"{"data":[{"totals":{"lines":{"percent":90.0}}}]}"#;
362        let mutants_json = r#"{"total_mutants":0,"caught":0,"missed":0,"timeout":0}"#;
363
364        let metrics = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0)
365            .expect("operation should succeed");
366
367        assert!((metrics.mutation_score - 0.0).abs() < f64::EPSILON);
368    }
369
370    #[test]
371    fn test_from_cargo_output_invalid_coverage() {
372        let coverage_json = r#"{"invalid": "json"}"#;
373        let mutants_json = r#"{"total_mutants":100,"caught":75}"#;
374
375        let result = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0);
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_from_cargo_output_invalid_mutants() {
381        let coverage_json = r#"{"data":[{"totals":{"lines":{"percent":85.5}}}]}"#;
382        let mutants_json = r#"{"invalid": "json"}"#;
383
384        let result = CodeQualityMetrics::from_cargo_output(coverage_json, mutants_json, 0);
385        assert!(result.is_err());
386    }
387
388    #[test]
389    fn test_code_quality_metrics_serialization() {
390        let metrics = CodeQualityMetrics::new(90.0, 80.0, 0);
391        let json = serde_json::to_string(&metrics).expect("JSON serialization should succeed");
392        let parsed: CodeQualityMetrics =
393            serde_json::from_str(&json).expect("JSON deserialization should succeed");
394
395        assert!((parsed.coverage_percent - metrics.coverage_percent).abs() < f64::EPSILON);
396        assert_eq!(parsed.pmat_grade, metrics.pmat_grade);
397    }
398}