Skip to main content

sbom_tools/quality/
scorer.rs

1//! SBOM Quality Scorer.
2//!
3//! Main scoring engine that combines metrics and compliance checking
4//! into an overall quality assessment.
5
6use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11    AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, DependencyMetrics,
12    HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics, ProvenanceMetrics,
13    VulnerabilityMetrics,
14};
15
16/// Quality scoring engine version
17pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19/// Scoring profile determines weights and thresholds
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23    /// Minimal requirements - basic identification
24    Minimal,
25    /// Standard requirements - recommended for most use cases
26    Standard,
27    /// Security-focused - emphasizes vulnerability info and supply chain
28    Security,
29    /// License-focused - emphasizes license compliance
30    LicenseCompliance,
31    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
32    Cra,
33    /// Comprehensive - all aspects equally weighted
34    Comprehensive,
35}
36
37impl ScoringProfile {
38    /// Get the compliance level associated with this profile
39    #[must_use]
40    pub const fn compliance_level(&self) -> ComplianceLevel {
41        match self {
42            Self::Minimal => ComplianceLevel::Minimum,
43            Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
44            Self::Security => ComplianceLevel::NtiaMinimum,
45            Self::Cra => ComplianceLevel::CraPhase2,
46            Self::Comprehensive => ComplianceLevel::Comprehensive,
47        }
48    }
49
50    /// Get weights for this profile
51    ///
52    /// All weights sum to 1.0. The lifecycle weight is applied only when
53    /// enrichment data is available; otherwise it is redistributed.
54    const fn weights(self) -> ScoringWeights {
55        match self {
56            Self::Minimal => ScoringWeights {
57                completeness: 0.35,
58                identifiers: 0.20,
59                licenses: 0.10,
60                vulnerabilities: 0.05,
61                dependencies: 0.10,
62                integrity: 0.05,
63                provenance: 0.10,
64                lifecycle: 0.05,
65            },
66            Self::Standard => ScoringWeights {
67                completeness: 0.25,
68                identifiers: 0.20,
69                licenses: 0.12,
70                vulnerabilities: 0.08,
71                dependencies: 0.10,
72                integrity: 0.08,
73                provenance: 0.10,
74                lifecycle: 0.07,
75            },
76            Self::Security => ScoringWeights {
77                completeness: 0.12,
78                identifiers: 0.18,
79                licenses: 0.05,
80                vulnerabilities: 0.20,
81                dependencies: 0.10,
82                integrity: 0.15,
83                provenance: 0.10,
84                lifecycle: 0.10,
85            },
86            Self::LicenseCompliance => ScoringWeights {
87                completeness: 0.15,
88                identifiers: 0.12,
89                licenses: 0.35,
90                vulnerabilities: 0.05,
91                dependencies: 0.10,
92                integrity: 0.05,
93                provenance: 0.10,
94                lifecycle: 0.08,
95            },
96            Self::Cra => ScoringWeights {
97                completeness: 0.12,
98                identifiers: 0.18,
99                licenses: 0.08,
100                vulnerabilities: 0.15,
101                dependencies: 0.12,
102                integrity: 0.12,
103                provenance: 0.15,
104                lifecycle: 0.08,
105            },
106            Self::Comprehensive => ScoringWeights {
107                completeness: 0.15,
108                identifiers: 0.13,
109                licenses: 0.13,
110                vulnerabilities: 0.10,
111                dependencies: 0.12,
112                integrity: 0.12,
113                provenance: 0.13,
114                lifecycle: 0.12,
115            },
116        }
117    }
118}
119
120/// Weights for overall score calculation (sum to 1.0)
121#[derive(Debug, Clone)]
122struct ScoringWeights {
123    completeness: f32,
124    identifiers: f32,
125    licenses: f32,
126    vulnerabilities: f32,
127    dependencies: f32,
128    integrity: f32,
129    provenance: f32,
130    lifecycle: f32,
131}
132
133impl ScoringWeights {
134    /// Return weights as an array for iteration
135    fn as_array(&self) -> [f32; 8] {
136        [
137            self.completeness,
138            self.identifiers,
139            self.licenses,
140            self.vulnerabilities,
141            self.dependencies,
142            self.integrity,
143            self.provenance,
144            self.lifecycle,
145        ]
146    }
147
148    /// Renormalize weights, excluding categories marked as N/A.
149    ///
150    /// When a category has no applicable data (e.g., lifecycle without
151    /// enrichment), its weight is proportionally redistributed.
152    fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
153        let raw = self.as_array();
154        let total_available: f32 = raw
155            .iter()
156            .zip(available)
157            .filter(|&(_, a)| *a)
158            .map(|(w, _)| w)
159            .sum();
160
161        if total_available <= 0.0 {
162            return [0.0; 8];
163        }
164
165        let scale = 1.0 / total_available;
166        let mut result = [0.0_f32; 8];
167        for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
168            result[i] = if avail { w * scale } else { 0.0 };
169        }
170        result
171    }
172}
173
174/// Quality grade based on score
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[non_exhaustive]
177pub enum QualityGrade {
178    /// Excellent: 90-100
179    A,
180    /// Good: 80-89
181    B,
182    /// Fair: 70-79
183    C,
184    /// Poor: 60-69
185    D,
186    /// Failing: <60
187    F,
188}
189
190impl QualityGrade {
191    /// Create grade from score
192    #[must_use]
193    pub const fn from_score(score: f32) -> Self {
194        match score as u32 {
195            90..=100 => Self::A,
196            80..=89 => Self::B,
197            70..=79 => Self::C,
198            60..=69 => Self::D,
199            _ => Self::F,
200        }
201    }
202
203    /// Get grade letter
204    #[must_use]
205    pub const fn letter(&self) -> &'static str {
206        match self {
207            Self::A => "A",
208            Self::B => "B",
209            Self::C => "C",
210            Self::D => "D",
211            Self::F => "F",
212        }
213    }
214
215    /// Get grade description
216    #[must_use]
217    pub const fn description(&self) -> &'static str {
218        match self {
219            Self::A => "Excellent",
220            Self::B => "Good",
221            Self::C => "Fair",
222            Self::D => "Poor",
223            Self::F => "Failing",
224        }
225    }
226}
227
228/// Recommendation for improving quality
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Recommendation {
231    /// Priority (1 = highest, 5 = lowest)
232    pub priority: u8,
233    /// Category of the recommendation
234    pub category: RecommendationCategory,
235    /// Human-readable message
236    pub message: String,
237    /// Estimated impact on score (0-100)
238    pub impact: f32,
239    /// Affected components (if applicable)
240    pub affected_count: usize,
241}
242
243/// Category for recommendations
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[non_exhaustive]
246pub enum RecommendationCategory {
247    Completeness,
248    Identifiers,
249    Licenses,
250    Vulnerabilities,
251    Dependencies,
252    Compliance,
253    Integrity,
254    Provenance,
255    Lifecycle,
256}
257
258impl RecommendationCategory {
259    #[must_use]
260    pub const fn name(&self) -> &'static str {
261        match self {
262            Self::Completeness => "Completeness",
263            Self::Identifiers => "Identifiers",
264            Self::Licenses => "Licenses",
265            Self::Vulnerabilities => "Vulnerabilities",
266            Self::Dependencies => "Dependencies",
267            Self::Compliance => "Compliance",
268            Self::Integrity => "Integrity",
269            Self::Provenance => "Provenance",
270            Self::Lifecycle => "Lifecycle",
271        }
272    }
273}
274
275/// Complete quality report for an SBOM
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[must_use]
278pub struct QualityReport {
279    /// Scoring engine version
280    pub scoring_engine_version: String,
281    /// Overall score (0-100)
282    pub overall_score: f32,
283    /// Overall grade
284    pub grade: QualityGrade,
285    /// Scoring profile used
286    pub profile: ScoringProfile,
287
288    // Individual category scores (0-100)
289    /// Completeness score
290    pub completeness_score: f32,
291    /// Identifier quality score
292    pub identifier_score: f32,
293    /// License quality score
294    pub license_score: f32,
295    /// Vulnerability documentation score
296    pub vulnerability_score: f32,
297    /// Dependency graph quality score
298    pub dependency_score: f32,
299    /// Hash/integrity quality score
300    pub integrity_score: f32,
301    /// Provenance quality score (combined provenance + auditability)
302    pub provenance_score: f32,
303    /// Lifecycle quality score (`None` if no enrichment data)
304    pub lifecycle_score: Option<f32>,
305
306    // Detailed metrics
307    /// Detailed completeness metrics
308    pub completeness_metrics: CompletenessMetrics,
309    /// Detailed identifier metrics
310    pub identifier_metrics: IdentifierMetrics,
311    /// Detailed license metrics
312    pub license_metrics: LicenseMetrics,
313    /// Detailed vulnerability metrics
314    pub vulnerability_metrics: VulnerabilityMetrics,
315    /// Detailed dependency metrics
316    pub dependency_metrics: DependencyMetrics,
317    /// Hash/integrity metrics
318    pub hash_quality_metrics: HashQualityMetrics,
319    /// Provenance metrics
320    pub provenance_metrics: ProvenanceMetrics,
321    /// Auditability metrics
322    pub auditability_metrics: AuditabilityMetrics,
323    /// Lifecycle metrics (enrichment-dependent)
324    pub lifecycle_metrics: LifecycleMetrics,
325
326    /// Compliance check result
327    pub compliance: ComplianceResult,
328    /// Prioritized recommendations
329    pub recommendations: Vec<Recommendation>,
330}
331
332/// Quality scorer for SBOMs
333#[derive(Debug, Clone)]
334pub struct QualityScorer {
335    /// Scoring profile
336    profile: ScoringProfile,
337    /// Completeness weights
338    completeness_weights: CompletenessWeights,
339}
340
341impl QualityScorer {
342    /// Create a new quality scorer with the given profile
343    #[must_use]
344    pub fn new(profile: ScoringProfile) -> Self {
345        Self {
346            profile,
347            completeness_weights: CompletenessWeights::default(),
348        }
349    }
350
351    /// Set custom completeness weights
352    #[must_use]
353    pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
354        self.completeness_weights = weights;
355        self
356    }
357
358    /// Score an SBOM
359    pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
360        let total_components = sbom.components.len();
361        let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
362
363        // Calculate all metrics
364        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
365        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
366        let license_metrics = LicenseMetrics::from_sbom(sbom);
367        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
368        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
369        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
370        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
371        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
372        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
373
374        // Calculate individual category scores
375        let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
376        let identifier_score = identifier_metrics.quality_score(total_components);
377        let license_score = license_metrics.quality_score(total_components);
378        let vulnerability_score = vulnerability_metrics.documentation_score();
379        let dependency_score = dependency_metrics.quality_score(total_components);
380        let integrity_score = hash_quality_metrics.quality_score(total_components);
381        let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
382        let auditability_raw = auditability_metrics.quality_score(total_components);
383        // Combine provenance and auditability (60/40 split)
384        let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
385        let lifecycle_score = lifecycle_metrics.quality_score();
386
387        // Determine which categories are available
388        let lifecycle_available = lifecycle_score.is_some();
389        let available = [
390            true,               // completeness
391            true,               // identifiers
392            true,               // licenses
393            true,               // vulnerabilities
394            true,               // dependencies
395            true,               // integrity
396            true,               // provenance
397            lifecycle_available, // lifecycle
398        ];
399
400        // Calculate weighted overall score with N/A renormalization
401        let weights = self.profile.weights();
402        let norm = weights.renormalize(&available);
403        let scores = [
404            completeness_score,
405            identifier_score,
406            license_score,
407            vulnerability_score,
408            dependency_score,
409            integrity_score,
410            provenance_score,
411            lifecycle_score.unwrap_or(0.0),
412        ];
413
414        let mut overall_score: f32 = scores
415            .iter()
416            .zip(norm.iter())
417            .map(|(s, w)| s * w)
418            .sum();
419        overall_score = overall_score.min(100.0);
420
421        // Apply hard penalty caps for critical issues
422        overall_score = self.apply_score_caps(
423            overall_score,
424            &lifecycle_metrics,
425            &dependency_metrics,
426            &hash_quality_metrics,
427            total_components,
428        );
429
430        // Run compliance check
431        let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
432        let compliance = compliance_checker.check(sbom);
433
434        // Generate recommendations
435        let recommendations = self.generate_recommendations(
436            &completeness_metrics,
437            &identifier_metrics,
438            &license_metrics,
439            &dependency_metrics,
440            &hash_quality_metrics,
441            &provenance_metrics,
442            &lifecycle_metrics,
443            &compliance,
444            total_components,
445        );
446
447        QualityReport {
448            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
449            overall_score,
450            grade: QualityGrade::from_score(overall_score),
451            profile: self.profile,
452            completeness_score,
453            identifier_score,
454            license_score,
455            vulnerability_score,
456            dependency_score,
457            integrity_score,
458            provenance_score,
459            lifecycle_score,
460            completeness_metrics,
461            identifier_metrics,
462            license_metrics,
463            vulnerability_metrics,
464            dependency_metrics,
465            hash_quality_metrics,
466            provenance_metrics,
467            auditability_metrics,
468            lifecycle_metrics,
469            compliance,
470            recommendations,
471        }
472    }
473
474    /// Apply hard score caps for critical issues
475    fn apply_score_caps(
476        &self,
477        mut score: f32,
478        lifecycle: &LifecycleMetrics,
479        deps: &DependencyMetrics,
480        hashes: &HashQualityMetrics,
481        total_components: usize,
482    ) -> f32 {
483        let is_security_profile = matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
484
485        // EOL components: cap at D grade for security-focused profiles
486        if is_security_profile && lifecycle.eol_components > 0 {
487            score = score.min(69.0);
488        }
489
490        // Dependency cycles: cap at B grade
491        if deps.cycle_count > 0
492            && matches!(
493                self.profile,
494                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
495            )
496        {
497            score = score.min(89.0);
498        }
499
500        // No hashes at all: cap at C grade for Security profile
501        if matches!(self.profile, ScoringProfile::Security)
502            && total_components > 0
503            && hashes.components_with_any_hash == 0
504        {
505            score = score.min(79.0);
506        }
507
508        // Weak-only hashes: cap at B grade for Security profile
509        if matches!(self.profile, ScoringProfile::Security)
510            && hashes.components_with_weak_only > 0
511            && hashes.components_with_strong_hash == 0
512        {
513            score = score.min(89.0);
514        }
515
516        score
517    }
518
519    #[allow(clippy::too_many_arguments)]
520    fn generate_recommendations(
521        &self,
522        completeness: &CompletenessMetrics,
523        identifiers: &IdentifierMetrics,
524        licenses: &LicenseMetrics,
525        dependencies: &DependencyMetrics,
526        hashes: &HashQualityMetrics,
527        provenance: &ProvenanceMetrics,
528        lifecycle: &LifecycleMetrics,
529        compliance: &ComplianceResult,
530        total_components: usize,
531    ) -> Vec<Recommendation> {
532        let mut recommendations = Vec::new();
533
534        // Priority 1: Compliance errors
535        if compliance.error_count > 0 {
536            recommendations.push(Recommendation {
537                priority: 1,
538                category: RecommendationCategory::Compliance,
539                message: format!(
540                    "Fix {} compliance error(s) to meet {} requirements",
541                    compliance.error_count,
542                    compliance.level.name()
543                ),
544                impact: 20.0,
545                affected_count: compliance.error_count,
546            });
547        }
548
549        // Priority 1: EOL components
550        if lifecycle.eol_components > 0 {
551            recommendations.push(Recommendation {
552                priority: 1,
553                category: RecommendationCategory::Lifecycle,
554                message: format!(
555                    "{} component(s) have reached end-of-life — upgrade or replace",
556                    lifecycle.eol_components
557                ),
558                impact: 15.0,
559                affected_count: lifecycle.eol_components,
560            });
561        }
562
563        // Priority 1: Missing versions (critical for identification)
564        let missing_versions = total_components
565            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
566        if missing_versions > 0 {
567            recommendations.push(Recommendation {
568                priority: 1,
569                category: RecommendationCategory::Completeness,
570                message: "Add version information to all components".to_string(),
571                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
572                affected_count: missing_versions,
573            });
574        }
575
576        // Priority 2: Weak-only hashes
577        if hashes.components_with_weak_only > 0 {
578            recommendations.push(Recommendation {
579                priority: 2,
580                category: RecommendationCategory::Integrity,
581                message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
582                impact: 10.0,
583                affected_count: hashes.components_with_weak_only,
584            });
585        }
586
587        // Priority 2: Missing PURLs (important for identification)
588        if identifiers.missing_all_identifiers > 0 {
589            recommendations.push(Recommendation {
590                priority: 2,
591                category: RecommendationCategory::Identifiers,
592                message: "Add PURL or CPE identifiers to components".to_string(),
593                impact: (identifiers.missing_all_identifiers as f32
594                    / total_components.max(1) as f32)
595                    * 20.0,
596                affected_count: identifiers.missing_all_identifiers,
597            });
598        }
599
600        // Priority 2: Invalid identifiers
601        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
602        if invalid_ids > 0 {
603            recommendations.push(Recommendation {
604                priority: 2,
605                category: RecommendationCategory::Identifiers,
606                message: "Fix malformed PURL/CPE identifiers".to_string(),
607                impact: 10.0,
608                affected_count: invalid_ids,
609            });
610        }
611
612        // Priority 2: Missing tool creator info
613        if !provenance.has_tool_creator {
614            recommendations.push(Recommendation {
615                priority: 2,
616                category: RecommendationCategory::Provenance,
617                message: "Add SBOM creation tool information".to_string(),
618                impact: 8.0,
619                affected_count: 0,
620            });
621        }
622
623        // Priority 3: Dependency cycles
624        if dependencies.cycle_count > 0 {
625            recommendations.push(Recommendation {
626                priority: 3,
627                category: RecommendationCategory::Dependencies,
628                message: format!(
629                    "{} dependency cycle(s) detected — review dependency graph",
630                    dependencies.cycle_count
631                ),
632                impact: 10.0,
633                affected_count: dependencies.cycle_count,
634            });
635        }
636
637        // Priority 3: Missing licenses
638        let missing_licenses = total_components - licenses.with_declared;
639        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
640        {
641            recommendations.push(Recommendation {
642                priority: 3,
643                category: RecommendationCategory::Licenses,
644                message: "Add license information to components".to_string(),
645                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
646                affected_count: missing_licenses,
647            });
648        }
649
650        // Priority 3: NOASSERTION licenses
651        if licenses.noassertion_count > 0 {
652            recommendations.push(Recommendation {
653                priority: 3,
654                category: RecommendationCategory::Licenses,
655                message: "Replace NOASSERTION with actual license information".to_string(),
656                impact: 5.0,
657                affected_count: licenses.noassertion_count,
658            });
659        }
660
661        // Priority 3: VCS URL coverage
662        if total_components > 0 {
663            let missing_vcs = total_components
664                .saturating_sub(
665                    ((completeness.components_with_hashes / 100.0) * total_components as f32)
666                        as usize,
667                );
668            if missing_vcs > total_components / 2 {
669                recommendations.push(Recommendation {
670                    priority: 3,
671                    category: RecommendationCategory::Provenance,
672                    message: "Add VCS (source repository) URLs to components".to_string(),
673                    impact: 5.0,
674                    affected_count: missing_vcs,
675                });
676            }
677        }
678
679        // Priority 4: Non-standard licenses
680        if licenses.non_standard_licenses > 0 {
681            recommendations.push(Recommendation {
682                priority: 4,
683                category: RecommendationCategory::Licenses,
684                message: "Use SPDX license identifiers for better interoperability".to_string(),
685                impact: 3.0,
686                affected_count: licenses.non_standard_licenses,
687            });
688        }
689
690        // Priority 4: Outdated components
691        if lifecycle.outdated_components > 0 {
692            recommendations.push(Recommendation {
693                priority: 4,
694                category: RecommendationCategory::Lifecycle,
695                message: format!(
696                    "{} component(s) are outdated — newer versions available",
697                    lifecycle.outdated_components
698                ),
699                impact: 5.0,
700                affected_count: lifecycle.outdated_components,
701            });
702        }
703
704        // Priority 4: Missing completeness declaration
705        if provenance.completeness_declaration == CompletenessDeclaration::Unknown
706            && matches!(
707                self.profile,
708                ScoringProfile::Cra | ScoringProfile::Comprehensive
709            )
710        {
711            recommendations.push(Recommendation {
712                priority: 4,
713                category: RecommendationCategory::Provenance,
714                message: "Add compositions section with aggregate completeness declaration"
715                    .to_string(),
716                impact: 5.0,
717                affected_count: 0,
718            });
719        }
720
721        // Priority 4: Missing dependency information
722        if total_components > 1 && dependencies.total_dependencies == 0 {
723            recommendations.push(Recommendation {
724                priority: 4,
725                category: RecommendationCategory::Dependencies,
726                message: "Add dependency relationships between components".to_string(),
727                impact: 10.0,
728                affected_count: total_components,
729            });
730        }
731
732        // Priority 4: Many orphan components
733        if dependencies.orphan_components > 1
734            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
735        {
736            recommendations.push(Recommendation {
737                priority: 4,
738                category: RecommendationCategory::Dependencies,
739                message: "Review orphan components that have no dependency relationships"
740                    .to_string(),
741                impact: 5.0,
742                affected_count: dependencies.orphan_components,
743            });
744        }
745
746        // Priority 5: Missing supplier information
747        let missing_suppliers = total_components
748            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
749        if missing_suppliers > 0
750            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
751        {
752            recommendations.push(Recommendation {
753                priority: 5,
754                category: RecommendationCategory::Completeness,
755                message: "Add supplier information to components".to_string(),
756                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
757                affected_count: missing_suppliers,
758            });
759        }
760
761        // Priority 5: Missing hashes
762        let missing_hashes = total_components
763            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
764        if missing_hashes > 0
765            && matches!(
766                self.profile,
767                ScoringProfile::Security | ScoringProfile::Comprehensive
768            )
769        {
770            recommendations.push(Recommendation {
771                priority: 5,
772                category: RecommendationCategory::Integrity,
773                message: "Add cryptographic hashes for integrity verification".to_string(),
774                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
775                affected_count: missing_hashes,
776            });
777        }
778
779        // Priority 5: Consider SBOM signing (only if not already signed)
780        if !provenance.has_signature
781            && matches!(
782                self.profile,
783                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
784            )
785        {
786            recommendations.push(Recommendation {
787                priority: 5,
788                category: RecommendationCategory::Integrity,
789                message: "Consider adding a digital signature to the SBOM".to_string(),
790                impact: 3.0,
791                affected_count: 0,
792            });
793        }
794
795        // Sort by priority, then by impact
796        recommendations.sort_by(|a, b| {
797            a.priority.cmp(&b.priority).then_with(|| {
798                b.impact
799                    .partial_cmp(&a.impact)
800                    .unwrap_or(std::cmp::Ordering::Equal)
801            })
802        });
803
804        recommendations
805    }
806}
807
808impl Default for QualityScorer {
809    fn default() -> Self {
810        Self::new(ScoringProfile::Standard)
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    #[test]
819    fn test_grade_from_score() {
820        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
821        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
822        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
823        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
824        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
825    }
826
827    #[test]
828    fn test_scoring_profile_compliance_level() {
829        assert_eq!(
830            ScoringProfile::Minimal.compliance_level(),
831            ComplianceLevel::Minimum
832        );
833        assert_eq!(
834            ScoringProfile::Security.compliance_level(),
835            ComplianceLevel::NtiaMinimum
836        );
837        assert_eq!(
838            ScoringProfile::Comprehensive.compliance_level(),
839            ComplianceLevel::Comprehensive
840        );
841    }
842
843    #[test]
844    fn test_scoring_weights_sum_to_one() {
845        let profiles = [
846            ScoringProfile::Minimal,
847            ScoringProfile::Standard,
848            ScoringProfile::Security,
849            ScoringProfile::LicenseCompliance,
850            ScoringProfile::Cra,
851            ScoringProfile::Comprehensive,
852        ];
853        for profile in &profiles {
854            let w = profile.weights();
855            let sum: f32 = w.as_array().iter().sum();
856            assert!(
857                (sum - 1.0).abs() < 0.01,
858                "{profile:?} weights sum to {sum}, expected 1.0"
859            );
860        }
861    }
862
863    #[test]
864    fn test_renormalize_all_available() {
865        let w = ScoringProfile::Standard.weights();
866        let available = [true; 8];
867        let norm = w.renormalize(&available);
868        let sum: f32 = norm.iter().sum();
869        assert!((sum - 1.0).abs() < 0.001);
870    }
871
872    #[test]
873    fn test_renormalize_lifecycle_unavailable() {
874        let w = ScoringProfile::Standard.weights();
875        let mut available = [true; 8];
876        available[7] = false; // lifecycle
877        let norm = w.renormalize(&available);
878        let sum: f32 = norm.iter().sum();
879        assert!((sum - 1.0).abs() < 0.001);
880        assert_eq!(norm[7], 0.0);
881    }
882
883    #[test]
884    fn test_scoring_engine_version() {
885        assert_eq!(SCORING_ENGINE_VERSION, "2.0");
886    }
887}