1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompletenessMetrics {
16 pub components_with_version: f32,
18 pub components_with_purl: f32,
20 pub components_with_cpe: f32,
22 pub components_with_supplier: f32,
24 pub components_with_hashes: f32,
26 pub components_with_licenses: f32,
28 pub components_with_description: f32,
30 pub has_creator_info: bool,
32 pub has_timestamp: bool,
34 pub has_serial_number: bool,
36 pub total_components: usize,
38}
39
40impl CompletenessMetrics {
41 #[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, has_serial_number: sbom.document.serial_number.is_some(),
94 total_components: total,
95 }
96 }
97
98 #[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 #[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 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 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#[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, cpe: 0.5, supplier: 1.0,
180 hashes: 1.0,
181 licenses: 1.2, creator_info: 0.3,
183 serial_number: 0.2,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HashQualityMetrics {
195 pub components_with_any_hash: usize,
197 pub components_with_strong_hash: usize,
199 pub components_with_weak_only: usize,
201 pub algorithm_distribution: BTreeMap<String, usize>,
203 pub total_hashes: usize,
205}
206
207impl HashQualityMetrics {
208 #[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 #[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
276fn 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
293fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct IdentifierMetrics {
319 pub valid_purls: usize,
321 pub invalid_purls: usize,
323 pub valid_cpes: usize,
325 pub invalid_cpes: usize,
327 pub with_swid: usize,
329 pub ecosystems: Vec<String>,
331 pub missing_all_identifiers: usize,
333}
334
335impl IdentifierMetrics {
336 #[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 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 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct LicenseMetrics {
417 pub with_declared: usize,
419 pub with_concluded: usize,
421 pub valid_spdx_expressions: usize,
423 pub non_standard_licenses: usize,
425 pub noassertion_count: usize,
427 pub deprecated_licenses: usize,
429 pub restrictive_licenses: usize,
431 pub copyleft_license_ids: Vec<String>,
433 pub unique_licenses: Vec<String>,
435}
436
437impl LicenseMetrics {
438 #[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 #[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 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 let noassertion_penalty =
519 (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
520
521 let deprecated_penalty = (self.deprecated_licenses as f32 * 2.0).min(10.0);
523
524 (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
525 }
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct VulnerabilityMetrics {
531 pub components_with_vulns: usize,
533 pub total_vulnerabilities: usize,
535 pub with_cvss: usize,
537 pub with_cwe: usize,
539 pub with_remediation: usize,
541 pub with_vex_status: usize,
543}
544
545impl VulnerabilityMetrics {
546 #[must_use]
548 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
549 let mut components_with_vulns = 0;
550 let mut total_vulns = 0;
551 let mut with_cvss = 0;
552 let mut with_cwe = 0;
553 let mut with_remediation = 0;
554 let mut with_vex = 0;
555
556 for comp in sbom.components.values() {
557 if !comp.vulnerabilities.is_empty() {
558 components_with_vulns += 1;
559 }
560
561 for vuln in &comp.vulnerabilities {
562 total_vulns += 1;
563
564 if !vuln.cvss.is_empty() {
565 with_cvss += 1;
566 }
567 if !vuln.cwes.is_empty() {
568 with_cwe += 1;
569 }
570 if vuln.remediation.is_some() {
571 with_remediation += 1;
572 }
573 }
574
575 if comp.vex_status.is_some()
576 || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
577 {
578 with_vex += 1;
579 }
580 }
581
582 Self {
583 components_with_vulns,
584 total_vulnerabilities: total_vulns,
585 with_cvss,
586 with_cwe,
587 with_remediation,
588 with_vex_status: with_vex,
589 }
590 }
591
592 #[must_use]
599 pub fn documentation_score(&self) -> Option<f32> {
600 if self.total_vulnerabilities == 0 {
601 return None; }
603
604 let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
605 let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
606 let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
607
608 Some(
609 remediation_ratio
610 .mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0))
611 .min(100.0),
612 )
613 }
614}
615
616const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 50_000;
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
629#[non_exhaustive]
630pub enum ComplexityLevel {
631 Low,
633 Moderate,
635 High,
637 VeryHigh,
639}
640
641impl ComplexityLevel {
642 #[must_use]
644 pub const fn from_score(simplicity: f32) -> Self {
645 match simplicity as u32 {
646 75..=100 => Self::Low,
647 50..=74 => Self::Moderate,
648 25..=49 => Self::High,
649 _ => Self::VeryHigh,
650 }
651 }
652
653 #[must_use]
655 pub const fn label(&self) -> &'static str {
656 match self {
657 Self::Low => "Low",
658 Self::Moderate => "Moderate",
659 Self::High => "High",
660 Self::VeryHigh => "Very High",
661 }
662 }
663}
664
665impl std::fmt::Display for ComplexityLevel {
666 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667 f.write_str(self.label())
668 }
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct ComplexityFactors {
675 pub dependency_volume: f32,
677 pub normalized_depth: f32,
679 pub fanout_concentration: f32,
681 pub cycle_ratio: f32,
683 pub fragmentation: f32,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct DependencyMetrics {
690 pub total_dependencies: usize,
692 pub components_with_deps: usize,
694 pub max_depth: Option<usize>,
696 pub avg_depth: Option<f32>,
698 pub orphan_components: usize,
700 pub root_components: usize,
702 pub cycle_count: usize,
704 pub island_count: usize,
706 pub graph_analysis_skipped: bool,
708 pub max_out_degree: usize,
710 pub software_complexity_index: Option<f32>,
712 pub complexity_level: Option<ComplexityLevel>,
714 pub complexity_factors: Option<ComplexityFactors>,
716}
717
718impl DependencyMetrics {
719 #[must_use]
721 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
722 use crate::model::CanonicalId;
723
724 let total_deps = sbom.edges.len();
725
726 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
728 let mut has_outgoing: HashSet<&str> = HashSet::new();
729 let mut has_incoming: HashSet<&str> = HashSet::new();
730
731 for edge in &sbom.edges {
732 children
733 .entry(edge.from.value())
734 .or_default()
735 .push(edge.to.value());
736 has_outgoing.insert(edge.from.value());
737 has_incoming.insert(edge.to.value());
738 }
739
740 let all_ids: Vec<&str> = sbom.components.keys().map(CanonicalId::value).collect();
741
742 let orphans = all_ids
743 .iter()
744 .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
745 .count();
746
747 let roots: Vec<&str> = has_outgoing
748 .iter()
749 .filter(|c| !has_incoming.contains(*c))
750 .copied()
751 .collect();
752 let root_count = roots.len();
753
754 let max_out_degree = children.values().map(Vec::len).max().unwrap_or(0);
756
757 if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
759 return Self {
760 total_dependencies: total_deps,
761 components_with_deps: has_outgoing.len(),
762 max_depth: None,
763 avg_depth: None,
764 orphan_components: orphans,
765 root_components: root_count,
766 cycle_count: 0,
767 island_count: 0,
768 graph_analysis_skipped: true,
769 max_out_degree,
770 software_complexity_index: None,
771 complexity_level: None,
772 complexity_factors: None,
773 };
774 }
775
776 let (max_depth, avg_depth) = compute_depth(&roots, &children);
778
779 let cycle_count = detect_cycles(&all_ids, &children);
781
782 let island_count = count_islands(&all_ids, &sbom.edges);
784
785 let component_count = all_ids.len();
787 let (complexity_index, complexity_lvl, factors) = compute_complexity(
788 total_deps,
789 component_count,
790 max_depth.unwrap_or(0),
791 max_out_degree,
792 cycle_count,
793 orphans,
794 island_count,
795 );
796
797 Self {
798 total_dependencies: total_deps,
799 components_with_deps: has_outgoing.len(),
800 max_depth,
801 avg_depth,
802 orphan_components: orphans,
803 root_components: root_count,
804 cycle_count,
805 island_count,
806 graph_analysis_skipped: false,
807 max_out_degree,
808 software_complexity_index: Some(complexity_index),
809 complexity_level: Some(complexity_lvl),
810 complexity_factors: Some(factors),
811 }
812 }
813
814 #[must_use]
816 pub fn quality_score(&self, total_components: usize) -> f32 {
817 if total_components == 0 {
818 return 0.0;
819 }
820
821 let coverage = if total_components > 1 {
823 (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
824 } else {
825 100.0 };
827
828 let orphan_ratio = self.orphan_components as f32 / total_components as f32;
830 let orphan_penalty = orphan_ratio * 10.0;
831
832 let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
834
835 let island_penalty = if total_components > 5 && self.island_count > 3 {
837 ((self.island_count - 3) as f32 * 3.0).min(15.0)
838 } else {
839 0.0
840 };
841
842 (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
843 }
844}
845
846fn compute_depth(
848 roots: &[&str],
849 children: &HashMap<&str, Vec<&str>>,
850) -> (Option<usize>, Option<f32>) {
851 use std::collections::VecDeque;
852
853 if roots.is_empty() {
854 return (None, None);
855 }
856
857 let mut visited: HashSet<&str> = HashSet::new();
858 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
859 let mut max_d: usize = 0;
860 let mut total_depth: usize = 0;
861 let mut count: usize = 0;
862
863 for &root in roots {
864 if visited.insert(root) {
865 queue.push_back((root, 0));
866 }
867 }
868
869 while let Some((node, depth)) = queue.pop_front() {
870 max_d = max_d.max(depth);
871 total_depth += depth;
872 count += 1;
873
874 if let Some(kids) = children.get(node) {
875 for &kid in kids {
876 if visited.insert(kid) {
877 queue.push_back((kid, depth + 1));
878 }
879 }
880 }
881 }
882
883 let avg = if count > 0 {
884 Some(total_depth as f32 / count as f32)
885 } else {
886 None
887 };
888
889 (Some(max_d), avg)
890}
891
892fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
894 const WHITE: u8 = 0;
895 const GRAY: u8 = 1;
896 const BLACK: u8 = 2;
897
898 let mut color: HashMap<&str, u8> = HashMap::with_capacity(all_nodes.len());
899 for &node in all_nodes {
900 color.insert(node, WHITE);
901 }
902
903 let mut cycles = 0;
904
905 fn dfs<'a>(
906 node: &'a str,
907 children: &HashMap<&str, Vec<&'a str>>,
908 color: &mut HashMap<&'a str, u8>,
909 cycles: &mut usize,
910 ) {
911 color.insert(node, GRAY);
912
913 if let Some(kids) = children.get(node) {
914 for &kid in kids {
915 match color.get(kid).copied().unwrap_or(WHITE) {
916 GRAY => *cycles += 1, WHITE => dfs(kid, children, color, cycles),
918 _ => {}
919 }
920 }
921 }
922
923 color.insert(node, BLACK);
924 }
925
926 for &node in all_nodes {
927 if color.get(node).copied().unwrap_or(WHITE) == WHITE {
928 dfs(node, children, &mut color, &mut cycles);
929 }
930 }
931
932 cycles
933}
934
935fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
937 if all_nodes.is_empty() {
938 return 0;
939 }
940
941 let node_idx: HashMap<&str, usize> =
943 all_nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
944
945 let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
946 let mut rank: Vec<u8> = vec![0; all_nodes.len()];
947
948 fn find(parent: &mut Vec<usize>, x: usize) -> usize {
949 if parent[x] != x {
950 parent[x] = find(parent, parent[x]); }
952 parent[x]
953 }
954
955 fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
956 let ra = find(parent, a);
957 let rb = find(parent, b);
958 if ra != rb {
959 if rank[ra] < rank[rb] {
960 parent[ra] = rb;
961 } else if rank[ra] > rank[rb] {
962 parent[rb] = ra;
963 } else {
964 parent[rb] = ra;
965 rank[ra] += 1;
966 }
967 }
968 }
969
970 for edge in edges {
971 if let (Some(&a), Some(&b)) = (
972 node_idx.get(edge.from.value()),
973 node_idx.get(edge.to.value()),
974 ) {
975 union(&mut parent, &mut rank, a, b);
976 }
977 }
978
979 let mut roots = HashSet::new();
981 for i in 0..all_nodes.len() {
982 roots.insert(find(&mut parent, i));
983 }
984
985 roots.len()
986}
987
988fn compute_complexity(
993 edges: usize,
994 components: usize,
995 max_depth: usize,
996 max_out_degree: usize,
997 cycle_count: usize,
998 _orphans: usize,
999 islands: usize,
1000) -> (f32, ComplexityLevel, ComplexityFactors) {
1001 if components == 0 {
1002 let factors = ComplexityFactors {
1003 dependency_volume: 0.0,
1004 normalized_depth: 0.0,
1005 fanout_concentration: 0.0,
1006 cycle_ratio: 0.0,
1007 fragmentation: 0.0,
1008 };
1009 return (100.0, ComplexityLevel::Low, factors);
1010 }
1011
1012 let edge_ratio = edges as f64 / components as f64;
1014 let dependency_volume = ((1.0 + edge_ratio).ln() / 20.0_f64.ln()).min(1.0) as f32;
1015
1016 let normalized_depth = (max_depth as f32 / 15.0).min(1.0);
1018
1019 let fanout_denom = (components as f32 * 0.25).max(4.0);
1022 let fanout_concentration = (max_out_degree as f32 / fanout_denom).min(1.0);
1023
1024 let cycle_threshold = (components as f32 * 0.05).max(1.0);
1026 let cycle_ratio = (cycle_count as f32 / cycle_threshold).min(1.0);
1027
1028 let extra_islands = islands.saturating_sub(1);
1031 let fragmentation = if components > 1 {
1032 (extra_islands as f32 / (components - 1) as f32).min(1.0)
1033 } else {
1034 0.0
1035 };
1036
1037 let factors = ComplexityFactors {
1038 dependency_volume,
1039 normalized_depth,
1040 fanout_concentration,
1041 cycle_ratio,
1042 fragmentation,
1043 };
1044
1045 let raw_complexity = 0.30 * dependency_volume
1046 + 0.20 * normalized_depth
1047 + 0.20 * fanout_concentration
1048 + 0.20 * cycle_ratio
1049 + 0.10 * fragmentation;
1050
1051 let simplicity_index = (100.0 - raw_complexity * 100.0).clamp(0.0, 100.0);
1052 let level = ComplexityLevel::from_score(simplicity_index);
1053
1054 (simplicity_index, level, factors)
1055}
1056
1057#[derive(Debug, Clone, Serialize, Deserialize)]
1063pub struct ProvenanceMetrics {
1064 pub has_tool_creator: bool,
1066 pub has_tool_version: bool,
1068 pub has_org_creator: bool,
1070 pub has_contact_email: bool,
1072 pub has_serial_number: bool,
1074 pub has_document_name: bool,
1076 pub timestamp_age_days: u32,
1078 pub is_fresh: bool,
1080 pub has_primary_component: bool,
1082 pub lifecycle_phase: Option<String>,
1084 pub completeness_declaration: CompletenessDeclaration,
1086 pub has_signature: bool,
1088}
1089
1090const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
1092
1093impl ProvenanceMetrics {
1094 #[must_use]
1096 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1097 let doc = &sbom.document;
1098
1099 let has_tool_creator = doc
1100 .creators
1101 .iter()
1102 .any(|c| c.creator_type == CreatorType::Tool);
1103 let has_tool_version = doc.creators.iter().any(|c| {
1104 c.creator_type == CreatorType::Tool
1105 && (c.name.contains(' ') || c.name.contains('/') || c.name.contains('@'))
1106 });
1107 let has_org_creator = doc
1108 .creators
1109 .iter()
1110 .any(|c| c.creator_type == CreatorType::Organization);
1111 let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
1112
1113 let age_days = (chrono::Utc::now() - doc.created).num_days().max(0) as u32;
1114
1115 Self {
1116 has_tool_creator,
1117 has_tool_version,
1118 has_org_creator,
1119 has_contact_email,
1120 has_serial_number: doc.serial_number.is_some(),
1121 has_document_name: doc.name.is_some(),
1122 timestamp_age_days: age_days,
1123 is_fresh: age_days < FRESHNESS_THRESHOLD_DAYS,
1124 has_primary_component: sbom.primary_component_id.is_some(),
1125 lifecycle_phase: doc.lifecycle_phase.clone(),
1126 completeness_declaration: doc.completeness_declaration.clone(),
1127 has_signature: doc.signature.is_some(),
1128 }
1129 }
1130
1131 #[must_use]
1138 pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
1139 let mut score = 0.0;
1140 let mut total_weight = 0.0;
1141
1142 let completeness_declared =
1143 self.completeness_declaration != CompletenessDeclaration::Unknown;
1144
1145 let checks: &[(bool, f32)] = &[
1146 (self.has_tool_creator, 15.0),
1147 (self.has_tool_version, 5.0),
1148 (self.has_org_creator, 12.0),
1149 (self.has_contact_email, 8.0),
1150 (self.has_serial_number, 8.0),
1151 (self.has_document_name, 5.0),
1152 (self.is_fresh, 12.0),
1153 (self.has_primary_component, 12.0),
1154 (completeness_declared, 8.0),
1155 (self.has_signature, 5.0),
1156 ];
1157
1158 for &(present, weight) in checks {
1159 if present {
1160 score += weight;
1161 }
1162 total_weight += weight;
1163 }
1164
1165 if is_cyclonedx {
1167 let weight = 10.0;
1168 if self.lifecycle_phase.is_some() {
1169 score += weight;
1170 }
1171 total_weight += weight;
1172 }
1173
1174 if total_weight > 0.0 {
1175 (score / total_weight) * 100.0
1176 } else {
1177 0.0
1178 }
1179 }
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct AuditabilityMetrics {
1189 pub components_with_vcs: usize,
1191 pub components_with_website: usize,
1193 pub components_with_advisories: usize,
1195 pub components_with_any_external_ref: usize,
1197 pub has_security_contact: bool,
1199 pub has_vuln_disclosure_url: bool,
1201}
1202
1203impl AuditabilityMetrics {
1204 #[must_use]
1206 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1207 let mut with_vcs = 0;
1208 let mut with_website = 0;
1209 let mut with_advisories = 0;
1210 let mut with_any = 0;
1211
1212 for comp in sbom.components.values() {
1213 if comp.external_refs.is_empty() {
1214 continue;
1215 }
1216 with_any += 1;
1217
1218 let has_vcs = comp
1219 .external_refs
1220 .iter()
1221 .any(|r| r.ref_type == ExternalRefType::Vcs);
1222 let has_website = comp
1223 .external_refs
1224 .iter()
1225 .any(|r| r.ref_type == ExternalRefType::Website);
1226 let has_advisories = comp
1227 .external_refs
1228 .iter()
1229 .any(|r| r.ref_type == ExternalRefType::Advisories);
1230
1231 if has_vcs {
1232 with_vcs += 1;
1233 }
1234 if has_website {
1235 with_website += 1;
1236 }
1237 if has_advisories {
1238 with_advisories += 1;
1239 }
1240 }
1241
1242 Self {
1243 components_with_vcs: with_vcs,
1244 components_with_website: with_website,
1245 components_with_advisories: with_advisories,
1246 components_with_any_external_ref: with_any,
1247 has_security_contact: sbom.document.security_contact.is_some(),
1248 has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1249 }
1250 }
1251
1252 #[must_use]
1256 pub fn quality_score(&self, total_components: usize) -> f32 {
1257 if total_components == 0 {
1258 return 0.0;
1259 }
1260
1261 let ref_coverage =
1263 (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1264 let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1265
1266 let security_contact_score = if self.has_security_contact { 20.0 } else { 0.0 };
1268 let disclosure_score = if self.has_vuln_disclosure_url {
1269 20.0
1270 } else {
1271 0.0
1272 };
1273
1274 (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1275 }
1276}
1277
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1284pub struct LifecycleMetrics {
1285 pub eol_components: usize,
1287 pub stale_components: usize,
1289 pub deprecated_components: usize,
1291 pub archived_components: usize,
1293 pub outdated_components: usize,
1295 pub enriched_components: usize,
1297 pub enrichment_coverage: f32,
1299}
1300
1301impl LifecycleMetrics {
1302 #[must_use]
1308 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1309 let total = sbom.components.len();
1310 let mut eol = 0;
1311 let mut stale = 0;
1312 let mut deprecated = 0;
1313 let mut archived = 0;
1314 let mut outdated = 0;
1315 let mut enriched = 0;
1316
1317 for comp in sbom.components.values() {
1318 let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1319 if has_lifecycle_data {
1320 enriched += 1;
1321 }
1322
1323 if let Some(ref eol_info) = comp.eol
1324 && eol_info.status == EolStatus::EndOfLife
1325 {
1326 eol += 1;
1327 }
1328
1329 if let Some(ref stale_info) = comp.staleness {
1330 match stale_info.level {
1331 StalenessLevel::Stale | StalenessLevel::Abandoned => stale += 1,
1332 StalenessLevel::Deprecated => deprecated += 1,
1333 StalenessLevel::Archived => archived += 1,
1334 _ => {}
1335 }
1336 if stale_info.is_deprecated {
1337 deprecated += 1;
1338 }
1339 if stale_info.is_archived {
1340 archived += 1;
1341 }
1342 if stale_info.latest_version.is_some() {
1343 outdated += 1;
1344 }
1345 }
1346 }
1347
1348 let coverage = if total > 0 {
1349 (enriched as f32 / total as f32) * 100.0
1350 } else {
1351 0.0
1352 };
1353
1354 Self {
1355 eol_components: eol,
1356 stale_components: stale,
1357 deprecated_components: deprecated,
1358 archived_components: archived,
1359 outdated_components: outdated,
1360 enriched_components: enriched,
1361 enrichment_coverage: coverage,
1362 }
1363 }
1364
1365 #[must_use]
1367 pub fn has_data(&self) -> bool {
1368 self.enriched_components > 0
1369 }
1370
1371 #[must_use]
1376 pub fn quality_score(&self) -> Option<f32> {
1377 if !self.has_data() {
1378 return None;
1379 }
1380
1381 let mut score = 100.0_f32;
1382
1383 score -= (self.eol_components as f32 * 15.0).min(60.0);
1385 score -= (self.stale_components as f32 * 5.0).min(30.0);
1387 score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1389 score -= (self.outdated_components as f32 * 1.0).min(10.0);
1391
1392 Some(score.clamp(0.0, 100.0))
1393 }
1394}
1395
1396fn is_valid_purl(purl: &str) -> bool {
1401 purl.starts_with("pkg:") && purl.contains('/')
1403}
1404
1405fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
1406 if let Some(rest) = purl.strip_prefix("pkg:")
1408 && let Some(slash_idx) = rest.find('/')
1409 {
1410 return Some(rest[..slash_idx].to_string());
1411 }
1412 None
1413}
1414
1415fn is_valid_cpe(cpe: &str) -> bool {
1416 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
1418}
1419
1420fn is_valid_spdx_license(expr: &str) -> bool {
1421 const COMMON_SPDX: &[&str] = &[
1423 "MIT",
1424 "Apache-2.0",
1425 "GPL-2.0",
1426 "GPL-3.0",
1427 "BSD-2-Clause",
1428 "BSD-3-Clause",
1429 "ISC",
1430 "MPL-2.0",
1431 "LGPL-2.1",
1432 "LGPL-3.0",
1433 "AGPL-3.0",
1434 "Unlicense",
1435 "CC0-1.0",
1436 "0BSD",
1437 "EPL-2.0",
1438 "CDDL-1.0",
1439 "Artistic-2.0",
1440 "GPL-2.0-only",
1441 "GPL-2.0-or-later",
1442 "GPL-3.0-only",
1443 "GPL-3.0-or-later",
1444 "LGPL-2.1-only",
1445 "LGPL-2.1-or-later",
1446 "LGPL-3.0-only",
1447 "LGPL-3.0-or-later",
1448 ];
1449
1450 let trimmed = expr.trim();
1452 COMMON_SPDX.contains(&trimmed)
1453 || trimmed.contains(" AND ")
1454 || trimmed.contains(" OR ")
1455 || trimmed.contains(" WITH ")
1456}
1457
1458fn is_deprecated_spdx_license(expr: &str) -> bool {
1463 const DEPRECATED: &[&str] = &[
1464 "GPL-2.0",
1465 "GPL-2.0+",
1466 "GPL-3.0",
1467 "GPL-3.0+",
1468 "LGPL-2.0",
1469 "LGPL-2.0+",
1470 "LGPL-2.1",
1471 "LGPL-2.1+",
1472 "LGPL-3.0",
1473 "LGPL-3.0+",
1474 "AGPL-1.0",
1475 "AGPL-3.0",
1476 "GFDL-1.1",
1477 "GFDL-1.2",
1478 "GFDL-1.3",
1479 "BSD-2-Clause-FreeBSD",
1480 "BSD-2-Clause-NetBSD",
1481 "eCos-2.0",
1482 "Nunit",
1483 "StandardML-NJ",
1484 "wxWindows",
1485 ];
1486 let trimmed = expr.trim();
1487 DEPRECATED.contains(&trimmed)
1488}
1489
1490fn is_restrictive_license(expr: &str) -> bool {
1495 let trimmed = expr.trim().to_uppercase();
1496 trimmed.starts_with("GPL")
1497 || trimmed.starts_with("LGPL")
1498 || trimmed.starts_with("AGPL")
1499 || trimmed.starts_with("EUPL")
1500 || trimmed.starts_with("SSPL")
1501 || trimmed.starts_with("OSL")
1502 || trimmed.starts_with("CPAL")
1503 || trimmed.starts_with("CC-BY-SA")
1504 || trimmed.starts_with("CC-BY-NC")
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510
1511 #[test]
1512 fn test_purl_validation() {
1513 assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
1514 assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
1515 assert!(!is_valid_purl("npm:something"));
1516 assert!(!is_valid_purl("invalid"));
1517 }
1518
1519 #[test]
1520 fn test_cpe_validation() {
1521 assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
1522 assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
1523 assert!(!is_valid_cpe("something:else"));
1524 }
1525
1526 #[test]
1527 fn test_spdx_license_validation() {
1528 assert!(is_valid_spdx_license("MIT"));
1529 assert!(is_valid_spdx_license("Apache-2.0"));
1530 assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
1531 assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
1532 }
1533
1534 #[test]
1535 fn test_strong_hash_classification() {
1536 assert!(is_strong_hash(&HashAlgorithm::Sha256));
1537 assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
1538 assert!(is_strong_hash(&HashAlgorithm::Blake3));
1539 assert!(!is_strong_hash(&HashAlgorithm::Md5));
1540 assert!(!is_strong_hash(&HashAlgorithm::Sha1));
1541 assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
1542 }
1543
1544 #[test]
1545 fn test_deprecated_license_detection() {
1546 assert!(is_deprecated_spdx_license("GPL-2.0"));
1547 assert!(is_deprecated_spdx_license("LGPL-2.1"));
1548 assert!(is_deprecated_spdx_license("AGPL-3.0"));
1549 assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
1550 assert!(!is_deprecated_spdx_license("MIT"));
1551 assert!(!is_deprecated_spdx_license("Apache-2.0"));
1552 }
1553
1554 #[test]
1555 fn test_restrictive_license_detection() {
1556 assert!(is_restrictive_license("GPL-3.0-only"));
1557 assert!(is_restrictive_license("LGPL-2.1-or-later"));
1558 assert!(is_restrictive_license("AGPL-3.0-only"));
1559 assert!(is_restrictive_license("EUPL-1.2"));
1560 assert!(is_restrictive_license("CC-BY-SA-4.0"));
1561 assert!(!is_restrictive_license("MIT"));
1562 assert!(!is_restrictive_license("Apache-2.0"));
1563 assert!(!is_restrictive_license("BSD-3-Clause"));
1564 }
1565
1566 #[test]
1567 fn test_hash_quality_score_no_components() {
1568 let metrics = HashQualityMetrics {
1569 components_with_any_hash: 0,
1570 components_with_strong_hash: 0,
1571 components_with_weak_only: 0,
1572 algorithm_distribution: BTreeMap::new(),
1573 total_hashes: 0,
1574 };
1575 assert_eq!(metrics.quality_score(0), 0.0);
1576 }
1577
1578 #[test]
1579 fn test_hash_quality_score_all_strong() {
1580 let metrics = HashQualityMetrics {
1581 components_with_any_hash: 10,
1582 components_with_strong_hash: 10,
1583 components_with_weak_only: 0,
1584 algorithm_distribution: BTreeMap::new(),
1585 total_hashes: 10,
1586 };
1587 assert_eq!(metrics.quality_score(10), 100.0);
1588 }
1589
1590 #[test]
1591 fn test_hash_quality_score_weak_only_penalty() {
1592 let metrics = HashQualityMetrics {
1593 components_with_any_hash: 10,
1594 components_with_strong_hash: 0,
1595 components_with_weak_only: 10,
1596 algorithm_distribution: BTreeMap::new(),
1597 total_hashes: 10,
1598 };
1599 assert_eq!(metrics.quality_score(10), 50.0);
1601 }
1602
1603 #[test]
1604 fn test_lifecycle_no_enrichment_returns_none() {
1605 let metrics = LifecycleMetrics {
1606 eol_components: 0,
1607 stale_components: 0,
1608 deprecated_components: 0,
1609 archived_components: 0,
1610 outdated_components: 0,
1611 enriched_components: 0,
1612 enrichment_coverage: 0.0,
1613 };
1614 assert!(!metrics.has_data());
1615 assert!(metrics.quality_score().is_none());
1616 }
1617
1618 #[test]
1619 fn test_lifecycle_with_eol_penalty() {
1620 let metrics = LifecycleMetrics {
1621 eol_components: 2,
1622 stale_components: 0,
1623 deprecated_components: 0,
1624 archived_components: 0,
1625 outdated_components: 0,
1626 enriched_components: 10,
1627 enrichment_coverage: 100.0,
1628 };
1629 assert_eq!(metrics.quality_score(), Some(70.0));
1631 }
1632
1633 #[test]
1634 fn test_cycle_detection_no_cycles() {
1635 let children: HashMap<&str, Vec<&str>> =
1636 HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
1637 let all_nodes = vec!["a", "b", "c"];
1638 assert_eq!(detect_cycles(&all_nodes, &children), 0);
1639 }
1640
1641 #[test]
1642 fn test_cycle_detection_with_cycle() {
1643 let children: HashMap<&str, Vec<&str>> =
1644 HashMap::from([("a", vec!["b"]), ("b", vec!["c"]), ("c", vec!["a"])]);
1645 let all_nodes = vec!["a", "b", "c"];
1646 assert_eq!(detect_cycles(&all_nodes, &children), 1);
1647 }
1648
1649 #[test]
1650 fn test_depth_computation() {
1651 let children: HashMap<&str, Vec<&str>> =
1652 HashMap::from([("root", vec!["a", "b"]), ("a", vec!["c"])]);
1653 let roots = vec!["root"];
1654 let (max_d, avg_d) = compute_depth(&roots, &children);
1655 assert_eq!(max_d, Some(2)); assert!(avg_d.is_some());
1657 }
1658
1659 #[test]
1660 fn test_depth_empty_roots() {
1661 let children: HashMap<&str, Vec<&str>> = HashMap::new();
1662 let roots: Vec<&str> = vec![];
1663 let (max_d, avg_d) = compute_depth(&roots, &children);
1664 assert_eq!(max_d, None);
1665 assert_eq!(avg_d, None);
1666 }
1667
1668 #[test]
1669 fn test_provenance_quality_score() {
1670 let metrics = ProvenanceMetrics {
1671 has_tool_creator: true,
1672 has_tool_version: true,
1673 has_org_creator: true,
1674 has_contact_email: true,
1675 has_serial_number: true,
1676 has_document_name: true,
1677 timestamp_age_days: 10,
1678 is_fresh: true,
1679 has_primary_component: true,
1680 lifecycle_phase: Some("build".to_string()),
1681 completeness_declaration: CompletenessDeclaration::Complete,
1682 has_signature: true,
1683 };
1684 assert_eq!(metrics.quality_score(true), 100.0);
1686 }
1687
1688 #[test]
1689 fn test_provenance_score_without_cyclonedx() {
1690 let metrics = ProvenanceMetrics {
1691 has_tool_creator: true,
1692 has_tool_version: true,
1693 has_org_creator: true,
1694 has_contact_email: true,
1695 has_serial_number: true,
1696 has_document_name: true,
1697 timestamp_age_days: 10,
1698 is_fresh: true,
1699 has_primary_component: true,
1700 lifecycle_phase: None,
1701 completeness_declaration: CompletenessDeclaration::Complete,
1702 has_signature: true,
1703 };
1704 assert_eq!(metrics.quality_score(false), 100.0);
1706 }
1707
1708 #[test]
1709 fn test_complexity_empty_graph() {
1710 let (simplicity, level, factors) = compute_complexity(0, 0, 0, 0, 0, 0, 0);
1711 assert_eq!(simplicity, 100.0);
1712 assert_eq!(level, ComplexityLevel::Low);
1713 assert_eq!(factors.dependency_volume, 0.0);
1714 }
1715
1716 #[test]
1717 fn test_complexity_single_node() {
1718 let (simplicity, level, _) = compute_complexity(0, 1, 0, 0, 0, 1, 1);
1720 assert!(
1721 simplicity >= 80.0,
1722 "Single node simplicity {simplicity} should be >= 80"
1723 );
1724 assert_eq!(level, ComplexityLevel::Low);
1725 }
1726
1727 #[test]
1728 fn test_complexity_monotonic_edges() {
1729 let (s1, _, _) = compute_complexity(5, 10, 2, 3, 0, 1, 1);
1731 let (s2, _, _) = compute_complexity(20, 10, 2, 3, 0, 1, 1);
1732 assert!(
1733 s2 <= s1,
1734 "More edges should not increase simplicity: {s2} vs {s1}"
1735 );
1736 }
1737
1738 #[test]
1739 fn test_complexity_monotonic_cycles() {
1740 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
1741 let (s2, _, _) = compute_complexity(10, 10, 2, 3, 3, 1, 1);
1742 assert!(
1743 s2 <= s1,
1744 "More cycles should not increase simplicity: {s2} vs {s1}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_complexity_monotonic_depth() {
1750 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
1751 let (s2, _, _) = compute_complexity(10, 10, 10, 3, 0, 1, 1);
1752 assert!(
1753 s2 <= s1,
1754 "More depth should not increase simplicity: {s2} vs {s1}"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_complexity_graph_skipped() {
1760 let (simplicity, _, _) = compute_complexity(100, 50, 5, 10, 2, 5, 3);
1763 assert!(simplicity >= 0.0 && simplicity <= 100.0);
1764 }
1765
1766 #[test]
1767 fn test_complexity_level_bands() {
1768 assert_eq!(ComplexityLevel::from_score(100.0), ComplexityLevel::Low);
1769 assert_eq!(ComplexityLevel::from_score(75.0), ComplexityLevel::Low);
1770 assert_eq!(ComplexityLevel::from_score(74.0), ComplexityLevel::Moderate);
1771 assert_eq!(ComplexityLevel::from_score(50.0), ComplexityLevel::Moderate);
1772 assert_eq!(ComplexityLevel::from_score(49.0), ComplexityLevel::High);
1773 assert_eq!(ComplexityLevel::from_score(25.0), ComplexityLevel::High);
1774 assert_eq!(ComplexityLevel::from_score(24.0), ComplexityLevel::VeryHigh);
1775 assert_eq!(ComplexityLevel::from_score(0.0), ComplexityLevel::VeryHigh);
1776 }
1777
1778 #[test]
1779 fn test_completeness_declaration_display() {
1780 assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
1781 assert_eq!(
1782 CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
1783 "incomplete (first-party only)"
1784 );
1785 assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
1786 }
1787}