1use crate::model::NormalizedSbom;
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11 CompletenessMetrics, CompletenessWeights, DependencyMetrics, IdentifierMetrics, LicenseMetrics,
12 VulnerabilityMetrics,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub enum ScoringProfile {
19 Minimal,
21 Standard,
23 Security,
25 LicenseCompliance,
27 Cra,
29 Comprehensive,
31}
32
33impl ScoringProfile {
34 #[must_use]
36 pub const fn compliance_level(&self) -> ComplianceLevel {
37 match self {
38 Self::Minimal => ComplianceLevel::Minimum,
39 Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
40 Self::Security => ComplianceLevel::NtiaMinimum,
41 Self::Cra => ComplianceLevel::CraPhase2,
42 Self::Comprehensive => ComplianceLevel::Comprehensive,
43 }
44 }
45
46 const fn weights(self) -> ScoringWeights {
48 match self {
49 Self::Minimal => ScoringWeights {
50 completeness: 0.5,
51 identifiers: 0.2,
52 licenses: 0.1,
53 vulnerabilities: 0.1,
54 dependencies: 0.1,
55 },
56 Self::Standard => ScoringWeights {
57 completeness: 0.35,
58 identifiers: 0.25,
59 licenses: 0.15,
60 vulnerabilities: 0.1,
61 dependencies: 0.15,
62 },
63 Self::Security => ScoringWeights {
64 completeness: 0.2,
65 identifiers: 0.25,
66 licenses: 0.1,
67 vulnerabilities: 0.3,
68 dependencies: 0.15,
69 },
70 Self::LicenseCompliance => ScoringWeights {
71 completeness: 0.2,
72 identifiers: 0.15,
73 licenses: 0.4,
74 vulnerabilities: 0.1,
75 dependencies: 0.15,
76 },
77 Self::Cra => ScoringWeights {
78 completeness: 0.2, identifiers: 0.25, licenses: 0.1, vulnerabilities: 0.25, dependencies: 0.2, },
84 Self::Comprehensive => ScoringWeights {
85 completeness: 0.25,
86 identifiers: 0.2,
87 licenses: 0.2,
88 vulnerabilities: 0.15,
89 dependencies: 0.2,
90 },
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97struct ScoringWeights {
98 completeness: f32,
99 identifiers: f32,
100 licenses: f32,
101 vulnerabilities: f32,
102 dependencies: f32,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[non_exhaustive]
108pub enum QualityGrade {
109 A,
111 B,
113 C,
115 D,
117 F,
119}
120
121impl QualityGrade {
122 #[must_use]
124 pub const fn from_score(score: f32) -> Self {
125 match score as u32 {
126 90..=100 => Self::A,
127 80..=89 => Self::B,
128 70..=79 => Self::C,
129 60..=69 => Self::D,
130 _ => Self::F,
131 }
132 }
133
134 #[must_use]
136 pub const fn letter(&self) -> &'static str {
137 match self {
138 Self::A => "A",
139 Self::B => "B",
140 Self::C => "C",
141 Self::D => "D",
142 Self::F => "F",
143 }
144 }
145
146 #[must_use]
148 pub const fn description(&self) -> &'static str {
149 match self {
150 Self::A => "Excellent",
151 Self::B => "Good",
152 Self::C => "Fair",
153 Self::D => "Poor",
154 Self::F => "Failing",
155 }
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Recommendation {
162 pub priority: u8,
164 pub category: RecommendationCategory,
166 pub message: String,
168 pub impact: f32,
170 pub affected_count: usize,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[non_exhaustive]
177pub enum RecommendationCategory {
178 Completeness,
179 Identifiers,
180 Licenses,
181 Vulnerabilities,
182 Dependencies,
183 Compliance,
184}
185
186impl RecommendationCategory {
187 #[must_use]
188 pub const fn name(&self) -> &'static str {
189 match self {
190 Self::Completeness => "Completeness",
191 Self::Identifiers => "Identifiers",
192 Self::Licenses => "Licenses",
193 Self::Vulnerabilities => "Vulnerabilities",
194 Self::Dependencies => "Dependencies",
195 Self::Compliance => "Compliance",
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202#[must_use]
203pub struct QualityReport {
204 pub overall_score: f32,
206 pub grade: QualityGrade,
208 pub profile: ScoringProfile,
210 pub completeness_score: f32,
212 pub identifier_score: f32,
214 pub license_score: f32,
216 pub vulnerability_score: f32,
218 pub dependency_score: f32,
220 pub completeness_metrics: CompletenessMetrics,
222 pub identifier_metrics: IdentifierMetrics,
224 pub license_metrics: LicenseMetrics,
226 pub vulnerability_metrics: VulnerabilityMetrics,
228 pub dependency_metrics: DependencyMetrics,
230 pub compliance: ComplianceResult,
232 pub recommendations: Vec<Recommendation>,
234}
235
236#[derive(Debug, Clone)]
238pub struct QualityScorer {
239 profile: ScoringProfile,
241 completeness_weights: CompletenessWeights,
243}
244
245impl QualityScorer {
246 #[must_use]
248 pub fn new(profile: ScoringProfile) -> Self {
249 Self {
250 profile,
251 completeness_weights: CompletenessWeights::default(),
252 }
253 }
254
255 #[must_use]
257 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
258 self.completeness_weights = weights;
259 self
260 }
261
262 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
264 let total_components = sbom.components.len();
265
266 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
268 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
269 let license_metrics = LicenseMetrics::from_sbom(sbom);
270 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
271 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
272
273 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
275 let identifier_score = identifier_metrics.quality_score(total_components);
276 let license_score = license_metrics.quality_score(total_components);
277 let vulnerability_score = vulnerability_metrics.documentation_score();
278 let dependency_score = dependency_metrics.quality_score(total_components);
279
280 let weights = self.profile.weights();
282 let overall_score = dependency_score.mul_add(
283 weights.dependencies,
284 vulnerability_score.mul_add(
285 weights.vulnerabilities,
286 license_score.mul_add(
287 weights.licenses,
288 completeness_score.mul_add(weights.completeness, identifier_score * weights.identifiers),
289 ),
290 ),
291 )
292 .min(100.0);
293
294 let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
296 let compliance = compliance_checker.check(sbom);
297
298 let recommendations = self.generate_recommendations(
300 &completeness_metrics,
301 &identifier_metrics,
302 &license_metrics,
303 &dependency_metrics,
304 &compliance,
305 total_components,
306 );
307
308 QualityReport {
309 overall_score,
310 grade: QualityGrade::from_score(overall_score),
311 profile: self.profile,
312 completeness_score,
313 identifier_score,
314 license_score,
315 vulnerability_score,
316 dependency_score,
317 completeness_metrics,
318 identifier_metrics,
319 license_metrics,
320 vulnerability_metrics,
321 dependency_metrics,
322 compliance,
323 recommendations,
324 }
325 }
326
327 fn generate_recommendations(
328 &self,
329 completeness: &CompletenessMetrics,
330 identifiers: &IdentifierMetrics,
331 licenses: &LicenseMetrics,
332 dependencies: &DependencyMetrics,
333 compliance: &ComplianceResult,
334 total_components: usize,
335 ) -> Vec<Recommendation> {
336 let mut recommendations = Vec::new();
337
338 if compliance.error_count > 0 {
340 recommendations.push(Recommendation {
341 priority: 1,
342 category: RecommendationCategory::Compliance,
343 message: format!(
344 "Fix {} compliance error(s) to meet {} requirements",
345 compliance.error_count,
346 compliance.level.name()
347 ),
348 impact: 20.0,
349 affected_count: compliance.error_count,
350 });
351 }
352
353 let missing_versions = total_components
355 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
356 if missing_versions > 0 {
357 recommendations.push(Recommendation {
358 priority: 1,
359 category: RecommendationCategory::Completeness,
360 message: "Add version information to all components".to_string(),
361 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
362 affected_count: missing_versions,
363 });
364 }
365
366 if identifiers.missing_all_identifiers > 0 {
368 recommendations.push(Recommendation {
369 priority: 2,
370 category: RecommendationCategory::Identifiers,
371 message: "Add PURL or CPE identifiers to components".to_string(),
372 impact: (identifiers.missing_all_identifiers as f32
373 / total_components.max(1) as f32)
374 * 20.0,
375 affected_count: identifiers.missing_all_identifiers,
376 });
377 }
378
379 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
381 if invalid_ids > 0 {
382 recommendations.push(Recommendation {
383 priority: 2,
384 category: RecommendationCategory::Identifiers,
385 message: "Fix malformed PURL/CPE identifiers".to_string(),
386 impact: 10.0,
387 affected_count: invalid_ids,
388 });
389 }
390
391 let missing_licenses = total_components - licenses.with_declared;
393 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
394 {
395 recommendations.push(Recommendation {
396 priority: 3,
397 category: RecommendationCategory::Licenses,
398 message: "Add license information to components".to_string(),
399 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
400 affected_count: missing_licenses,
401 });
402 }
403
404 if licenses.noassertion_count > 0 {
406 recommendations.push(Recommendation {
407 priority: 3,
408 category: RecommendationCategory::Licenses,
409 message: "Replace NOASSERTION with actual license information".to_string(),
410 impact: 5.0,
411 affected_count: licenses.noassertion_count,
412 });
413 }
414
415 if licenses.non_standard_licenses > 0 {
417 recommendations.push(Recommendation {
418 priority: 4,
419 category: RecommendationCategory::Licenses,
420 message: "Use SPDX license identifiers for better interoperability".to_string(),
421 impact: 3.0,
422 affected_count: licenses.non_standard_licenses,
423 });
424 }
425
426 if total_components > 1 && dependencies.total_dependencies == 0 {
428 recommendations.push(Recommendation {
429 priority: 4,
430 category: RecommendationCategory::Dependencies,
431 message: "Add dependency relationships between components".to_string(),
432 impact: 10.0,
433 affected_count: total_components,
434 });
435 }
436
437 if dependencies.orphan_components > 1
439 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
440 {
441 recommendations.push(Recommendation {
442 priority: 4,
443 category: RecommendationCategory::Dependencies,
444 message: "Review orphan components that have no dependency relationships"
445 .to_string(),
446 impact: 5.0,
447 affected_count: dependencies.orphan_components,
448 });
449 }
450
451 let missing_suppliers = total_components
453 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
454 if missing_suppliers > 0
455 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
456 {
457 recommendations.push(Recommendation {
458 priority: 5,
459 category: RecommendationCategory::Completeness,
460 message: "Add supplier information to components".to_string(),
461 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
462 affected_count: missing_suppliers,
463 });
464 }
465
466 let missing_hashes = total_components
468 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
469 if missing_hashes > 0
470 && matches!(
471 self.profile,
472 ScoringProfile::Security | ScoringProfile::Comprehensive
473 )
474 {
475 recommendations.push(Recommendation {
476 priority: 5,
477 category: RecommendationCategory::Completeness,
478 message: "Add cryptographic hashes for integrity verification".to_string(),
479 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
480 affected_count: missing_hashes,
481 });
482 }
483
484 recommendations.sort_by(|a, b| {
486 a.priority.cmp(&b.priority).then_with(|| {
487 b.impact
488 .partial_cmp(&a.impact)
489 .unwrap_or(std::cmp::Ordering::Equal)
490 })
491 });
492
493 recommendations
494 }
495}
496
497impl Default for QualityScorer {
498 fn default() -> Self {
499 Self::new(ScoringProfile::Standard)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_grade_from_score() {
509 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
510 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
511 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
512 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
513 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
514 }
515
516 #[test]
517 fn test_scoring_profile_compliance_level() {
518 assert_eq!(
519 ScoringProfile::Minimal.compliance_level(),
520 ComplianceLevel::Minimum
521 );
522 assert_eq!(
523 ScoringProfile::Security.compliance_level(),
524 ComplianceLevel::NtiaMinimum
525 );
526 assert_eq!(
527 ScoringProfile::Comprehensive.compliance_level(),
528 ComplianceLevel::Comprehensive
529 );
530 }
531}