Skip to main content

sbom_tools/quality/
metrics.rs

1//! Quality metrics for SBOM assessment.
2//!
3//! Provides detailed metrics for different aspects of SBOM quality.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::model::{
8    CompletenessDeclaration, CreatorType, EolStatus, ExternalRefType, HashAlgorithm,
9    NormalizedSbom, StalenessLevel,
10};
11use serde::{Deserialize, Serialize};
12
13/// Overall completeness metrics for an SBOM
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompletenessMetrics {
16    /// Percentage of components with versions (0-100)
17    pub components_with_version: f32,
18    /// Percentage of components with PURLs (0-100)
19    pub components_with_purl: f32,
20    /// Percentage of components with CPEs (0-100)
21    pub components_with_cpe: f32,
22    /// Percentage of components with suppliers (0-100)
23    pub components_with_supplier: f32,
24    /// Percentage of components with hashes (0-100)
25    pub components_with_hashes: f32,
26    /// Percentage of components with licenses (0-100)
27    pub components_with_licenses: f32,
28    /// Percentage of components with descriptions (0-100)
29    pub components_with_description: f32,
30    /// Whether document has creator information
31    pub has_creator_info: bool,
32    /// Whether document has timestamp
33    pub has_timestamp: bool,
34    /// Whether document has serial number/ID
35    pub has_serial_number: bool,
36    /// Total component count
37    pub total_components: usize,
38}
39
40impl CompletenessMetrics {
41    /// Calculate completeness metrics from an SBOM
42    #[must_use] 
43    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
44        let total = sbom.components.len();
45        if total == 0 {
46            return Self::empty();
47        }
48
49        let mut with_version = 0;
50        let mut with_purl = 0;
51        let mut with_cpe = 0;
52        let mut with_supplier = 0;
53        let mut with_hashes = 0;
54        let mut with_licenses = 0;
55        let mut with_description = 0;
56
57        for comp in sbom.components.values() {
58            if comp.version.is_some() {
59                with_version += 1;
60            }
61            if comp.identifiers.purl.is_some() {
62                with_purl += 1;
63            }
64            if !comp.identifiers.cpe.is_empty() {
65                with_cpe += 1;
66            }
67            if comp.supplier.is_some() {
68                with_supplier += 1;
69            }
70            if !comp.hashes.is_empty() {
71                with_hashes += 1;
72            }
73            if !comp.licenses.declared.is_empty() || comp.licenses.concluded.is_some() {
74                with_licenses += 1;
75            }
76            if comp.description.is_some() {
77                with_description += 1;
78            }
79        }
80
81        let pct = |count: usize| (count as f32 / total as f32) * 100.0;
82
83        Self {
84            components_with_version: pct(with_version),
85            components_with_purl: pct(with_purl),
86            components_with_cpe: pct(with_cpe),
87            components_with_supplier: pct(with_supplier),
88            components_with_hashes: pct(with_hashes),
89            components_with_licenses: pct(with_licenses),
90            components_with_description: pct(with_description),
91            has_creator_info: !sbom.document.creators.is_empty(),
92            has_timestamp: true, // Always set in our model
93            has_serial_number: sbom.document.serial_number.is_some(),
94            total_components: total,
95        }
96    }
97
98    /// Create empty metrics
99    #[must_use] 
100    pub const fn empty() -> Self {
101        Self {
102            components_with_version: 0.0,
103            components_with_purl: 0.0,
104            components_with_cpe: 0.0,
105            components_with_supplier: 0.0,
106            components_with_hashes: 0.0,
107            components_with_licenses: 0.0,
108            components_with_description: 0.0,
109            has_creator_info: false,
110            has_timestamp: false,
111            has_serial_number: false,
112            total_components: 0,
113        }
114    }
115
116    /// Calculate overall completeness score (0-100)
117    #[must_use] 
118    pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
119        let mut score = 0.0;
120        let mut total_weight = 0.0;
121
122        // Component field scores
123        score += self.components_with_version * weights.version;
124        total_weight += weights.version * 100.0;
125
126        score += self.components_with_purl * weights.purl;
127        total_weight += weights.purl * 100.0;
128
129        score += self.components_with_cpe * weights.cpe;
130        total_weight += weights.cpe * 100.0;
131
132        score += self.components_with_supplier * weights.supplier;
133        total_weight += weights.supplier * 100.0;
134
135        score += self.components_with_hashes * weights.hashes;
136        total_weight += weights.hashes * 100.0;
137
138        score += self.components_with_licenses * weights.licenses;
139        total_weight += weights.licenses * 100.0;
140
141        // Document metadata scores
142        if self.has_creator_info {
143            score += 100.0 * weights.creator_info;
144        }
145        total_weight += weights.creator_info * 100.0;
146
147        if self.has_serial_number {
148            score += 100.0 * weights.serial_number;
149        }
150        total_weight += weights.serial_number * 100.0;
151
152        if total_weight > 0.0 {
153            (score / total_weight) * 100.0
154        } else {
155            0.0
156        }
157    }
158}
159
160/// Weights for completeness score calculation
161#[derive(Debug, Clone)]
162pub struct CompletenessWeights {
163    pub version: f32,
164    pub purl: f32,
165    pub cpe: f32,
166    pub supplier: f32,
167    pub hashes: f32,
168    pub licenses: f32,
169    pub creator_info: f32,
170    pub serial_number: f32,
171}
172
173impl Default for CompletenessWeights {
174    fn default() -> Self {
175        Self {
176            version: 1.0,
177            purl: 1.5, // Higher weight for PURL
178            cpe: 0.5,  // Lower weight, nice to have
179            supplier: 1.0,
180            hashes: 1.0,
181            licenses: 1.2, // Important for compliance
182            creator_info: 0.3,
183            serial_number: 0.2,
184        }
185    }
186}
187
188// ============================================================================
189// Hash quality metrics
190// ============================================================================
191
192/// Hash/integrity quality metrics
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HashQualityMetrics {
195    /// Components with any hash
196    pub components_with_any_hash: usize,
197    /// Components with at least one strong hash (SHA-256+, SHA-3, BLAKE, Blake3)
198    pub components_with_strong_hash: usize,
199    /// Components with only weak hashes (MD5, SHA-1) and no strong backup
200    pub components_with_weak_only: usize,
201    /// Distribution of hash algorithms across all components
202    pub algorithm_distribution: BTreeMap<String, usize>,
203    /// Total hash entries across all components
204    pub total_hashes: usize,
205}
206
207impl HashQualityMetrics {
208    /// Calculate hash quality metrics from an SBOM
209    #[must_use]
210    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
211        let mut with_any = 0;
212        let mut with_strong = 0;
213        let mut with_weak_only = 0;
214        let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
215        let mut total_hashes = 0;
216
217        for comp in sbom.components.values() {
218            if comp.hashes.is_empty() {
219                continue;
220            }
221            with_any += 1;
222            total_hashes += comp.hashes.len();
223
224            let mut has_strong = false;
225            let mut has_weak = false;
226
227            for hash in &comp.hashes {
228                let label = hash_algorithm_label(&hash.algorithm);
229                *distribution.entry(label).or_insert(0) += 1;
230
231                if is_strong_hash(&hash.algorithm) {
232                    has_strong = true;
233                } else {
234                    has_weak = true;
235                }
236            }
237
238            if has_strong {
239                with_strong += 1;
240            } else if has_weak {
241                with_weak_only += 1;
242            }
243        }
244
245        Self {
246            components_with_any_hash: with_any,
247            components_with_strong_hash: with_strong,
248            components_with_weak_only: with_weak_only,
249            algorithm_distribution: distribution,
250            total_hashes,
251        }
252    }
253
254    /// Calculate integrity quality score (0-100)
255    ///
256    /// Base 60% for any-hash coverage + 40% bonus for strong-hash coverage,
257    /// with a penalty for weak-only components.
258    #[must_use]
259    pub fn quality_score(&self, total_components: usize) -> f32 {
260        if total_components == 0 {
261            return 0.0;
262        }
263
264        let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
265        let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
266        let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
267
268        let base = any_coverage * 60.0;
269        let strong_bonus = strong_coverage * 40.0;
270        let weak_penalty = weak_only_ratio * 10.0;
271
272        (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
273    }
274}
275
276/// Whether a hash algorithm is considered cryptographically strong
277fn is_strong_hash(algo: &HashAlgorithm) -> bool {
278    matches!(
279        algo,
280        HashAlgorithm::Sha256
281            | HashAlgorithm::Sha384
282            | HashAlgorithm::Sha512
283            | HashAlgorithm::Sha3_256
284            | HashAlgorithm::Sha3_384
285            | HashAlgorithm::Sha3_512
286            | HashAlgorithm::Blake2b256
287            | HashAlgorithm::Blake2b384
288            | HashAlgorithm::Blake2b512
289            | HashAlgorithm::Blake3
290    )
291}
292
293/// Human-readable label for a hash algorithm
294fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
295    match algo {
296        HashAlgorithm::Md5 => "MD5".to_string(),
297        HashAlgorithm::Sha1 => "SHA-1".to_string(),
298        HashAlgorithm::Sha256 => "SHA-256".to_string(),
299        HashAlgorithm::Sha384 => "SHA-384".to_string(),
300        HashAlgorithm::Sha512 => "SHA-512".to_string(),
301        HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
302        HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
303        HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
304        HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
305        HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
306        HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
307        HashAlgorithm::Blake3 => "BLAKE3".to_string(),
308        HashAlgorithm::Other(s) => s.clone(),
309    }
310}
311
312// ============================================================================
313// Identifier quality metrics
314// ============================================================================
315
316/// Identifier quality metrics
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct IdentifierMetrics {
319    /// Components with valid PURLs
320    pub valid_purls: usize,
321    /// Components with invalid/malformed PURLs
322    pub invalid_purls: usize,
323    /// Components with valid CPEs
324    pub valid_cpes: usize,
325    /// Components with invalid/malformed CPEs
326    pub invalid_cpes: usize,
327    /// Components with SWID tags
328    pub with_swid: usize,
329    /// Unique ecosystems identified
330    pub ecosystems: Vec<String>,
331    /// Components missing all identifiers (only name)
332    pub missing_all_identifiers: usize,
333}
334
335impl IdentifierMetrics {
336    /// Calculate identifier metrics from an SBOM
337    #[must_use] 
338    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
339        let mut valid_purls = 0;
340        let mut invalid_purls = 0;
341        let mut valid_cpes = 0;
342        let mut invalid_cpes = 0;
343        let mut with_swid = 0;
344        let mut missing_all = 0;
345        let mut ecosystems = std::collections::HashSet::new();
346
347        for comp in sbom.components.values() {
348            let has_purl = comp.identifiers.purl.is_some();
349            let has_cpe = !comp.identifiers.cpe.is_empty();
350            let has_swid = comp.identifiers.swid.is_some();
351
352            if let Some(ref purl) = comp.identifiers.purl {
353                if is_valid_purl(purl) {
354                    valid_purls += 1;
355                    // Extract ecosystem from PURL
356                    if let Some(eco) = extract_ecosystem_from_purl(purl) {
357                        ecosystems.insert(eco);
358                    }
359                } else {
360                    invalid_purls += 1;
361                }
362            }
363
364            for cpe in &comp.identifiers.cpe {
365                if is_valid_cpe(cpe) {
366                    valid_cpes += 1;
367                } else {
368                    invalid_cpes += 1;
369                }
370            }
371
372            if has_swid {
373                with_swid += 1;
374            }
375
376            if !has_purl && !has_cpe && !has_swid {
377                missing_all += 1;
378            }
379        }
380
381        let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
382        ecosystem_list.sort();
383
384        Self {
385            valid_purls,
386            invalid_purls,
387            valid_cpes,
388            invalid_cpes,
389            with_swid,
390            ecosystems: ecosystem_list,
391            missing_all_identifiers: missing_all,
392        }
393    }
394
395    /// Calculate identifier quality score (0-100)
396    #[must_use] 
397    pub fn quality_score(&self, total_components: usize) -> f32 {
398        if total_components == 0 {
399            return 0.0;
400        }
401
402        let with_valid_id = self.valid_purls + self.valid_cpes + self.with_swid;
403        let coverage =
404            (with_valid_id.min(total_components) as f32 / total_components as f32) * 100.0;
405
406        // Penalize invalid identifiers
407        let invalid_count = self.invalid_purls + self.invalid_cpes;
408        let penalty = (invalid_count as f32 / total_components as f32) * 20.0;
409
410        (coverage - penalty).clamp(0.0, 100.0)
411    }
412}
413
414/// License quality metrics
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct LicenseMetrics {
417    /// Components with declared licenses
418    pub with_declared: usize,
419    /// Components with concluded licenses
420    pub with_concluded: usize,
421    /// Components with valid SPDX expressions
422    pub valid_spdx_expressions: usize,
423    /// Components with non-standard license names
424    pub non_standard_licenses: usize,
425    /// Components with NOASSERTION license
426    pub noassertion_count: usize,
427    /// Components with deprecated SPDX license identifiers
428    pub deprecated_licenses: usize,
429    /// Components with restrictive/copyleft licenses (GPL family)
430    pub restrictive_licenses: usize,
431    /// Specific copyleft license identifiers found
432    pub copyleft_license_ids: Vec<String>,
433    /// Unique licenses found
434    pub unique_licenses: Vec<String>,
435}
436
437impl LicenseMetrics {
438    /// Calculate license metrics from an SBOM
439    #[must_use]
440    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
441        let mut with_declared = 0;
442        let mut with_concluded = 0;
443        let mut valid_spdx = 0;
444        let mut non_standard = 0;
445        let mut noassertion = 0;
446        let mut deprecated = 0;
447        let mut restrictive = 0;
448        let mut licenses = HashSet::new();
449        let mut copyleft_ids = HashSet::new();
450
451        for comp in sbom.components.values() {
452            if !comp.licenses.declared.is_empty() {
453                with_declared += 1;
454                for lic in &comp.licenses.declared {
455                    let expr = &lic.expression;
456                    licenses.insert(expr.clone());
457
458                    if expr == "NOASSERTION" {
459                        noassertion += 1;
460                    } else if is_valid_spdx_license(expr) {
461                        valid_spdx += 1;
462                    } else {
463                        non_standard += 1;
464                    }
465
466                    if is_deprecated_spdx_license(expr) {
467                        deprecated += 1;
468                    }
469                    if is_restrictive_license(expr) {
470                        restrictive += 1;
471                        copyleft_ids.insert(expr.clone());
472                    }
473                }
474            }
475
476            if comp.licenses.concluded.is_some() {
477                with_concluded += 1;
478            }
479        }
480
481        let mut license_list: Vec<String> = licenses.into_iter().collect();
482        license_list.sort();
483
484        let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
485        copyleft_list.sort();
486
487        Self {
488            with_declared,
489            with_concluded,
490            valid_spdx_expressions: valid_spdx,
491            non_standard_licenses: non_standard,
492            noassertion_count: noassertion,
493            deprecated_licenses: deprecated,
494            restrictive_licenses: restrictive,
495            copyleft_license_ids: copyleft_list,
496            unique_licenses: license_list,
497        }
498    }
499
500    /// Calculate license quality score (0-100)
501    #[must_use]
502    pub fn quality_score(&self, total_components: usize) -> f32 {
503        if total_components == 0 {
504            return 0.0;
505        }
506
507        let coverage = (self.with_declared as f32 / total_components as f32) * 60.0;
508
509        // Bonus for SPDX compliance
510        let spdx_ratio = if self.with_declared > 0 {
511            self.valid_spdx_expressions as f32 / self.with_declared as f32
512        } else {
513            0.0
514        };
515        let spdx_bonus = spdx_ratio * 30.0;
516
517        // Penalty for NOASSERTION
518        let noassertion_penalty =
519            (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
520
521        // Penalty for deprecated licenses (2 points each, capped)
522        let deprecated_penalty =
523            (self.deprecated_licenses as f32 * 2.0).min(10.0);
524
525        (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
526    }
527}
528
529/// Vulnerability information quality metrics
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct VulnerabilityMetrics {
532    /// Components with vulnerability information
533    pub components_with_vulns: usize,
534    /// Total vulnerabilities reported
535    pub total_vulnerabilities: usize,
536    /// Vulnerabilities with CVSS scores
537    pub with_cvss: usize,
538    /// Vulnerabilities with CWE information
539    pub with_cwe: usize,
540    /// Vulnerabilities with remediation info
541    pub with_remediation: usize,
542    /// Components with VEX status
543    pub with_vex_status: usize,
544}
545
546impl VulnerabilityMetrics {
547    /// Calculate vulnerability metrics from an SBOM
548    #[must_use] 
549    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
550        let mut components_with_vulns = 0;
551        let mut total_vulns = 0;
552        let mut with_cvss = 0;
553        let mut with_cwe = 0;
554        let mut with_remediation = 0;
555        let mut with_vex = 0;
556
557        for comp in sbom.components.values() {
558            if !comp.vulnerabilities.is_empty() {
559                components_with_vulns += 1;
560            }
561
562            for vuln in &comp.vulnerabilities {
563                total_vulns += 1;
564
565                if !vuln.cvss.is_empty() {
566                    with_cvss += 1;
567                }
568                if !vuln.cwes.is_empty() {
569                    with_cwe += 1;
570                }
571                if vuln.remediation.is_some() {
572                    with_remediation += 1;
573                }
574            }
575
576            if comp.vex_status.is_some()
577                || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
578            {
579                with_vex += 1;
580            }
581        }
582
583        Self {
584            components_with_vulns,
585            total_vulnerabilities: total_vulns,
586            with_cvss,
587            with_cwe,
588            with_remediation,
589            with_vex_status: with_vex,
590        }
591    }
592
593    /// Calculate vulnerability documentation quality score (0-100)
594    /// Note: This measures how well vulnerabilities are documented, not how many there are
595    #[must_use] 
596    pub fn documentation_score(&self) -> f32 {
597        if self.total_vulnerabilities == 0 {
598            return 100.0; // No vulns to document
599        }
600
601        let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
602        let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
603        let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
604
605        remediation_ratio.mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0)).min(100.0)
606    }
607}
608
609// ============================================================================
610// Dependency graph quality metrics
611// ============================================================================
612
613/// Maximum edge count before skipping expensive graph analysis
614const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 50_000;
615
616/// Dependency graph quality metrics
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct DependencyMetrics {
619    /// Total dependency relationships
620    pub total_dependencies: usize,
621    /// Components with at least one dependency
622    pub components_with_deps: usize,
623    /// Maximum dependency depth (computed via BFS from roots)
624    pub max_depth: Option<usize>,
625    /// Average dependency depth across all reachable components
626    pub avg_depth: Option<f32>,
627    /// Orphan components (no incoming or outgoing deps)
628    pub orphan_components: usize,
629    /// Root components (no incoming deps, but has outgoing)
630    pub root_components: usize,
631    /// Number of dependency cycles detected
632    pub cycle_count: usize,
633    /// Number of disconnected subgraphs (islands)
634    pub island_count: usize,
635    /// Whether graph analysis was skipped due to size
636    pub graph_analysis_skipped: bool,
637}
638
639impl DependencyMetrics {
640    /// Calculate dependency metrics from an SBOM
641    #[must_use]
642    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
643        use crate::model::CanonicalId;
644
645        let total_deps = sbom.edges.len();
646
647        // Build adjacency lists using CanonicalId.value() for string keys
648        let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
649        let mut has_outgoing: HashSet<&str> = HashSet::new();
650        let mut has_incoming: HashSet<&str> = HashSet::new();
651
652        for edge in &sbom.edges {
653            children
654                .entry(edge.from.value())
655                .or_default()
656                .push(edge.to.value());
657            has_outgoing.insert(edge.from.value());
658            has_incoming.insert(edge.to.value());
659        }
660
661        let all_ids: Vec<&str> = sbom
662            .components
663            .keys()
664            .map(CanonicalId::value)
665            .collect();
666
667        let orphans = all_ids
668            .iter()
669            .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
670            .count();
671
672        let roots: Vec<&str> = has_outgoing
673            .iter()
674            .filter(|c| !has_incoming.contains(*c))
675            .copied()
676            .collect();
677        let root_count = roots.len();
678
679        // Skip expensive graph analysis for very large graphs
680        if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
681            return Self {
682                total_dependencies: total_deps,
683                components_with_deps: has_outgoing.len(),
684                max_depth: None,
685                avg_depth: None,
686                orphan_components: orphans,
687                root_components: root_count,
688                cycle_count: 0,
689                island_count: 0,
690                graph_analysis_skipped: true,
691            };
692        }
693
694        // BFS from roots to compute depth
695        let (max_depth, avg_depth) = compute_depth(&roots, &children);
696
697        // DFS cycle detection
698        let cycle_count = detect_cycles(&all_ids, &children);
699
700        // Union-Find for island/subgraph detection
701        let island_count = count_islands(&all_ids, &sbom.edges);
702
703        Self {
704            total_dependencies: total_deps,
705            components_with_deps: has_outgoing.len(),
706            max_depth,
707            avg_depth,
708            orphan_components: orphans,
709            root_components: root_count,
710            cycle_count,
711            island_count,
712            graph_analysis_skipped: false,
713        }
714    }
715
716    /// Calculate dependency graph quality score (0-100)
717    #[must_use]
718    pub fn quality_score(&self, total_components: usize) -> f32 {
719        if total_components == 0 {
720            return 0.0;
721        }
722
723        // Score based on how many components have dependency info
724        let coverage = if total_components > 1 {
725            (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
726        } else {
727            100.0 // Single component SBOM
728        };
729
730        // Slight penalty for orphan components
731        let orphan_ratio = self.orphan_components as f32 / total_components as f32;
732        let orphan_penalty = orphan_ratio * 10.0;
733
734        // Penalty for cycles (5 points each, capped at 20)
735        let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
736
737        // Penalty for excessive islands (>3 in multi-component SBOMs)
738        let island_penalty = if total_components > 5 && self.island_count > 3 {
739            ((self.island_count - 3) as f32 * 3.0).min(15.0)
740        } else {
741            0.0
742        };
743
744        (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
745    }
746}
747
748/// BFS from roots to compute max and average depth
749fn compute_depth(roots: &[&str], children: &HashMap<&str, Vec<&str>>) -> (Option<usize>, Option<f32>) {
750    use std::collections::VecDeque;
751
752    if roots.is_empty() {
753        return (None, None);
754    }
755
756    let mut visited: HashSet<&str> = HashSet::new();
757    let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
758    let mut max_d: usize = 0;
759    let mut total_depth: usize = 0;
760    let mut count: usize = 0;
761
762    for &root in roots {
763        if visited.insert(root) {
764            queue.push_back((root, 0));
765        }
766    }
767
768    while let Some((node, depth)) = queue.pop_front() {
769        max_d = max_d.max(depth);
770        total_depth += depth;
771        count += 1;
772
773        if let Some(kids) = children.get(node) {
774            for &kid in kids {
775                if visited.insert(kid) {
776                    queue.push_back((kid, depth + 1));
777                }
778            }
779        }
780    }
781
782    let avg = if count > 0 {
783        Some(total_depth as f32 / count as f32)
784    } else {
785        None
786    };
787
788    (Some(max_d), avg)
789}
790
791/// DFS-based cycle detection (white/gray/black coloring)
792fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
793    const WHITE: u8 = 0;
794    const GRAY: u8 = 1;
795    const BLACK: u8 = 2;
796
797    let mut color: HashMap<&str, u8> = HashMap::with_capacity(all_nodes.len());
798    for &node in all_nodes {
799        color.insert(node, WHITE);
800    }
801
802    let mut cycles = 0;
803
804    fn dfs<'a>(
805        node: &'a str,
806        children: &HashMap<&str, Vec<&'a str>>,
807        color: &mut HashMap<&'a str, u8>,
808        cycles: &mut usize,
809    ) {
810        color.insert(node, GRAY);
811
812        if let Some(kids) = children.get(node) {
813            for &kid in kids {
814                match color.get(kid).copied().unwrap_or(WHITE) {
815                    GRAY => *cycles += 1, // back edge = cycle
816                    WHITE => dfs(kid, children, color, cycles),
817                    _ => {}
818                }
819            }
820        }
821
822        color.insert(node, BLACK);
823    }
824
825    for &node in all_nodes {
826        if color.get(node).copied().unwrap_or(WHITE) == WHITE {
827            dfs(node, children, &mut color, &mut cycles);
828        }
829    }
830
831    cycles
832}
833
834/// Union-Find to count disconnected subgraphs (islands)
835fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
836    if all_nodes.is_empty() {
837        return 0;
838    }
839
840    // Map node IDs to indices
841    let node_idx: HashMap<&str, usize> = all_nodes
842        .iter()
843        .enumerate()
844        .map(|(i, &n)| (n, i))
845        .collect();
846
847    let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
848    let mut rank: Vec<u8> = vec![0; all_nodes.len()];
849
850    fn find(parent: &mut Vec<usize>, x: usize) -> usize {
851        if parent[x] != x {
852            parent[x] = find(parent, parent[x]); // path compression
853        }
854        parent[x]
855    }
856
857    fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
858        let ra = find(parent, a);
859        let rb = find(parent, b);
860        if ra != rb {
861            if rank[ra] < rank[rb] {
862                parent[ra] = rb;
863            } else if rank[ra] > rank[rb] {
864                parent[rb] = ra;
865            } else {
866                parent[rb] = ra;
867                rank[ra] += 1;
868            }
869        }
870    }
871
872    for edge in edges {
873        if let (Some(&a), Some(&b)) = (
874            node_idx.get(edge.from.value()),
875            node_idx.get(edge.to.value()),
876        ) {
877            union(&mut parent, &mut rank, a, b);
878        }
879    }
880
881    // Count unique roots
882    let mut roots = HashSet::new();
883    for i in 0..all_nodes.len() {
884        roots.insert(find(&mut parent, i));
885    }
886
887    roots.len()
888}
889
890// ============================================================================
891// Provenance metrics
892// ============================================================================
893
894/// Document provenance and authorship quality metrics
895#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct ProvenanceMetrics {
897    /// Whether the SBOM was created by an identified tool
898    pub has_tool_creator: bool,
899    /// Whether the tool creator includes version information
900    pub has_tool_version: bool,
901    /// Whether an organization is identified as creator
902    pub has_org_creator: bool,
903    /// Whether any creator has a contact email
904    pub has_contact_email: bool,
905    /// Whether the document has a serial number / namespace
906    pub has_serial_number: bool,
907    /// Whether the document has a name
908    pub has_document_name: bool,
909    /// Age of the SBOM in days (since creation timestamp)
910    pub timestamp_age_days: u32,
911    /// Whether the SBOM is considered fresh (< 90 days old)
912    pub is_fresh: bool,
913    /// Whether a primary/described component is identified
914    pub has_primary_component: bool,
915    /// SBOM lifecycle phase (from CycloneDX 1.5+ metadata)
916    pub lifecycle_phase: Option<String>,
917    /// Self-declared completeness level of the SBOM
918    pub completeness_declaration: CompletenessDeclaration,
919    /// Whether the SBOM has a digital signature
920    pub has_signature: bool,
921}
922
923/// Freshness threshold in days
924const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
925
926impl ProvenanceMetrics {
927    /// Calculate provenance metrics from an SBOM
928    #[must_use]
929    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
930        let doc = &sbom.document;
931
932        let has_tool_creator = doc
933            .creators
934            .iter()
935            .any(|c| c.creator_type == CreatorType::Tool);
936        let has_tool_version = doc.creators.iter().any(|c| {
937            c.creator_type == CreatorType::Tool
938                && (c.name.contains(' ') || c.name.contains('/') || c.name.contains('@'))
939        });
940        let has_org_creator = doc
941            .creators
942            .iter()
943            .any(|c| c.creator_type == CreatorType::Organization);
944        let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
945
946        let age_days = (chrono::Utc::now() - doc.created)
947            .num_days()
948            .max(0) as u32;
949
950        Self {
951            has_tool_creator,
952            has_tool_version,
953            has_org_creator,
954            has_contact_email,
955            has_serial_number: doc.serial_number.is_some(),
956            has_document_name: doc.name.is_some(),
957            timestamp_age_days: age_days,
958            is_fresh: age_days < FRESHNESS_THRESHOLD_DAYS,
959            has_primary_component: sbom.primary_component_id.is_some(),
960            lifecycle_phase: doc.lifecycle_phase.clone(),
961            completeness_declaration: doc.completeness_declaration.clone(),
962            has_signature: doc.signature.is_some(),
963        }
964    }
965
966    /// Calculate provenance quality score (0-100)
967    ///
968    /// Weighted checklist: tool creator (15%), tool version (5%), org creator (12%),
969    /// contact email (8%), serial number (8%), document name (5%), freshness (12%),
970    /// primary component (12%), completeness declaration (8%), signature (5%),
971    /// lifecycle phase (10% CDX-only).
972    #[must_use]
973    pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
974        let mut score = 0.0;
975        let mut total_weight = 0.0;
976
977        let completeness_declared =
978            self.completeness_declaration != CompletenessDeclaration::Unknown;
979
980        let checks: &[(bool, f32)] = &[
981            (self.has_tool_creator, 15.0),
982            (self.has_tool_version, 5.0),
983            (self.has_org_creator, 12.0),
984            (self.has_contact_email, 8.0),
985            (self.has_serial_number, 8.0),
986            (self.has_document_name, 5.0),
987            (self.is_fresh, 12.0),
988            (self.has_primary_component, 12.0),
989            (completeness_declared, 8.0),
990            (self.has_signature, 5.0),
991        ];
992
993        for &(present, weight) in checks {
994            if present {
995                score += weight;
996            }
997            total_weight += weight;
998        }
999
1000        // Lifecycle phase: only applicable for CycloneDX 1.5+
1001        if is_cyclonedx {
1002            let weight = 10.0;
1003            if self.lifecycle_phase.is_some() {
1004                score += weight;
1005            }
1006            total_weight += weight;
1007        }
1008
1009        if total_weight > 0.0 {
1010            (score / total_weight) * 100.0
1011        } else {
1012            0.0
1013        }
1014    }
1015}
1016
1017// ============================================================================
1018// Auditability metrics
1019// ============================================================================
1020
1021/// External reference and auditability quality metrics
1022#[derive(Debug, Clone, Serialize, Deserialize)]
1023pub struct AuditabilityMetrics {
1024    /// Components with VCS (version control) references
1025    pub components_with_vcs: usize,
1026    /// Components with website references
1027    pub components_with_website: usize,
1028    /// Components with security advisory references
1029    pub components_with_advisories: usize,
1030    /// Components with any external reference
1031    pub components_with_any_external_ref: usize,
1032    /// Whether the document has a security contact
1033    pub has_security_contact: bool,
1034    /// Whether the document has a vulnerability disclosure URL
1035    pub has_vuln_disclosure_url: bool,
1036}
1037
1038impl AuditabilityMetrics {
1039    /// Calculate auditability metrics from an SBOM
1040    #[must_use]
1041    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1042        let mut with_vcs = 0;
1043        let mut with_website = 0;
1044        let mut with_advisories = 0;
1045        let mut with_any = 0;
1046
1047        for comp in sbom.components.values() {
1048            if comp.external_refs.is_empty() {
1049                continue;
1050            }
1051            with_any += 1;
1052
1053            let has_vcs = comp
1054                .external_refs
1055                .iter()
1056                .any(|r| r.ref_type == ExternalRefType::Vcs);
1057            let has_website = comp
1058                .external_refs
1059                .iter()
1060                .any(|r| r.ref_type == ExternalRefType::Website);
1061            let has_advisories = comp
1062                .external_refs
1063                .iter()
1064                .any(|r| r.ref_type == ExternalRefType::Advisories);
1065
1066            if has_vcs {
1067                with_vcs += 1;
1068            }
1069            if has_website {
1070                with_website += 1;
1071            }
1072            if has_advisories {
1073                with_advisories += 1;
1074            }
1075        }
1076
1077        Self {
1078            components_with_vcs: with_vcs,
1079            components_with_website: with_website,
1080            components_with_advisories: with_advisories,
1081            components_with_any_external_ref: with_any,
1082            has_security_contact: sbom.document.security_contact.is_some(),
1083            has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1084        }
1085    }
1086
1087    /// Calculate auditability quality score (0-100)
1088    ///
1089    /// Component-level coverage (60%) + document-level security metadata (40%).
1090    #[must_use]
1091    pub fn quality_score(&self, total_components: usize) -> f32 {
1092        if total_components == 0 {
1093            return 0.0;
1094        }
1095
1096        // Component-level: external ref coverage
1097        let ref_coverage =
1098            (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1099        let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1100
1101        // Document-level security metadata
1102        let security_contact_score = if self.has_security_contact {
1103            20.0
1104        } else {
1105            0.0
1106        };
1107        let disclosure_score = if self.has_vuln_disclosure_url {
1108            20.0
1109        } else {
1110            0.0
1111        };
1112
1113        (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1114    }
1115}
1116
1117// ============================================================================
1118// Lifecycle metrics
1119// ============================================================================
1120
1121/// Component lifecycle quality metrics (requires enrichment data)
1122#[derive(Debug, Clone, Serialize, Deserialize)]
1123pub struct LifecycleMetrics {
1124    /// Components that have reached end-of-life
1125    pub eol_components: usize,
1126    /// Components classified as stale (no updates for 1+ years)
1127    pub stale_components: usize,
1128    /// Components explicitly marked as deprecated
1129    pub deprecated_components: usize,
1130    /// Components with archived repositories
1131    pub archived_components: usize,
1132    /// Components with a newer version available
1133    pub outdated_components: usize,
1134    /// Components that had lifecycle enrichment data
1135    pub enriched_components: usize,
1136    /// Enrichment coverage percentage (0-100)
1137    pub enrichment_coverage: f32,
1138}
1139
1140impl LifecycleMetrics {
1141    /// Calculate lifecycle metrics from an SBOM
1142    ///
1143    /// These metrics are only meaningful after enrichment. When
1144    /// `enrichment_coverage == 0`, the lifecycle score should be
1145    /// treated as N/A and excluded from the weighted total.
1146    #[must_use]
1147    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1148        let total = sbom.components.len();
1149        let mut eol = 0;
1150        let mut stale = 0;
1151        let mut deprecated = 0;
1152        let mut archived = 0;
1153        let mut outdated = 0;
1154        let mut enriched = 0;
1155
1156        for comp in sbom.components.values() {
1157            let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1158            if has_lifecycle_data {
1159                enriched += 1;
1160            }
1161
1162            if let Some(ref eol_info) = comp.eol
1163                && eol_info.status == EolStatus::EndOfLife {
1164                    eol += 1;
1165                }
1166
1167            if let Some(ref stale_info) = comp.staleness {
1168                match stale_info.level {
1169                    StalenessLevel::Stale | StalenessLevel::Abandoned => stale += 1,
1170                    StalenessLevel::Deprecated => deprecated += 1,
1171                    StalenessLevel::Archived => archived += 1,
1172                    _ => {}
1173                }
1174                if stale_info.is_deprecated {
1175                    deprecated += 1;
1176                }
1177                if stale_info.is_archived {
1178                    archived += 1;
1179                }
1180                if stale_info.latest_version.is_some() {
1181                    outdated += 1;
1182                }
1183            }
1184        }
1185
1186        let coverage = if total > 0 {
1187            (enriched as f32 / total as f32) * 100.0
1188        } else {
1189            0.0
1190        };
1191
1192        Self {
1193            eol_components: eol,
1194            stale_components: stale,
1195            deprecated_components: deprecated,
1196            archived_components: archived,
1197            outdated_components: outdated,
1198            enriched_components: enriched,
1199            enrichment_coverage: coverage,
1200        }
1201    }
1202
1203    /// Whether enrichment data is available for scoring
1204    #[must_use]
1205    pub fn has_data(&self) -> bool {
1206        self.enriched_components > 0
1207    }
1208
1209    /// Calculate lifecycle quality score (0-100)
1210    ///
1211    /// Starts at 100, subtracts penalties for problematic components.
1212    /// Returns `None` if no enrichment data is available.
1213    #[must_use]
1214    pub fn quality_score(&self) -> Option<f32> {
1215        if !self.has_data() {
1216            return None;
1217        }
1218
1219        let mut score = 100.0_f32;
1220
1221        // EOL: severe penalty (15 points each, capped at 60)
1222        score -= (self.eol_components as f32 * 15.0).min(60.0);
1223        // Stale: moderate penalty (5 points each, capped at 30)
1224        score -= (self.stale_components as f32 * 5.0).min(30.0);
1225        // Deprecated/archived: moderate penalty (3 points each, capped at 20)
1226        score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1227        // Outdated: mild penalty (1 point each, capped at 10)
1228        score -= (self.outdated_components as f32 * 1.0).min(10.0);
1229
1230        Some(score.clamp(0.0, 100.0))
1231    }
1232}
1233
1234// ============================================================================
1235// Helper functions
1236// ============================================================================
1237
1238fn is_valid_purl(purl: &str) -> bool {
1239    // Basic PURL validation: pkg:type/namespace/name@version
1240    purl.starts_with("pkg:") && purl.contains('/')
1241}
1242
1243fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
1244    // Extract type from pkg:type/...
1245    if let Some(rest) = purl.strip_prefix("pkg:")
1246        && let Some(slash_idx) = rest.find('/') {
1247            return Some(rest[..slash_idx].to_string());
1248        }
1249    None
1250}
1251
1252fn is_valid_cpe(cpe: &str) -> bool {
1253    // Basic CPE validation
1254    cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
1255}
1256
1257fn is_valid_spdx_license(expr: &str) -> bool {
1258    // Common SPDX license identifiers
1259    const COMMON_SPDX: &[&str] = &[
1260        "MIT",
1261        "Apache-2.0",
1262        "GPL-2.0",
1263        "GPL-3.0",
1264        "BSD-2-Clause",
1265        "BSD-3-Clause",
1266        "ISC",
1267        "MPL-2.0",
1268        "LGPL-2.1",
1269        "LGPL-3.0",
1270        "AGPL-3.0",
1271        "Unlicense",
1272        "CC0-1.0",
1273        "0BSD",
1274        "EPL-2.0",
1275        "CDDL-1.0",
1276        "Artistic-2.0",
1277        "GPL-2.0-only",
1278        "GPL-2.0-or-later",
1279        "GPL-3.0-only",
1280        "GPL-3.0-or-later",
1281        "LGPL-2.1-only",
1282        "LGPL-2.1-or-later",
1283        "LGPL-3.0-only",
1284        "LGPL-3.0-or-later",
1285    ];
1286
1287    // Check for common licenses or expressions
1288    let trimmed = expr.trim();
1289    COMMON_SPDX.contains(&trimmed)
1290        || trimmed.contains(" AND ")
1291        || trimmed.contains(" OR ")
1292        || trimmed.contains(" WITH ")
1293}
1294
1295/// Whether a license identifier is on the SPDX deprecated list.
1296///
1297/// These are license IDs that SPDX has deprecated in favor of more specific
1298/// identifiers (e.g., `GPL-2.0` → `GPL-2.0-only` or `GPL-2.0-or-later`).
1299fn is_deprecated_spdx_license(expr: &str) -> bool {
1300    const DEPRECATED: &[&str] = &[
1301        "GPL-2.0",
1302        "GPL-2.0+",
1303        "GPL-3.0",
1304        "GPL-3.0+",
1305        "LGPL-2.0",
1306        "LGPL-2.0+",
1307        "LGPL-2.1",
1308        "LGPL-2.1+",
1309        "LGPL-3.0",
1310        "LGPL-3.0+",
1311        "AGPL-1.0",
1312        "AGPL-3.0",
1313        "GFDL-1.1",
1314        "GFDL-1.2",
1315        "GFDL-1.3",
1316        "BSD-2-Clause-FreeBSD",
1317        "BSD-2-Clause-NetBSD",
1318        "eCos-2.0",
1319        "Nunit",
1320        "StandardML-NJ",
1321        "wxWindows",
1322    ];
1323    let trimmed = expr.trim();
1324    DEPRECATED.contains(&trimmed)
1325}
1326
1327/// Whether a license is considered restrictive/copyleft (GPL family).
1328///
1329/// This is informational — restrictive licenses are not inherently a quality
1330/// issue, but organizations need to know about them for compliance.
1331fn is_restrictive_license(expr: &str) -> bool {
1332    let trimmed = expr.trim().to_uppercase();
1333    trimmed.starts_with("GPL")
1334        || trimmed.starts_with("LGPL")
1335        || trimmed.starts_with("AGPL")
1336        || trimmed.starts_with("EUPL")
1337        || trimmed.starts_with("SSPL")
1338        || trimmed.starts_with("OSL")
1339        || trimmed.starts_with("CPAL")
1340        || trimmed.starts_with("CC-BY-SA")
1341        || trimmed.starts_with("CC-BY-NC")
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347
1348    #[test]
1349    fn test_purl_validation() {
1350        assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
1351        assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
1352        assert!(!is_valid_purl("npm:something"));
1353        assert!(!is_valid_purl("invalid"));
1354    }
1355
1356    #[test]
1357    fn test_cpe_validation() {
1358        assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
1359        assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
1360        assert!(!is_valid_cpe("something:else"));
1361    }
1362
1363    #[test]
1364    fn test_spdx_license_validation() {
1365        assert!(is_valid_spdx_license("MIT"));
1366        assert!(is_valid_spdx_license("Apache-2.0"));
1367        assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
1368        assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
1369    }
1370
1371    #[test]
1372    fn test_strong_hash_classification() {
1373        assert!(is_strong_hash(&HashAlgorithm::Sha256));
1374        assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
1375        assert!(is_strong_hash(&HashAlgorithm::Blake3));
1376        assert!(!is_strong_hash(&HashAlgorithm::Md5));
1377        assert!(!is_strong_hash(&HashAlgorithm::Sha1));
1378        assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
1379    }
1380
1381    #[test]
1382    fn test_deprecated_license_detection() {
1383        assert!(is_deprecated_spdx_license("GPL-2.0"));
1384        assert!(is_deprecated_spdx_license("LGPL-2.1"));
1385        assert!(is_deprecated_spdx_license("AGPL-3.0"));
1386        assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
1387        assert!(!is_deprecated_spdx_license("MIT"));
1388        assert!(!is_deprecated_spdx_license("Apache-2.0"));
1389    }
1390
1391    #[test]
1392    fn test_restrictive_license_detection() {
1393        assert!(is_restrictive_license("GPL-3.0-only"));
1394        assert!(is_restrictive_license("LGPL-2.1-or-later"));
1395        assert!(is_restrictive_license("AGPL-3.0-only"));
1396        assert!(is_restrictive_license("EUPL-1.2"));
1397        assert!(is_restrictive_license("CC-BY-SA-4.0"));
1398        assert!(!is_restrictive_license("MIT"));
1399        assert!(!is_restrictive_license("Apache-2.0"));
1400        assert!(!is_restrictive_license("BSD-3-Clause"));
1401    }
1402
1403    #[test]
1404    fn test_hash_quality_score_no_components() {
1405        let metrics = HashQualityMetrics {
1406            components_with_any_hash: 0,
1407            components_with_strong_hash: 0,
1408            components_with_weak_only: 0,
1409            algorithm_distribution: BTreeMap::new(),
1410            total_hashes: 0,
1411        };
1412        assert_eq!(metrics.quality_score(0), 0.0);
1413    }
1414
1415    #[test]
1416    fn test_hash_quality_score_all_strong() {
1417        let metrics = HashQualityMetrics {
1418            components_with_any_hash: 10,
1419            components_with_strong_hash: 10,
1420            components_with_weak_only: 0,
1421            algorithm_distribution: BTreeMap::new(),
1422            total_hashes: 10,
1423        };
1424        assert_eq!(metrics.quality_score(10), 100.0);
1425    }
1426
1427    #[test]
1428    fn test_hash_quality_score_weak_only_penalty() {
1429        let metrics = HashQualityMetrics {
1430            components_with_any_hash: 10,
1431            components_with_strong_hash: 0,
1432            components_with_weak_only: 10,
1433            algorithm_distribution: BTreeMap::new(),
1434            total_hashes: 10,
1435        };
1436        // 60 (any) + 0 (strong) - 10 (weak penalty) = 50
1437        assert_eq!(metrics.quality_score(10), 50.0);
1438    }
1439
1440    #[test]
1441    fn test_lifecycle_no_enrichment_returns_none() {
1442        let metrics = LifecycleMetrics {
1443            eol_components: 0,
1444            stale_components: 0,
1445            deprecated_components: 0,
1446            archived_components: 0,
1447            outdated_components: 0,
1448            enriched_components: 0,
1449            enrichment_coverage: 0.0,
1450        };
1451        assert!(!metrics.has_data());
1452        assert!(metrics.quality_score().is_none());
1453    }
1454
1455    #[test]
1456    fn test_lifecycle_with_eol_penalty() {
1457        let metrics = LifecycleMetrics {
1458            eol_components: 2,
1459            stale_components: 0,
1460            deprecated_components: 0,
1461            archived_components: 0,
1462            outdated_components: 0,
1463            enriched_components: 10,
1464            enrichment_coverage: 100.0,
1465        };
1466        // 100 - 30 (2 * 15) = 70
1467        assert_eq!(metrics.quality_score(), Some(70.0));
1468    }
1469
1470    #[test]
1471    fn test_cycle_detection_no_cycles() {
1472        let children: HashMap<&str, Vec<&str>> = HashMap::from([
1473            ("a", vec!["b"]),
1474            ("b", vec!["c"]),
1475        ]);
1476        let all_nodes = vec!["a", "b", "c"];
1477        assert_eq!(detect_cycles(&all_nodes, &children), 0);
1478    }
1479
1480    #[test]
1481    fn test_cycle_detection_with_cycle() {
1482        let children: HashMap<&str, Vec<&str>> = HashMap::from([
1483            ("a", vec!["b"]),
1484            ("b", vec!["c"]),
1485            ("c", vec!["a"]),
1486        ]);
1487        let all_nodes = vec!["a", "b", "c"];
1488        assert_eq!(detect_cycles(&all_nodes, &children), 1);
1489    }
1490
1491    #[test]
1492    fn test_depth_computation() {
1493        let children: HashMap<&str, Vec<&str>> = HashMap::from([
1494            ("root", vec!["a", "b"]),
1495            ("a", vec!["c"]),
1496        ]);
1497        let roots = vec!["root"];
1498        let (max_d, avg_d) = compute_depth(&roots, &children);
1499        assert_eq!(max_d, Some(2)); // root -> a -> c
1500        assert!(avg_d.is_some());
1501    }
1502
1503    #[test]
1504    fn test_depth_empty_roots() {
1505        let children: HashMap<&str, Vec<&str>> = HashMap::new();
1506        let roots: Vec<&str> = vec![];
1507        let (max_d, avg_d) = compute_depth(&roots, &children);
1508        assert_eq!(max_d, None);
1509        assert_eq!(avg_d, None);
1510    }
1511
1512    #[test]
1513    fn test_provenance_quality_score() {
1514        let metrics = ProvenanceMetrics {
1515            has_tool_creator: true,
1516            has_tool_version: true,
1517            has_org_creator: true,
1518            has_contact_email: true,
1519            has_serial_number: true,
1520            has_document_name: true,
1521            timestamp_age_days: 10,
1522            is_fresh: true,
1523            has_primary_component: true,
1524            lifecycle_phase: Some("build".to_string()),
1525            completeness_declaration: CompletenessDeclaration::Complete,
1526            has_signature: true,
1527        };
1528        // All checks pass for CycloneDX
1529        assert_eq!(metrics.quality_score(true), 100.0);
1530    }
1531
1532    #[test]
1533    fn test_provenance_score_without_cyclonedx() {
1534        let metrics = ProvenanceMetrics {
1535            has_tool_creator: true,
1536            has_tool_version: true,
1537            has_org_creator: true,
1538            has_contact_email: true,
1539            has_serial_number: true,
1540            has_document_name: true,
1541            timestamp_age_days: 10,
1542            is_fresh: true,
1543            has_primary_component: true,
1544            lifecycle_phase: None,
1545            completeness_declaration: CompletenessDeclaration::Complete,
1546            has_signature: true,
1547        };
1548        // Lifecycle phase excluded for non-CDX
1549        assert_eq!(metrics.quality_score(false), 100.0);
1550    }
1551
1552    #[test]
1553    fn test_completeness_declaration_display() {
1554        assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
1555        assert_eq!(
1556            CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
1557            "incomplete (first-party only)"
1558        );
1559        assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
1560    }
1561}