Skip to main content

sbom_tools/diff/
multi_engine.rs

1//! Multi-SBOM comparison engines.
2//!
3//! Uses [`IncrementalDiffEngine`] internally to cache diff results across
4//! repeated comparisons (timeline, matrix, diff-multi), avoiding redundant
5//! recomputation when the same SBOM pair is compared multiple times.
6
7use super::incremental::IncrementalDiffEngine;
8use super::multi::{
9    ComparisonResult, ComplianceScoreEntry, ComplianceSnapshot, ComponentEvolution,
10    DependencySnapshot, DivergenceType, DivergentComponent, EvolutionSummary,
11    InconsistentComponent, MatrixResult, MultiDiffResult, MultiDiffSummary, SbomCluster,
12    SbomClustering, SbomInfo, SecurityImpact, TimelineResult, VariableComponent, VersionAtPoint,
13    VersionChangeType, VersionSpread, VulnerabilityMatrix, VulnerabilitySnapshot,
14};
15use super::{DiffEngine, DiffResult};
16use crate::matching::FuzzyMatchConfig;
17use crate::model::{NormalizedSbom, VulnerabilityCounts};
18use std::collections::{HashMap, HashSet};
19
20/// Engine for multi-SBOM comparisons.
21///
22/// Internally wraps an [`IncrementalDiffEngine`] so that repeated comparisons
23/// of the same SBOM pairs (common in timeline and matrix modes) benefit from
24/// result caching.
25pub struct MultiDiffEngine {
26    /// Fuzzy matching configuration (applied when building the engine).
27    fuzzy_config: Option<FuzzyMatchConfig>,
28    /// Whether to include unchanged components in diff results.
29    include_unchanged: bool,
30    /// Graph diff configuration (optional).
31    graph_diff_config: Option<super::GraphDiffConfig>,
32    /// Caching wrapper built lazily on first diff operation.
33    incremental: Option<IncrementalDiffEngine>,
34}
35
36impl MultiDiffEngine {
37    #[must_use]
38    pub const fn new() -> Self {
39        Self {
40            fuzzy_config: None,
41            include_unchanged: false,
42            graph_diff_config: None,
43            incremental: None,
44        }
45    }
46
47    /// Configure fuzzy matching
48    #[must_use]
49    pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
50        self.fuzzy_config = Some(config);
51        self.incremental = None;
52        self
53    }
54
55    /// Include unchanged components
56    #[must_use]
57    pub fn include_unchanged(mut self, include: bool) -> Self {
58        self.include_unchanged = include;
59        self.incremental = None;
60        self
61    }
62
63    /// Enable graph-aware diffing with the given configuration
64    #[must_use]
65    pub fn with_graph_diff(mut self, config: super::GraphDiffConfig) -> Self {
66        self.graph_diff_config = Some(config);
67        self.incremental = None;
68        self
69    }
70
71    /// Build the configured `DiffEngine` and wrap it in an `IncrementalDiffEngine`.
72    fn ensure_engine(&mut self) {
73        if self.incremental.is_none() {
74            let mut engine = DiffEngine::new();
75            if let Some(config) = self.fuzzy_config.clone() {
76                engine = engine.with_fuzzy_config(config);
77            }
78            engine = engine.include_unchanged(self.include_unchanged);
79            if let Some(config) = self.graph_diff_config.clone() {
80                engine = engine.with_graph_diff(config);
81            }
82            self.incremental = Some(IncrementalDiffEngine::new(engine));
83        }
84    }
85
86    /// Perform a single diff using the cached incremental engine.
87    fn cached_diff(&mut self, old: &NormalizedSbom, new: &NormalizedSbom) -> DiffResult {
88        self.ensure_engine();
89        self.incremental
90            .as_ref()
91            .expect("engine initialized by ensure_engine")
92            .diff(old, new)
93            .into_result()
94    }
95
96    /// Perform 1:N diff-multi comparison (baseline vs multiple targets)
97    pub fn diff_multi(
98        &mut self,
99        baseline: &NormalizedSbom,
100        baseline_name: &str,
101        baseline_path: &str,
102        targets: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
103    ) -> MultiDiffResult {
104        let baseline_info = SbomInfo::from_sbom(
105            baseline,
106            baseline_name.to_string(),
107            baseline_path.to_string(),
108        );
109
110        // Compute individual diffs
111        let mut comparisons: Vec<ComparisonResult> = Vec::new();
112        let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new(); // component_id -> (target_name -> version)
113
114        // Collect baseline versions
115        for (id, comp) in &baseline.components {
116            let version = comp.version.clone().unwrap_or_default();
117            all_versions
118                .entry(id.value().to_string())
119                .or_default()
120                .insert(baseline_name.to_string(), version);
121        }
122
123        for (target_sbom, target_name, target_path) in targets {
124            let diff = self.cached_diff(baseline, target_sbom);
125            let target_info = SbomInfo::from_sbom(
126                target_sbom,
127                target_name.to_string(),
128                target_path.to_string(),
129            );
130
131            // Collect target versions
132            for (id, comp) in &target_sbom.components {
133                let version = comp.version.clone().unwrap_or_default();
134                all_versions
135                    .entry(id.value().to_string())
136                    .or_default()
137                    .insert(target_name.to_string(), version);
138            }
139
140            comparisons.push(ComparisonResult {
141                target: target_info,
142                diff,
143                unique_components: vec![],    // Computed in summary phase
144                divergent_components: vec![], // Computed in summary phase
145            });
146        }
147
148        // Compute summary
149        let summary = self.compute_multi_diff_summary(
150            &baseline_info,
151            baseline,
152            &comparisons,
153            targets,
154            &all_versions,
155        );
156
157        // Update comparisons with divergent component info
158        for (i, comp) in comparisons.iter_mut().enumerate() {
159            let (target_sbom, target_name, _) = &targets[i];
160            comp.divergent_components =
161                self.find_divergent_components(baseline, target_sbom, target_name, &all_versions);
162        }
163
164        MultiDiffResult {
165            baseline: baseline_info,
166            comparisons,
167            summary,
168        }
169    }
170
171    fn compute_multi_diff_summary(
172        &self,
173        baseline_info: &SbomInfo,
174        baseline: &NormalizedSbom,
175        comparisons: &[ComparisonResult],
176        targets: &[(&NormalizedSbom, &str, &str)],
177        all_versions: &HashMap<String, HashMap<String, String>>,
178    ) -> MultiDiffSummary {
179        let baseline_components: HashSet<_> = baseline
180            .components
181            .keys()
182            .map(|k| k.value().to_string())
183            .collect();
184        let _target_names: Vec<_> = targets
185            .iter()
186            .map(|(_, name, _)| name.to_string())
187            .collect();
188
189        // Find universal components (in baseline and ALL targets)
190        let mut universal: HashSet<String> = baseline_components.clone();
191        for (target_sbom, _, _) in targets {
192            let target_components: HashSet<_> = target_sbom
193                .components
194                .keys()
195                .map(|k| k.value().to_string())
196                .collect();
197            universal = universal
198                .intersection(&target_components)
199                .cloned()
200                .collect();
201        }
202
203        // Find variable components (different versions across targets)
204        let mut variable_components: Vec<VariableComponent> = vec![];
205        for (comp_id, versions) in all_versions {
206            let unique_versions: HashSet<_> = versions.values().collect();
207            if unique_versions.len() > 1 {
208                let name = baseline
209                    .components
210                    .iter()
211                    .find(|(id, _)| id.value() == comp_id)
212                    .map(|(_, c)| c.name.clone())
213                    .or_else(|| {
214                        targets.iter().find_map(|(sbom, _, _)| {
215                            sbom.components
216                                .iter()
217                                .find(|(id, _)| id.value() == comp_id)
218                                .map(|(_, c)| c.name.clone())
219                        })
220                    })
221                    .unwrap_or_else(|| comp_id.clone());
222
223                let baseline_version = versions.get(&baseline_info.name.clone()).cloned();
224                let all_versions_vec: Vec<_> = unique_versions.into_iter().cloned().collect();
225
226                // Calculate major version spread
227                let major_spread = calculate_major_version_spread(&all_versions_vec);
228
229                variable_components.push(VariableComponent {
230                    id: comp_id.clone(),
231                    name: name.clone(),
232                    ecosystem: None,
233                    version_spread: VersionSpread {
234                        baseline: baseline_version,
235                        min_version: all_versions_vec.iter().min().cloned(),
236                        max_version: all_versions_vec.iter().max().cloned(),
237                        unique_versions: all_versions_vec,
238                        is_consistent: false,
239                        major_version_spread: major_spread,
240                    },
241                    targets_with_component: versions.keys().cloned().collect(),
242                    security_impact: classify_security_impact(&name),
243                });
244            }
245        }
246
247        // Find inconsistent components (missing from some targets)
248        let mut inconsistent_components: Vec<InconsistentComponent> = vec![];
249        let all_component_ids: HashSet<_> = all_versions.keys().cloned().collect();
250
251        for comp_id in &all_component_ids {
252            if universal.contains(comp_id) {
253                continue; // Present everywhere, not inconsistent
254            }
255
256            let in_baseline = baseline_components.contains(comp_id);
257            let mut present_in: Vec<String> = vec![];
258            let mut missing_from: Vec<String> = vec![];
259
260            if in_baseline {
261                present_in.push(baseline_info.name.clone());
262            } else {
263                missing_from.push(baseline_info.name.clone());
264            }
265
266            for (target_sbom, target_name, _) in targets {
267                let has_component = target_sbom
268                    .components
269                    .iter()
270                    .any(|(id, _)| id.value() == comp_id);
271                if has_component {
272                    present_in.push(target_name.to_string());
273                } else {
274                    missing_from.push(target_name.to_string());
275                }
276            }
277
278            if !missing_from.is_empty() {
279                let name = all_versions
280                    .get(comp_id)
281                    .and_then(|_| {
282                        baseline
283                            .components
284                            .iter()
285                            .find(|(id, _)| id.value() == comp_id)
286                            .map(|(_, c)| c.name.clone())
287                    })
288                    .unwrap_or_else(|| comp_id.clone());
289
290                inconsistent_components.push(InconsistentComponent {
291                    id: comp_id.clone(),
292                    name,
293                    in_baseline,
294                    present_in,
295                    missing_from,
296                });
297            }
298        }
299
300        // Compute deviation scores
301        let mut deviation_scores: HashMap<String, f64> = HashMap::new();
302        let mut max_deviation = 0.0f64;
303
304        for comp in comparisons {
305            let score = 100.0 - comp.diff.semantic_score;
306            deviation_scores.insert(comp.target.name.clone(), score);
307            max_deviation = max_deviation.max(score);
308        }
309
310        // Build vulnerability matrix with unique and common vulnerabilities
311        let vulnerability_matrix =
312            compute_vulnerability_matrix(baseline, &baseline_info.name, targets);
313
314        MultiDiffSummary {
315            baseline_component_count: baseline_info.component_count,
316            universal_components: universal.into_iter().collect(),
317            variable_components,
318            inconsistent_components,
319            deviation_scores,
320            max_deviation,
321            vulnerability_matrix,
322        }
323    }
324
325    fn find_divergent_components(
326        &self,
327        baseline: &NormalizedSbom,
328        target: &NormalizedSbom,
329        _target_name: &str,
330        all_versions: &HashMap<String, HashMap<String, String>>,
331    ) -> Vec<DivergentComponent> {
332        let mut divergent = vec![];
333
334        for (id, comp) in &target.components {
335            let comp_id = id.value().to_string();
336            let target_version = comp.version.clone().unwrap_or_default();
337
338            // Check if baseline has different version
339            let baseline_version = baseline
340                .components
341                .iter()
342                .find(|(bid, _)| bid.value() == comp_id)
343                .and_then(|(_, bc)| bc.version.clone());
344
345            let divergence_type = if baseline_version.is_none() {
346                DivergenceType::Added
347            } else if baseline_version.as_ref() != Some(&target_version) {
348                DivergenceType::VersionMismatch
349            } else {
350                continue; // Same version, not divergent
351            };
352
353            divergent.push(DivergentComponent {
354                id: comp_id.clone(),
355                name: comp.name.clone(),
356                baseline_version,
357                target_version,
358                versions_across_targets: all_versions.get(&comp_id).cloned().unwrap_or_default(),
359                divergence_type,
360            });
361        }
362
363        // Check for removed components
364        for (id, comp) in &baseline.components {
365            let comp_id = id.value().to_string();
366            let in_target = target
367                .components
368                .iter()
369                .any(|(tid, _)| tid.value() == comp_id);
370
371            if !in_target {
372                divergent.push(DivergentComponent {
373                    id: comp_id.clone(),
374                    name: comp.name.clone(),
375                    baseline_version: comp.version.clone(),
376                    target_version: String::new(),
377                    versions_across_targets: all_versions
378                        .get(&comp_id)
379                        .cloned()
380                        .unwrap_or_default(),
381                    divergence_type: DivergenceType::Removed,
382                });
383            }
384        }
385
386        divergent
387    }
388
389    /// Perform timeline analysis across ordered SBOM versions
390    pub fn timeline(
391        &mut self,
392        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
393    ) -> TimelineResult {
394        let sbom_infos: Vec<SbomInfo> = sboms
395            .iter()
396            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
397            .collect();
398
399        // Compute incremental diffs (adjacent pairs)
400        let mut incremental_diffs: Vec<DiffResult> = vec![];
401        for i in 0..sboms.len().saturating_sub(1) {
402            let diff = self.cached_diff(sboms[i].0, sboms[i + 1].0);
403            incremental_diffs.push(diff);
404        }
405
406        // Compute cumulative diffs from first
407        let mut cumulative_from_first: Vec<DiffResult> = vec![];
408        if !sboms.is_empty() {
409            for i in 1..sboms.len() {
410                let diff = self.cached_diff(sboms[0].0, sboms[i].0);
411                cumulative_from_first.push(diff);
412            }
413        }
414
415        // Build evolution summary
416        let evolution_summary =
417            self.build_evolution_summary(sboms, &sbom_infos, &incremental_diffs);
418
419        TimelineResult {
420            sboms: sbom_infos,
421            incremental_diffs,
422            cumulative_from_first,
423            evolution_summary,
424        }
425    }
426
427    fn build_evolution_summary(
428        &self,
429        sboms: &[(&NormalizedSbom, &str, &str)],
430        sbom_infos: &[SbomInfo],
431        _incremental_diffs: &[DiffResult],
432    ) -> EvolutionSummary {
433        // Track component versions across timeline
434        let mut version_history: HashMap<String, Vec<VersionAtPoint>> = HashMap::new();
435        let mut components_added: Vec<ComponentEvolution> = vec![];
436        let mut components_removed: Vec<ComponentEvolution> = vec![];
437        let mut all_components: HashSet<String> = HashSet::new();
438
439        // Collect all component IDs
440        for (sbom, _, _) in sboms {
441            for (id, _) in &sbom.components {
442                all_components.insert(id.value().to_string());
443            }
444        }
445
446        // Build version history for each component
447        for comp_id in &all_components {
448            let mut history: Vec<VersionAtPoint> = vec![];
449            let mut first_seen: Option<(usize, String)> = None;
450            let mut last_seen: Option<usize> = None;
451            let mut prev_version: Option<String> = None;
452            let mut version_change_count: usize = 0;
453
454            for (i, (sbom, name, _)) in sboms.iter().enumerate() {
455                let comp = sbom.components.iter().find(|(id, _)| id.value() == comp_id);
456
457                let (version, change_type) = if let Some((_, c)) = comp {
458                    let ver = c.version.clone();
459                    let change = if first_seen.is_none() {
460                        first_seen = Some((i, ver.clone().unwrap_or_default()));
461                        VersionChangeType::Initial
462                    } else {
463                        let ct = classify_version_change(prev_version.as_ref(), ver.as_ref());
464                        // Count actual version changes (not unchanged or absent)
465                        if !matches!(ct, VersionChangeType::Unchanged | VersionChangeType::Absent) {
466                            version_change_count += 1;
467                        }
468                        ct
469                    };
470                    last_seen = Some(i);
471                    prev_version.clone_from(&ver);
472                    (ver, change)
473                } else if first_seen.is_some() {
474                    (None, VersionChangeType::Removed)
475                } else {
476                    (None, VersionChangeType::Absent)
477                };
478
479                history.push(VersionAtPoint {
480                    sbom_index: i,
481                    sbom_name: name.to_string(),
482                    version,
483                    change_type,
484                });
485            }
486
487            version_history.insert(comp_id.clone(), history);
488
489            // Track added/removed
490            if let Some((first_idx, first_ver)) = first_seen {
491                let still_present = last_seen == Some(sboms.len() - 1);
492                let current_version = if still_present {
493                    sboms.last().and_then(|(sbom, _, _)| {
494                        sbom.components
495                            .iter()
496                            .find(|(id, _)| id.value() == comp_id)
497                            .and_then(|(_, c)| c.version.clone())
498                    })
499                } else {
500                    None
501                };
502
503                let name = sboms
504                    .iter()
505                    .find_map(|(sbom, _, _)| {
506                        sbom.components
507                            .iter()
508                            .find(|(id, _)| id.value() == comp_id)
509                            .map(|(_, c)| c.name.clone())
510                    })
511                    .unwrap_or_else(|| comp_id.clone());
512
513                let evolution = ComponentEvolution {
514                    id: comp_id.clone(),
515                    name,
516                    first_seen_index: first_idx,
517                    first_seen_version: first_ver,
518                    last_seen_index: if still_present { None } else { last_seen },
519                    current_version,
520                    version_change_count,
521                };
522
523                if first_idx > 0 {
524                    components_added.push(evolution.clone());
525                }
526                if !still_present {
527                    components_removed.push(evolution);
528                }
529            }
530        }
531
532        // Build vulnerability trend
533        let vulnerability_trend: Vec<VulnerabilitySnapshot> = sbom_infos
534            .iter()
535            .enumerate()
536            .map(|(i, info)| VulnerabilitySnapshot {
537                sbom_index: i,
538                sbom_name: info.name.clone(),
539                counts: info.vulnerability_counts.clone(),
540                new_vulnerabilities: vec![],
541                resolved_vulnerabilities: vec![],
542            })
543            .collect();
544
545        // Build dependency trend, computing transitive deps from edge depth data
546        let dependency_trend: Vec<DependencySnapshot> = sboms
547            .iter()
548            .enumerate()
549            .map(|(i, (sbom, _, _))| {
550                let total_edges = sbom.edges.len();
551                // Count root nodes (no incoming edges) to determine direct vs transitive
552                let targets: HashSet<_> = sbom.edges.iter().map(|e| &e.to).collect();
553                let sources: HashSet<_> = sbom.edges.iter().map(|e| &e.from).collect();
554                let roots: HashSet<_> = sources.difference(&targets).collect();
555                let direct = sbom
556                    .edges
557                    .iter()
558                    .filter(|e| roots.contains(&&e.from))
559                    .count();
560                let transitive = total_edges.saturating_sub(direct);
561
562                DependencySnapshot {
563                    sbom_index: i,
564                    sbom_name: sbom_infos[i].name.clone(),
565                    direct_dependencies: direct,
566                    transitive_dependencies: transitive,
567                    total_edges,
568                }
569            })
570            .collect();
571
572        // Build compliance trend
573        let compliance_trend: Vec<ComplianceSnapshot> = sboms
574            .iter()
575            .enumerate()
576            .map(|(i, (sbom, name, _))| {
577                use crate::quality::{ComplianceChecker, ComplianceLevel};
578                let scores = ComplianceLevel::all()
579                    .iter()
580                    .map(|level| {
581                        let result = ComplianceChecker::new(*level).check(sbom);
582                        ComplianceScoreEntry {
583                            standard: level.name().to_string(),
584                            error_count: result.error_count,
585                            warning_count: result.warning_count,
586                            info_count: result.info_count,
587                            is_compliant: result.is_compliant,
588                        }
589                    })
590                    .collect();
591                ComplianceSnapshot {
592                    sbom_index: i,
593                    sbom_name: name.to_string(),
594                    scores,
595                }
596            })
597            .collect();
598
599        EvolutionSummary {
600            components_added,
601            components_removed,
602            version_history,
603            vulnerability_trend,
604            license_changes: vec![],
605            dependency_trend,
606            compliance_trend,
607        }
608    }
609
610    /// Perform N×N matrix comparison
611    pub fn matrix(
612        &mut self,
613        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
614        similarity_threshold: Option<f64>,
615    ) -> MatrixResult {
616        let sbom_infos: Vec<SbomInfo> = sboms
617            .iter()
618            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
619            .collect();
620
621        let n = sboms.len();
622        let num_pairs = n * (n - 1) / 2;
623
624        let mut diffs: Vec<Option<DiffResult>> = vec![None; num_pairs];
625        let mut similarity_scores: Vec<f64> = vec![0.0; num_pairs];
626
627        // Compute upper triangle
628        let mut idx = 0;
629        for i in 0..n {
630            for j in (i + 1)..n {
631                let diff = self.cached_diff(sboms[i].0, sboms[j].0);
632                let similarity = diff.semantic_score / 100.0;
633                similarity_scores[idx] = similarity;
634                diffs[idx] = Some(diff);
635                idx += 1;
636            }
637        }
638
639        // Optional clustering
640        let clustering = similarity_threshold
641            .map(|threshold| self.cluster_sboms(&sbom_infos, &similarity_scores, threshold));
642
643        MatrixResult {
644            sboms: sbom_infos,
645            diffs,
646            similarity_scores,
647            clustering,
648        }
649    }
650
651    fn cluster_sboms(
652        &self,
653        sboms: &[SbomInfo],
654        similarity_scores: &[f64],
655        threshold: f64,
656    ) -> SbomClustering {
657        let n = sboms.len();
658        let mut clusters: Vec<SbomCluster> = vec![];
659        let mut assigned: HashSet<usize> = HashSet::new();
660
661        // Simple greedy clustering
662        for i in 0..n {
663            if assigned.contains(&i) {
664                continue;
665            }
666
667            let mut cluster_members = vec![i];
668            assigned.insert(i);
669
670            for j in (i + 1)..n {
671                if assigned.contains(&j) {
672                    continue;
673                }
674
675                // Get similarity between i and j
676                let idx = i * (2 * n - i - 1) / 2 + (j - i - 1);
677                let similarity = similarity_scores.get(idx).copied().unwrap_or(0.0);
678
679                if similarity >= threshold {
680                    cluster_members.push(j);
681                    assigned.insert(j);
682                }
683            }
684
685            if cluster_members.len() > 1 {
686                // Calculate average internal similarity
687                let mut total_sim = 0.0;
688                let mut count = 0;
689                for (mi, &a) in cluster_members.iter().enumerate() {
690                    for &b in cluster_members.iter().skip(mi + 1) {
691                        let (x, y) = if a < b { (a, b) } else { (b, a) };
692                        let idx = x * (2 * n - x - 1) / 2 + (y - x - 1);
693                        total_sim += similarity_scores.get(idx).copied().unwrap_or(0.0);
694                        count += 1;
695                    }
696                }
697
698                clusters.push(SbomCluster {
699                    members: cluster_members.clone(),
700                    centroid_index: cluster_members[0],
701                    internal_similarity: if count > 0 {
702                        total_sim / f64::from(count)
703                    } else {
704                        1.0
705                    },
706                    label: None,
707                });
708            }
709        }
710
711        // Find outliers
712        let outliers: Vec<usize> = (0..n).filter(|i| !assigned.contains(i)).collect();
713
714        SbomClustering {
715            clusters,
716            outliers,
717            algorithm: "greedy".to_string(),
718            threshold,
719        }
720    }
721}
722
723impl Default for MultiDiffEngine {
724    fn default() -> Self {
725        Self::new()
726    }
727}
728
729/// Classify security impact based on component name
730fn classify_security_impact(name: &str) -> SecurityImpact {
731    let name_lower = name.to_lowercase();
732    let critical_components = [
733        "openssl",
734        "curl",
735        "libcurl",
736        "gnutls",
737        "mbedtls",
738        "wolfssl",
739        "boringssl",
740    ];
741    let high_components = [
742        "zlib", "libssh", "openssh", "gnupg", "gpg", "sqlite", "kernel", "glibc",
743    ];
744
745    if critical_components.iter().any(|c| name_lower.contains(c)) {
746        SecurityImpact::Critical
747    } else if high_components.iter().any(|c| name_lower.contains(c)) {
748        SecurityImpact::High
749    } else {
750        SecurityImpact::Low
751    }
752}
753
754/// Calculate major version spread from a list of version strings
755fn calculate_major_version_spread(versions: &[String]) -> u32 {
756    let mut major_versions: HashSet<u64> = HashSet::new();
757
758    for version in versions {
759        // Try to parse as semver first
760        if let Ok(v) = semver::Version::parse(version) {
761            major_versions.insert(v.major);
762        } else {
763            // Fallback: try to extract leading number
764            if let Some(major_str) = version.split(['.', '-', '_']).next()
765                && let Ok(major) = major_str.parse::<u64>()
766            {
767                major_versions.insert(major);
768            }
769        }
770    }
771
772    match (major_versions.iter().min(), major_versions.iter().max()) {
773        (Some(&min), Some(&max)) => (max - min) as u32,
774        _ => 0,
775    }
776}
777
778/// Compute vulnerability matrix with unique and common vulnerabilities
779fn compute_vulnerability_matrix(
780    baseline: &NormalizedSbom,
781    baseline_name: &str,
782    targets: &[(&NormalizedSbom, &str, &str)],
783) -> VulnerabilityMatrix {
784    // Collect all vulnerabilities per SBOM
785    let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
786    let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
787
788    // Baseline vulnerabilities
789    let baseline_vulns: HashSet<String> = baseline
790        .all_vulnerabilities()
791        .iter()
792        .map(|(_, v)| v.id.clone())
793        .collect();
794    vuln_sets.insert(baseline_name.to_string(), baseline_vulns);
795    per_sbom.insert(baseline_name.to_string(), baseline.vulnerability_counts());
796
797    // Target vulnerabilities
798    for (sbom, name, _) in targets {
799        let target_vulns: HashSet<String> = sbom
800            .all_vulnerabilities()
801            .iter()
802            .map(|(_, v)| v.id.clone())
803            .collect();
804        vuln_sets.insert(name.to_string(), target_vulns);
805        per_sbom.insert(name.to_string(), sbom.vulnerability_counts());
806    }
807
808    // Find common vulnerabilities (in ALL SBOMs)
809    let mut common_vulnerabilities: HashSet<String> =
810        vuln_sets.values().next().cloned().unwrap_or_default();
811
812    for vulns in vuln_sets.values() {
813        common_vulnerabilities = common_vulnerabilities
814            .intersection(vulns)
815            .cloned()
816            .collect();
817    }
818
819    // Find unique vulnerabilities per SBOM
820    let mut unique_vulnerabilities: HashMap<String, Vec<String>> = HashMap::new();
821
822    for (sbom_name, vulns) in &vuln_sets {
823        let mut unique: HashSet<String> = vulns.clone();
824
825        // Remove vulnerabilities that exist in any other SBOM
826        for (other_name, other_vulns) in &vuln_sets {
827            if other_name != sbom_name {
828                unique = unique.difference(other_vulns).cloned().collect();
829            }
830        }
831
832        if !unique.is_empty() {
833            unique_vulnerabilities.insert(sbom_name.clone(), unique.into_iter().collect());
834        }
835    }
836
837    VulnerabilityMatrix {
838        per_sbom,
839        unique_vulnerabilities,
840        common_vulnerabilities: common_vulnerabilities.into_iter().collect(),
841    }
842}
843
844/// Classify version change type
845fn classify_version_change(old: Option<&String>, new: Option<&String>) -> VersionChangeType {
846    match (old, new) {
847        (None, Some(_)) => VersionChangeType::Initial,
848        (Some(_), None) => VersionChangeType::Removed,
849        (Some(o), Some(n)) if o == n => VersionChangeType::Unchanged,
850        (Some(o), Some(n)) => {
851            // Try to parse as semver
852            if let (Ok(old_v), Ok(new_v)) = (semver::Version::parse(o), semver::Version::parse(n)) {
853                if new_v.major > old_v.major {
854                    VersionChangeType::MajorUpgrade
855                } else if new_v.major < old_v.major {
856                    VersionChangeType::Downgrade
857                } else if new_v.minor > old_v.minor {
858                    VersionChangeType::MinorUpgrade
859                } else if new_v.minor < old_v.minor {
860                    VersionChangeType::Downgrade
861                } else if new_v.patch > old_v.patch {
862                    VersionChangeType::PatchUpgrade
863                } else {
864                    VersionChangeType::Downgrade
865                }
866            } else {
867                // String comparison fallback
868                if n > o {
869                    VersionChangeType::PatchUpgrade
870                } else {
871                    VersionChangeType::Downgrade
872                }
873            }
874        }
875        (None, None) => VersionChangeType::Absent,
876    }
877}