1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[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
26pub type Result<T> = std::result::Result<T, QualityError>;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub enum PmatGrade {
32 A,
34 B,
36 C,
38 D,
40 F,
42}
43
44impl PmatGrade {
45 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 pub fn meets_target(&self, target: Self) -> bool {
62 self.as_numeric() >= target.as_numeric()
63 }
64
65 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct CodeQualityMetrics {
92 pub coverage_percent: f64,
94
95 pub mutation_score: f64,
97
98 pub clippy_warnings: u32,
100
101 pub pmat_grade: PmatGrade,
103
104 pub timestamp: DateTime<Utc>,
106}
107
108impl CodeQualityMetrics {
109 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 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 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 fn parse_coverage(json: &str) -> Result<f64> {
155 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 fn parse_mutants(json: &str) -> Result<f64> {
174 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 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 pub fn meets_grade(&self, target: PmatGrade) -> bool {
218 self.pmat_grade.meets_target(target)
219 }
220
221 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 assert!(metrics.meets_threshold(85.0, 75.0));
295 assert!(metrics.meets_threshold(90.0, 80.0));
296
297 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 let metrics = CodeQualityMetrics::new(85.0, 75.0, 0);
307 assert!(metrics.meets_threshold(85.0, 75.0));
308
309 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 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}