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 (`None` if no vulnerability data)
296    pub vulnerability_score: Option<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 vuln_available = vulnerability_score.is_some();
389        let lifecycle_available = lifecycle_score.is_some();
390        let available = [
391            true,                // completeness
392            true,                // identifiers
393            true,                // licenses
394            vuln_available,      // vulnerabilities
395            true,                // dependencies
396            true,                // integrity
397            true,                // provenance
398            lifecycle_available, // lifecycle
399        ];
400
401        // Calculate weighted overall score with N/A renormalization
402        let weights = self.profile.weights();
403        let norm = weights.renormalize(&available);
404        let scores = [
405            completeness_score,
406            identifier_score,
407            license_score,
408            vulnerability_score.unwrap_or(0.0),
409            dependency_score,
410            integrity_score,
411            provenance_score,
412            lifecycle_score.unwrap_or(0.0),
413        ];
414
415        let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
416        overall_score = overall_score.min(100.0);
417
418        // Apply hard penalty caps for critical issues
419        overall_score = self.apply_score_caps(
420            overall_score,
421            &lifecycle_metrics,
422            &dependency_metrics,
423            &hash_quality_metrics,
424            total_components,
425        );
426
427        // Run compliance check
428        let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
429        let compliance = compliance_checker.check(sbom);
430
431        // Generate recommendations
432        let recommendations = self.generate_recommendations(
433            &completeness_metrics,
434            &identifier_metrics,
435            &license_metrics,
436            &dependency_metrics,
437            &hash_quality_metrics,
438            &provenance_metrics,
439            &lifecycle_metrics,
440            &compliance,
441            total_components,
442        );
443
444        QualityReport {
445            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
446            overall_score,
447            grade: QualityGrade::from_score(overall_score),
448            profile: self.profile,
449            completeness_score,
450            identifier_score,
451            license_score,
452            vulnerability_score,
453            dependency_score,
454            integrity_score,
455            provenance_score,
456            lifecycle_score,
457            completeness_metrics,
458            identifier_metrics,
459            license_metrics,
460            vulnerability_metrics,
461            dependency_metrics,
462            hash_quality_metrics,
463            provenance_metrics,
464            auditability_metrics,
465            lifecycle_metrics,
466            compliance,
467            recommendations,
468        }
469    }
470
471    /// Apply hard score caps for critical issues
472    fn apply_score_caps(
473        &self,
474        mut score: f32,
475        lifecycle: &LifecycleMetrics,
476        deps: &DependencyMetrics,
477        hashes: &HashQualityMetrics,
478        total_components: usize,
479    ) -> f32 {
480        let is_security_profile =
481            matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
482
483        // EOL components: cap at D grade for security-focused profiles
484        if is_security_profile && lifecycle.eol_components > 0 {
485            score = score.min(69.0);
486        }
487
488        // Dependency cycles: cap at B grade
489        if deps.cycle_count > 0
490            && matches!(
491                self.profile,
492                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
493            )
494        {
495            score = score.min(89.0);
496        }
497
498        // No hashes at all: cap at C grade for Security profile
499        if matches!(self.profile, ScoringProfile::Security)
500            && total_components > 0
501            && hashes.components_with_any_hash == 0
502        {
503            score = score.min(79.0);
504        }
505
506        // Weak-only hashes: cap at B grade for Security profile
507        if matches!(self.profile, ScoringProfile::Security)
508            && hashes.components_with_weak_only > 0
509            && hashes.components_with_strong_hash == 0
510        {
511            score = score.min(89.0);
512        }
513
514        score
515    }
516
517    #[allow(clippy::too_many_arguments)]
518    fn generate_recommendations(
519        &self,
520        completeness: &CompletenessMetrics,
521        identifiers: &IdentifierMetrics,
522        licenses: &LicenseMetrics,
523        dependencies: &DependencyMetrics,
524        hashes: &HashQualityMetrics,
525        provenance: &ProvenanceMetrics,
526        lifecycle: &LifecycleMetrics,
527        compliance: &ComplianceResult,
528        total_components: usize,
529    ) -> Vec<Recommendation> {
530        let mut recommendations = Vec::new();
531
532        // Priority 1: Compliance errors
533        if compliance.error_count > 0 {
534            recommendations.push(Recommendation {
535                priority: 1,
536                category: RecommendationCategory::Compliance,
537                message: format!(
538                    "Fix {} compliance error(s) to meet {} requirements",
539                    compliance.error_count,
540                    compliance.level.name()
541                ),
542                impact: 20.0,
543                affected_count: compliance.error_count,
544            });
545        }
546
547        // Priority 1: EOL components
548        if lifecycle.eol_components > 0 {
549            recommendations.push(Recommendation {
550                priority: 1,
551                category: RecommendationCategory::Lifecycle,
552                message: format!(
553                    "{} component(s) have reached end-of-life — upgrade or replace",
554                    lifecycle.eol_components
555                ),
556                impact: 15.0,
557                affected_count: lifecycle.eol_components,
558            });
559        }
560
561        // Priority 1: Missing versions (critical for identification)
562        let missing_versions = total_components
563            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
564        if missing_versions > 0 {
565            recommendations.push(Recommendation {
566                priority: 1,
567                category: RecommendationCategory::Completeness,
568                message: "Add version information to all components".to_string(),
569                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
570                affected_count: missing_versions,
571            });
572        }
573
574        // Priority 2: Weak-only hashes
575        if hashes.components_with_weak_only > 0 {
576            recommendations.push(Recommendation {
577                priority: 2,
578                category: RecommendationCategory::Integrity,
579                message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
580                impact: 10.0,
581                affected_count: hashes.components_with_weak_only,
582            });
583        }
584
585        // Priority 2: Missing PURLs (important for identification)
586        if identifiers.missing_all_identifiers > 0 {
587            recommendations.push(Recommendation {
588                priority: 2,
589                category: RecommendationCategory::Identifiers,
590                message: "Add PURL or CPE identifiers to components".to_string(),
591                impact: (identifiers.missing_all_identifiers as f32
592                    / total_components.max(1) as f32)
593                    * 20.0,
594                affected_count: identifiers.missing_all_identifiers,
595            });
596        }
597
598        // Priority 2: Invalid identifiers
599        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
600        if invalid_ids > 0 {
601            recommendations.push(Recommendation {
602                priority: 2,
603                category: RecommendationCategory::Identifiers,
604                message: "Fix malformed PURL/CPE identifiers".to_string(),
605                impact: 10.0,
606                affected_count: invalid_ids,
607            });
608        }
609
610        // Priority 2: Missing tool creator info
611        if !provenance.has_tool_creator {
612            recommendations.push(Recommendation {
613                priority: 2,
614                category: RecommendationCategory::Provenance,
615                message: "Add SBOM creation tool information".to_string(),
616                impact: 8.0,
617                affected_count: 0,
618            });
619        }
620
621        // Priority 3: Dependency cycles
622        if dependencies.cycle_count > 0 {
623            recommendations.push(Recommendation {
624                priority: 3,
625                category: RecommendationCategory::Dependencies,
626                message: format!(
627                    "{} dependency cycle(s) detected — review dependency graph",
628                    dependencies.cycle_count
629                ),
630                impact: 10.0,
631                affected_count: dependencies.cycle_count,
632            });
633        }
634
635        // Priority 2-3: Software complexity
636        if let Some(level) = &dependencies.complexity_level {
637            match level {
638                super::metrics::ComplexityLevel::VeryHigh => {
639                    recommendations.push(Recommendation {
640                        priority: 2,
641                        category: RecommendationCategory::Dependencies,
642                        message:
643                            "Dependency structure is very complex — review for unnecessary transitive dependencies"
644                                .to_string(),
645                        impact: 8.0,
646                        affected_count: dependencies.total_dependencies,
647                    });
648                }
649                super::metrics::ComplexityLevel::High => {
650                    recommendations.push(Recommendation {
651                        priority: 3,
652                        category: RecommendationCategory::Dependencies,
653                        message:
654                            "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
655                                .to_string(),
656                        impact: 5.0,
657                        affected_count: dependencies.total_dependencies,
658                    });
659                }
660                _ => {}
661            }
662        }
663
664        // Priority 3: Missing licenses
665        let missing_licenses = total_components - licenses.with_declared;
666        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
667        {
668            recommendations.push(Recommendation {
669                priority: 3,
670                category: RecommendationCategory::Licenses,
671                message: "Add license information to components".to_string(),
672                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
673                affected_count: missing_licenses,
674            });
675        }
676
677        // Priority 3: NOASSERTION licenses
678        if licenses.noassertion_count > 0 {
679            recommendations.push(Recommendation {
680                priority: 3,
681                category: RecommendationCategory::Licenses,
682                message: "Replace NOASSERTION with actual license information".to_string(),
683                impact: 5.0,
684                affected_count: licenses.noassertion_count,
685            });
686        }
687
688        // Priority 3: VCS URL coverage
689        if total_components > 0 {
690            let missing_vcs = total_components.saturating_sub(
691                ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
692            );
693            if missing_vcs > total_components / 2 {
694                recommendations.push(Recommendation {
695                    priority: 3,
696                    category: RecommendationCategory::Provenance,
697                    message: "Add VCS (source repository) URLs to components".to_string(),
698                    impact: 5.0,
699                    affected_count: missing_vcs,
700                });
701            }
702        }
703
704        // Priority 4: Non-standard licenses
705        if licenses.non_standard_licenses > 0 {
706            recommendations.push(Recommendation {
707                priority: 4,
708                category: RecommendationCategory::Licenses,
709                message: "Use SPDX license identifiers for better interoperability".to_string(),
710                impact: 3.0,
711                affected_count: licenses.non_standard_licenses,
712            });
713        }
714
715        // Priority 4: Outdated components
716        if lifecycle.outdated_components > 0 {
717            recommendations.push(Recommendation {
718                priority: 4,
719                category: RecommendationCategory::Lifecycle,
720                message: format!(
721                    "{} component(s) are outdated — newer versions available",
722                    lifecycle.outdated_components
723                ),
724                impact: 5.0,
725                affected_count: lifecycle.outdated_components,
726            });
727        }
728
729        // Priority 4: Missing completeness declaration
730        if provenance.completeness_declaration == CompletenessDeclaration::Unknown
731            && matches!(
732                self.profile,
733                ScoringProfile::Cra | ScoringProfile::Comprehensive
734            )
735        {
736            recommendations.push(Recommendation {
737                priority: 4,
738                category: RecommendationCategory::Provenance,
739                message: "Add compositions section with aggregate completeness declaration"
740                    .to_string(),
741                impact: 5.0,
742                affected_count: 0,
743            });
744        }
745
746        // Priority 4: Missing dependency information
747        if total_components > 1 && dependencies.total_dependencies == 0 {
748            recommendations.push(Recommendation {
749                priority: 4,
750                category: RecommendationCategory::Dependencies,
751                message: "Add dependency relationships between components".to_string(),
752                impact: 10.0,
753                affected_count: total_components,
754            });
755        }
756
757        // Priority 4: Many orphan components
758        if dependencies.orphan_components > 1
759            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
760        {
761            recommendations.push(Recommendation {
762                priority: 4,
763                category: RecommendationCategory::Dependencies,
764                message: "Review orphan components that have no dependency relationships"
765                    .to_string(),
766                impact: 5.0,
767                affected_count: dependencies.orphan_components,
768            });
769        }
770
771        // Priority 5: Missing supplier information
772        let missing_suppliers = total_components
773            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
774        if missing_suppliers > 0
775            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
776        {
777            recommendations.push(Recommendation {
778                priority: 5,
779                category: RecommendationCategory::Completeness,
780                message: "Add supplier information to components".to_string(),
781                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
782                affected_count: missing_suppliers,
783            });
784        }
785
786        // Priority 5: Missing hashes
787        let missing_hashes = total_components
788            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
789        if missing_hashes > 0
790            && matches!(
791                self.profile,
792                ScoringProfile::Security | ScoringProfile::Comprehensive
793            )
794        {
795            recommendations.push(Recommendation {
796                priority: 5,
797                category: RecommendationCategory::Integrity,
798                message: "Add cryptographic hashes for integrity verification".to_string(),
799                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
800                affected_count: missing_hashes,
801            });
802        }
803
804        // Priority 5: Consider SBOM signing (only if not already signed)
805        if !provenance.has_signature
806            && matches!(
807                self.profile,
808                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
809            )
810        {
811            recommendations.push(Recommendation {
812                priority: 5,
813                category: RecommendationCategory::Integrity,
814                message: "Consider adding a digital signature to the SBOM".to_string(),
815                impact: 3.0,
816                affected_count: 0,
817            });
818        }
819
820        // Sort by priority, then by impact
821        recommendations.sort_by(|a, b| {
822            a.priority.cmp(&b.priority).then_with(|| {
823                b.impact
824                    .partial_cmp(&a.impact)
825                    .unwrap_or(std::cmp::Ordering::Equal)
826            })
827        });
828
829        recommendations
830    }
831}
832
833impl Default for QualityScorer {
834    fn default() -> Self {
835        Self::new(ScoringProfile::Standard)
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn test_grade_from_score() {
845        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
846        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
847        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
848        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
849        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
850    }
851
852    #[test]
853    fn test_scoring_profile_compliance_level() {
854        assert_eq!(
855            ScoringProfile::Minimal.compliance_level(),
856            ComplianceLevel::Minimum
857        );
858        assert_eq!(
859            ScoringProfile::Security.compliance_level(),
860            ComplianceLevel::NtiaMinimum
861        );
862        assert_eq!(
863            ScoringProfile::Comprehensive.compliance_level(),
864            ComplianceLevel::Comprehensive
865        );
866    }
867
868    #[test]
869    fn test_scoring_weights_sum_to_one() {
870        let profiles = [
871            ScoringProfile::Minimal,
872            ScoringProfile::Standard,
873            ScoringProfile::Security,
874            ScoringProfile::LicenseCompliance,
875            ScoringProfile::Cra,
876            ScoringProfile::Comprehensive,
877        ];
878        for profile in &profiles {
879            let w = profile.weights();
880            let sum: f32 = w.as_array().iter().sum();
881            assert!(
882                (sum - 1.0).abs() < 0.01,
883                "{profile:?} weights sum to {sum}, expected 1.0"
884            );
885        }
886    }
887
888    #[test]
889    fn test_renormalize_all_available() {
890        let w = ScoringProfile::Standard.weights();
891        let available = [true; 8];
892        let norm = w.renormalize(&available);
893        let sum: f32 = norm.iter().sum();
894        assert!((sum - 1.0).abs() < 0.001);
895    }
896
897    #[test]
898    fn test_renormalize_lifecycle_unavailable() {
899        let w = ScoringProfile::Standard.weights();
900        let mut available = [true; 8];
901        available[7] = false; // lifecycle
902        let norm = w.renormalize(&available);
903        let sum: f32 = norm.iter().sum();
904        assert!((sum - 1.0).abs() < 0.001);
905        assert_eq!(norm[7], 0.0);
906    }
907
908    #[test]
909    fn test_scoring_engine_version() {
910        assert_eq!(SCORING_ENGINE_VERSION, "2.0");
911    }
912}