1use std::{collections::HashMap, fmt};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Severity {
11 Critical,
13 High,
15 Medium,
17 Low,
19}
20
21impl Severity {
22 #[must_use]
24 pub fn weight(&self) -> f64 {
25 match self {
26 Self::Critical => 2.0,
27 Self::High => 1.5,
28 Self::Medium => 1.0,
29 Self::Low => 0.5,
30 }
31 }
32
33 #[must_use]
35 pub fn base_points(&self) -> f64 {
36 match self {
37 Self::Critical => 2.0,
38 Self::High => 1.5,
39 Self::Medium => 1.0,
40 Self::Low => 0.5,
41 }
42 }
43}
44
45impl fmt::Display for Severity {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::Critical => write!(f, "Critical"),
49 Self::High => write!(f, "High"),
50 Self::Medium => write!(f, "Medium"),
51 Self::Low => write!(f, "Low"),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
58pub enum LetterGrade {
59 A,
61 B,
63 C,
65 D,
67 F,
69}
70
71impl LetterGrade {
72 #[must_use]
74 pub fn from_score(score: f64) -> Self {
75 match score {
76 s if s >= 95.0 => Self::A,
77 s if s >= 85.0 => Self::B,
78 s if s >= 70.0 => Self::C,
79 s if s >= 50.0 => Self::D,
80 _ => Self::F,
81 }
82 }
83
84 #[must_use]
86 pub fn publication_decision(&self) -> &'static str {
87 match self {
88 Self::A => "Publish immediately",
89 Self::B => "Publish with documented caveats",
90 Self::C => "Remediation required before publication",
91 Self::D => "Major rework needed",
92 Self::F => "Do not publish",
93 }
94 }
95
96 #[must_use]
98 pub fn is_publishable(&self) -> bool {
99 matches!(self, Self::A | Self::B)
100 }
101}
102
103impl fmt::Display for LetterGrade {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::A => write!(f, "A"),
107 Self::B => write!(f, "B"),
108 Self::C => write!(f, "C"),
109 Self::D => write!(f, "D"),
110 Self::F => write!(f, "F"),
111 }
112 }
113}
114
115#[derive(Debug, Clone)]
117pub struct ChecklistItem {
118 pub id: u8,
120 pub description: String,
122 pub passed: bool,
124 pub severity: Severity,
126 pub suggestion: Option<String>,
128}
129
130impl ChecklistItem {
131 #[must_use]
133 pub fn new(id: u8, description: impl Into<String>, severity: Severity, passed: bool) -> Self {
134 Self {
135 id,
136 description: description.into(),
137 passed,
138 severity,
139 suggestion: None,
140 }
141 }
142
143 #[must_use]
145 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
146 self.suggestion = Some(suggestion.into());
147 self
148 }
149
150 #[must_use]
152 pub fn points_earned(&self) -> f64 {
153 if self.passed {
154 self.severity.base_points()
155 } else {
156 0.0
157 }
158 }
159
160 #[must_use]
162 pub fn max_points(&self) -> f64 {
163 self.severity.base_points()
164 }
165}
166
167#[derive(Debug, Clone)]
169pub struct QualityScore {
170 pub score: f64,
172 pub grade: LetterGrade,
174 pub points_earned: f64,
176 pub max_points: f64,
178 pub checklist: Vec<ChecklistItem>,
180 pub severity_breakdown: HashMap<Severity, SeverityStats>,
182}
183
184#[derive(Debug, Clone, Default)]
186pub struct SeverityStats {
187 pub total: usize,
189 pub passed: usize,
191 pub failed: usize,
193 pub points_earned: f64,
195 pub max_points: f64,
197}
198
199impl QualityScore {
200 #[must_use]
202 pub fn from_checklist(checklist: Vec<ChecklistItem>) -> Self {
203 let mut severity_breakdown: HashMap<Severity, SeverityStats> = HashMap::new();
204
205 let mut points_earned = 0.0;
206 let mut max_points = 0.0;
207
208 for item in &checklist {
209 let stats = severity_breakdown.entry(item.severity).or_default();
210
211 stats.total += 1;
212 stats.max_points += item.max_points();
213
214 if item.passed {
215 stats.passed += 1;
216 stats.points_earned += item.points_earned();
217 points_earned += item.points_earned();
218 } else {
219 stats.failed += 1;
220 }
221
222 max_points += item.max_points();
223 }
224
225 let score = if max_points > 0.0 {
226 (points_earned / max_points * 100.0).clamp(0.0, 100.0)
227 } else {
228 100.0
229 };
230
231 let grade = LetterGrade::from_score(score);
232
233 Self {
234 score,
235 grade,
236 points_earned,
237 max_points,
238 checklist,
239 severity_breakdown,
240 }
241 }
242
243 #[must_use]
245 pub fn failed_items(&self) -> Vec<&ChecklistItem> {
246 self.checklist.iter().filter(|item| !item.passed).collect()
247 }
248
249 #[must_use]
251 pub fn critical_failures(&self) -> Vec<&ChecklistItem> {
252 self.checklist
253 .iter()
254 .filter(|item| !item.passed && item.severity == Severity::Critical)
255 .collect()
256 }
257
258 #[must_use]
260 pub fn has_critical_failures(&self) -> bool {
261 self.checklist
262 .iter()
263 .any(|item| !item.passed && item.severity == Severity::Critical)
264 }
265
266 #[must_use]
268 pub fn badge_url(&self) -> String {
269 let color = match self.grade {
270 LetterGrade::A => "brightgreen",
271 LetterGrade::B => "green",
272 LetterGrade::C => "yellow",
273 LetterGrade::D => "orange",
274 LetterGrade::F => "red",
275 };
276 format!(
277 "https://img.shields.io/badge/data_quality-{}_({:.0}%25)-{}",
278 self.grade, self.score, color
279 )
280 }
281
282 #[must_use]
284 pub fn to_json(&self) -> String {
285 let failed_items: Vec<_> = self
286 .failed_items()
287 .iter()
288 .map(|item| {
289 format!(
290 r#" {{"id": {}, "description": "{}", "severity": "{}", "suggestion": {}}}"#,
291 item.id,
292 item.description.replace('"', "\\\""),
293 item.severity,
294 item.suggestion
295 .as_ref()
296 .map(|s| format!("\"{}\"", s.replace('"', "\\\"")))
297 .unwrap_or_else(|| "null".to_string())
298 )
299 })
300 .collect();
301
302 format!(
303 r#"{{
304 "score": {:.2},
305 "grade": "{}",
306 "is_publishable": {},
307 "decision": "{}",
308 "points_earned": {:.2},
309 "max_points": {:.2},
310 "critical_failures": {},
311 "failed_items": [
312{}
313 ],
314 "badge_url": "{}"
315}}"#,
316 self.score,
317 self.grade,
318 self.grade.is_publishable(),
319 self.grade.publication_decision(),
320 self.points_earned,
321 self.max_points,
322 self.has_critical_failures(),
323 failed_items.join(",\n"),
324 self.badge_url()
325 )
326 }
327}