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::error::SbomDiffError;
17use crate::matching::{FuzzyMatchConfig, MatchingRulesConfig};
18use crate::model::{NormalizedSbom, VulnerabilityCounts};
19use std::collections::{HashMap, HashSet};
20
21/// Engine for multi-SBOM comparisons.
22///
23/// Internally wraps an [`IncrementalDiffEngine`] so that repeated comparisons
24/// of the same SBOM pairs (common in timeline and matrix modes) benefit from
25/// result caching.
26pub struct MultiDiffEngine {
27    /// Fuzzy matching configuration (applied when building the engine).
28    fuzzy_config: Option<FuzzyMatchConfig>,
29    /// Whether to include unchanged components in diff results.
30    include_unchanged: bool,
31    /// Graph diff configuration (optional).
32    graph_diff_config: Option<super::GraphDiffConfig>,
33    /// Custom matching rules (applied when building the engine).
34    matching_rules: Option<MatchingRulesConfig>,
35    /// Caching wrapper built lazily on first diff operation.
36    incremental: Option<IncrementalDiffEngine>,
37}
38
39impl MultiDiffEngine {
40    #[must_use]
41    pub const fn new() -> Self {
42        Self {
43            fuzzy_config: None,
44            include_unchanged: false,
45            graph_diff_config: None,
46            matching_rules: None,
47            incremental: None,
48        }
49    }
50
51    /// Configure fuzzy matching
52    #[must_use]
53    pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
54        self.fuzzy_config = Some(config);
55        self.incremental = None;
56        self
57    }
58
59    /// Include unchanged components
60    #[must_use]
61    pub fn include_unchanged(mut self, include: bool) -> Self {
62        self.include_unchanged = include;
63        self.incremental = None;
64        self
65    }
66
67    /// Enable graph-aware diffing with the given configuration
68    #[must_use]
69    pub fn with_graph_diff(mut self, config: super::GraphDiffConfig) -> Self {
70        self.graph_diff_config = Some(config);
71        self.incremental = None;
72        self
73    }
74
75    /// Apply custom matching rules to every pairwise diff.
76    #[must_use]
77    pub fn with_matching_rules(mut self, rules: MatchingRulesConfig) -> Self {
78        self.matching_rules = Some(rules);
79        self.incremental = None;
80        self
81    }
82
83    /// Build the configured `DiffEngine` and wrap it in an `IncrementalDiffEngine`.
84    fn ensure_engine(&mut self) {
85        if self.incremental.is_none() {
86            let mut engine = DiffEngine::new();
87            if let Some(config) = self.fuzzy_config.clone() {
88                engine = engine.with_fuzzy_config(config);
89            }
90            engine = engine.include_unchanged(self.include_unchanged);
91            if let Some(config) = self.graph_diff_config.clone() {
92                engine = engine.with_graph_diff(config);
93            }
94            if let Some(rules) = self.matching_rules.clone() {
95                match crate::matching::RuleEngine::new(rules) {
96                    Ok(rule_engine) => engine = engine.with_rule_engine(rule_engine),
97                    Err(err) => {
98                        tracing::warn!("Failed to initialize matching rule engine: {err}");
99                    }
100                }
101            }
102            self.incremental = Some(IncrementalDiffEngine::new(engine));
103        }
104    }
105
106    /// Perform a single diff using the cached incremental engine.
107    fn cached_diff(
108        &mut self,
109        old: &NormalizedSbom,
110        new: &NormalizedSbom,
111    ) -> Result<DiffResult, SbomDiffError> {
112        self.ensure_engine();
113        Ok(self
114            .incremental
115            .as_ref()
116            .expect("engine initialized by ensure_engine")
117            .diff(old, new)?
118            .into_result())
119    }
120
121    /// Perform 1:N diff-multi comparison (baseline vs multiple targets)
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if any pairwise diff computation fails.
126    pub fn diff_multi(
127        &mut self,
128        baseline: &NormalizedSbom,
129        baseline_name: &str,
130        baseline_path: &str,
131        targets: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
132    ) -> Result<MultiDiffResult, SbomDiffError> {
133        let baseline_info = SbomInfo::from_sbom(
134            baseline,
135            baseline_name.to_string(),
136            baseline_path.to_string(),
137        );
138
139        // Compute individual diffs
140        let mut comparisons: Vec<ComparisonResult> = Vec::new();
141        // logical component id (version-stripped) -> (sbom_name -> version).
142        // Keyed logically so a version bump is ONE component with two
143        // versions, not two half-present components (see strip_purl_version).
144        let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new();
145
146        // Collect baseline versions
147        for (id, comp) in &baseline.components {
148            let version = comp.version.clone().unwrap_or_default();
149            all_versions
150                .entry(strip_purl_version(id.value()).to_string())
151                .or_default()
152                .insert(baseline_name.to_string(), version);
153        }
154
155        for (target_sbom, target_name, target_path) in targets {
156            let diff = self.cached_diff(baseline, target_sbom)?;
157            let target_info = SbomInfo::from_sbom(
158                target_sbom,
159                target_name.to_string(),
160                target_path.to_string(),
161            );
162
163            // Collect target versions
164            for (id, comp) in &target_sbom.components {
165                let version = comp.version.clone().unwrap_or_default();
166                all_versions
167                    .entry(strip_purl_version(id.value()).to_string())
168                    .or_default()
169                    .insert(target_name.to_string(), version);
170            }
171
172            comparisons.push(ComparisonResult {
173                target: target_info,
174                diff,
175                unique_components: vec![],    // Computed in summary phase
176                divergent_components: vec![], // Computed in summary phase
177            });
178        }
179
180        // Compute summary
181        let summary = self.compute_multi_diff_summary(
182            &baseline_info,
183            baseline,
184            &comparisons,
185            targets,
186            &all_versions,
187        );
188
189        // Update comparisons with divergent component info
190        for (i, comp) in comparisons.iter_mut().enumerate() {
191            let (target_sbom, target_name, _) = &targets[i];
192            comp.divergent_components =
193                self.find_divergent_components(baseline, target_sbom, target_name, &all_versions);
194        }
195
196        Ok(MultiDiffResult {
197            baseline: baseline_info,
198            comparisons,
199            summary,
200        })
201    }
202
203    fn compute_multi_diff_summary(
204        &self,
205        baseline_info: &SbomInfo,
206        baseline: &NormalizedSbom,
207        comparisons: &[ComparisonResult],
208        targets: &[(&NormalizedSbom, &str, &str)],
209        all_versions: &HashMap<String, HashMap<String, String>>,
210    ) -> MultiDiffSummary {
211        // All presence sets/maps below are keyed by LOGICAL (version-stripped)
212        // identity, matching `all_versions`, so universal/variable/inconsistent
213        // partition components rather than component@version strings.
214        let baseline_components: HashSet<_> = baseline
215            .components
216            .keys()
217            .map(|k| strip_purl_version(k.value()).to_string())
218            .collect();
219
220        // Per-SBOM component sets and name maps, built once — the loops below
221        // previously did a linear iter().find() per component per SBOM,
222        // O(components² × SBOMs) overall.
223        let target_component_sets: Vec<HashSet<&str>> = targets
224            .iter()
225            .map(|(target_sbom, _, _)| {
226                target_sbom
227                    .components
228                    .keys()
229                    .map(|k| strip_purl_version(k.value()))
230                    .collect()
231            })
232            .collect();
233        let baseline_names: HashMap<&str, &str> = baseline
234            .components
235            .iter()
236            .map(|(id, c)| (strip_purl_version(id.value()), c.name.as_str()))
237            .collect();
238        let target_names: Vec<HashMap<&str, &str>> = targets
239            .iter()
240            .map(|(target_sbom, _, _)| {
241                target_sbom
242                    .components
243                    .iter()
244                    .map(|(id, c)| (strip_purl_version(id.value()), c.name.as_str()))
245                    .collect()
246            })
247            .collect();
248
249        // Find universal components (in baseline and ALL targets)
250        let mut universal: HashSet<String> = baseline_components.clone();
251        universal.retain(|comp_id| {
252            target_component_sets
253                .iter()
254                .all(|set| set.contains(comp_id.as_str()))
255        });
256
257        // Find variable components (different versions across targets)
258        let mut variable_components: Vec<VariableComponent> = vec![];
259        for (comp_id, versions) in all_versions {
260            let unique_versions: HashSet<_> = versions.values().collect();
261            if unique_versions.len() > 1 {
262                let name = baseline_names
263                    .get(comp_id.as_str())
264                    .copied()
265                    .or_else(|| {
266                        target_names
267                            .iter()
268                            .find_map(|names| names.get(comp_id.as_str()).copied())
269                    })
270                    .map_or_else(|| comp_id.clone(), str::to_string);
271
272                let baseline_version = versions.get(&baseline_info.name.clone()).cloned();
273                let all_versions_vec: Vec<_> = unique_versions.into_iter().cloned().collect();
274
275                // Calculate major version spread
276                let major_spread = calculate_major_version_spread(&all_versions_vec);
277
278                variable_components.push(VariableComponent {
279                    id: comp_id.clone(),
280                    name: name.clone(),
281                    ecosystem: None,
282                    version_spread: VersionSpread {
283                        baseline: baseline_version,
284                        min_version: all_versions_vec.iter().min().cloned(),
285                        max_version: all_versions_vec.iter().max().cloned(),
286                        unique_versions: all_versions_vec,
287                        is_consistent: false,
288                        major_version_spread: major_spread,
289                    },
290                    targets_with_component: versions.keys().cloned().collect(),
291                    security_impact: classify_security_impact(&name),
292                });
293            }
294        }
295
296        // Find inconsistent components (missing from some targets)
297        let mut inconsistent_components: Vec<InconsistentComponent> = vec![];
298        let all_component_ids: HashSet<_> = all_versions.keys().cloned().collect();
299
300        for comp_id in &all_component_ids {
301            if universal.contains(comp_id) {
302                continue; // Present everywhere, not inconsistent
303            }
304
305            let in_baseline = baseline_components.contains(comp_id);
306            let mut present_in: Vec<String> = vec![];
307            let mut missing_from: Vec<String> = vec![];
308
309            if in_baseline {
310                present_in.push(baseline_info.name.clone());
311            } else {
312                missing_from.push(baseline_info.name.clone());
313            }
314
315            for ((_, target_name, _), component_set) in targets.iter().zip(&target_component_sets) {
316                if component_set.contains(comp_id.as_str()) {
317                    present_in.push(target_name.to_string());
318                } else {
319                    missing_from.push(target_name.to_string());
320                }
321            }
322
323            if !missing_from.is_empty() {
324                let name = baseline_names
325                    .get(comp_id.as_str())
326                    .map_or_else(|| comp_id.clone(), |n| (*n).to_string());
327
328                inconsistent_components.push(InconsistentComponent {
329                    id: comp_id.clone(),
330                    name,
331                    in_baseline,
332                    present_in,
333                    missing_from,
334                });
335            }
336        }
337
338        // Compute deviation scores as 0-1 FRACTIONS (semantic_score is 0-100).
339        // Every consumer (TUI bands/gauges, CLI logging) multiplies by 100 for
340        // display; storing the 0-100 value here double-scaled every rendered
341        // percentage ("Max Deviation: 10000.0%") and saturated the deviation
342        // gauge/band thresholds, which are calibrated for 0-1 fractions.
343        let mut deviation_scores: HashMap<String, f64> = HashMap::new();
344        let mut max_deviation = 0.0f64;
345
346        for comp in comparisons {
347            let score = ((100.0 - comp.diff.semantic_score) / 100.0).clamp(0.0, 1.0);
348            deviation_scores.insert(comp.target.name.clone(), score);
349            max_deviation = max_deviation.max(score);
350        }
351
352        // Build vulnerability matrix with unique and common vulnerabilities
353        let vulnerability_matrix =
354            compute_vulnerability_matrix(baseline, &baseline_info.name, targets);
355
356        // Sorted/BTreeMap outputs: several of these collections come from
357        // HashSet/HashMap iteration, whose order differs run to run —
358        // serialized multi results were not byte-reproducible.
359        let mut universal_components: Vec<String> = universal.into_iter().collect();
360        universal_components.sort_unstable();
361        variable_components.sort_by(|a, b| a.id.cmp(&b.id));
362        inconsistent_components.sort_by(|a, b| a.id.cmp(&b.id));
363
364        MultiDiffSummary {
365            baseline_component_count: baseline_info.component_count,
366            universal_components,
367            variable_components,
368            inconsistent_components,
369            deviation_scores: deviation_scores.into_iter().collect(),
370            max_deviation,
371            vulnerability_matrix,
372        }
373    }
374
375    fn find_divergent_components(
376        &self,
377        baseline: &NormalizedSbom,
378        target: &NormalizedSbom,
379        _target_name: &str,
380        all_versions: &HashMap<String, HashMap<String, String>>,
381    ) -> Vec<DivergentComponent> {
382        let mut divergent = vec![];
383
384        // Hashed lookup tables keyed by LOGICAL (version-stripped) identity —
385        // matching a purl-versioned id verbatim classified every version bump
386        // as Added-in-target plus Removed-from-baseline instead of one
387        // VersionMismatch. The loops below previously also did a linear
388        // find/any per component, O(components²) per target.
389        let baseline_by_value: HashMap<&str, &crate::model::Component> = baseline
390            .components
391            .iter()
392            .map(|(id, c)| (strip_purl_version(id.value()), c))
393            .collect();
394        let target_ids: HashSet<&str> = target
395            .components
396            .keys()
397            .map(|k| strip_purl_version(k.value()))
398            .collect();
399
400        for (id, comp) in &target.components {
401            let comp_id = strip_purl_version(id.value()).to_string();
402            let target_version = comp.version.clone().unwrap_or_default();
403
404            // Presence and version availability are separate questions: a
405            // baseline component without a version (common for SPDX packages
406            // lacking versionInfo) is PRESENT, not Added.
407            let baseline_comp = baseline_by_value.get(comp_id.as_str()).copied();
408
409            let divergence_type = match baseline_comp {
410                None => DivergenceType::Added,
411                Some(bc) if bc.version != comp.version => DivergenceType::VersionMismatch,
412                Some(_) => continue, // Same version (or both versionless), not divergent
413            };
414            let baseline_version = baseline_comp.and_then(|bc| bc.version.clone());
415
416            divergent.push(DivergentComponent {
417                id: comp_id.clone(),
418                name: comp.name.clone(),
419                baseline_version,
420                target_version,
421                versions_across_targets: all_versions
422                    .get(&comp_id)
423                    .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
424                    .unwrap_or_default(),
425                divergence_type,
426            });
427        }
428
429        // Check for removed components (logically absent from the target, not
430        // merely present at another version)
431        for (id, comp) in &baseline.components {
432            let comp_id = strip_purl_version(id.value()).to_string();
433            if !target_ids.contains(comp_id.as_str()) {
434                divergent.push(DivergentComponent {
435                    id: comp_id.clone(),
436                    name: comp.name.clone(),
437                    baseline_version: comp.version.clone(),
438                    target_version: String::new(),
439                    versions_across_targets: all_versions
440                        .get(&comp_id)
441                        .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
442                        .unwrap_or_default(),
443                    divergence_type: DivergenceType::Removed,
444                });
445            }
446        }
447
448        divergent
449    }
450
451    /// Perform timeline analysis across ordered SBOM versions
452    ///
453    /// # Errors
454    ///
455    /// Returns an error if any pairwise diff computation fails.
456    pub fn timeline(
457        &mut self,
458        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
459    ) -> Result<TimelineResult, SbomDiffError> {
460        let sbom_infos: Vec<SbomInfo> = sboms
461            .iter()
462            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
463            .collect();
464
465        // Compute incremental diffs (adjacent pairs), labelling each with the
466        // pair it compares so consumers need not rely on array position.
467        let mut incremental_diffs: Vec<DiffResult> = vec![];
468        let mut incremental_pairs: Vec<crate::diff::TimelinePair> = vec![];
469        for i in 0..sboms.len().saturating_sub(1) {
470            let diff = self.cached_diff(sboms[i].0, sboms[i + 1].0)?;
471            incremental_diffs.push(diff);
472            incremental_pairs.push(crate::diff::TimelinePair {
473                from_index: i,
474                to_index: i + 1,
475                from_name: sbom_infos[i].name.clone(),
476                to_name: sbom_infos[i + 1].name.clone(),
477            });
478        }
479
480        // Compute cumulative diffs from first
481        let mut cumulative_from_first: Vec<DiffResult> = vec![];
482        let mut cumulative_pairs: Vec<crate::diff::TimelinePair> = vec![];
483        if !sboms.is_empty() {
484            for i in 1..sboms.len() {
485                let diff = self.cached_diff(sboms[0].0, sboms[i].0)?;
486                cumulative_from_first.push(diff);
487                cumulative_pairs.push(crate::diff::TimelinePair {
488                    from_index: 0,
489                    to_index: i,
490                    from_name: sbom_infos[0].name.clone(),
491                    to_name: sbom_infos[i].name.clone(),
492                });
493            }
494        }
495
496        // Build evolution summary
497        let evolution_summary =
498            self.build_evolution_summary(sboms, &sbom_infos, &incremental_diffs);
499
500        Ok(TimelineResult {
501            sboms: sbom_infos,
502            incremental_diffs,
503            incremental_pairs,
504            cumulative_from_first,
505            cumulative_pairs,
506            evolution_summary,
507        })
508    }
509
510    fn build_evolution_summary(
511        &self,
512        sboms: &[(&NormalizedSbom, &str, &str)],
513        sbom_infos: &[SbomInfo],
514        _incremental_diffs: &[DiffResult],
515    ) -> EvolutionSummary {
516        // Track component versions across timeline
517        let mut version_history: HashMap<String, Vec<VersionAtPoint>> = HashMap::new();
518        let mut components_added: Vec<ComponentEvolution> = vec![];
519        let mut components_removed: Vec<ComponentEvolution> = vec![];
520        let mut all_components: HashSet<String> = HashSet::new();
521
522        // Collect all component IDs, and per-SBOM lookup maps — the history
523        // loop below runs all_components × sboms and previously did a linear
524        // find per cell. Keyed by LOGICAL (version-stripped) identity: keying
525        // on raw purl-with-version ids made every upgrade TWO evolutions (the
526        // old version "removed", the new one "added"), double-listing it in
527        // the evolution lists and hiding the actual version change from the
528        // per-component history.
529        let mut sbom_maps: Vec<HashMap<&str, &crate::model::Component>> =
530            Vec::with_capacity(sboms.len());
531        for (sbom, _, _) in sboms {
532            for (id, _) in &sbom.components {
533                all_components.insert(strip_purl_version(id.value()).to_string());
534            }
535            sbom_maps.push(
536                sbom.components
537                    .iter()
538                    .map(|(id, c)| (strip_purl_version(id.value()), c))
539                    .collect(),
540            );
541        }
542
543        // Build version history for each component
544        for comp_id in &all_components {
545            let mut history: Vec<VersionAtPoint> = vec![];
546            let mut first_seen: Option<(usize, String)> = None;
547            let mut last_seen: Option<usize> = None;
548            let mut prev_version: Option<String> = None;
549            // Presence at the previous timeline point: Removed marks only the
550            // FIRST absent point after a presence (later gap points are
551            // Absent), and a component reappearing after a gap re-enters as
552            // Initial rather than being version-compared against the stale
553            // pre-gap version.
554            let mut was_present = false;
555            let mut version_change_count: usize = 0;
556
557            for (i, (_, name, _)) in sboms.iter().enumerate() {
558                let comp = sbom_maps[i].get(comp_id.as_str()).copied();
559
560                let (version, change_type) = if let Some(c) = comp {
561                    let ver = c.version.clone();
562                    let change = if first_seen.is_none() {
563                        first_seen = Some((i, ver.clone().unwrap_or_default()));
564                        VersionChangeType::Initial
565                    } else if !was_present {
566                        // Reappearance after a gap
567                        VersionChangeType::Initial
568                    } else {
569                        let ct = classify_version_change(prev_version.as_ref(), ver.as_ref());
570                        // Count actual version changes (not unchanged or absent)
571                        if !matches!(ct, VersionChangeType::Unchanged | VersionChangeType::Absent) {
572                            version_change_count += 1;
573                        }
574                        ct
575                    };
576                    last_seen = Some(i);
577                    prev_version.clone_from(&ver);
578                    was_present = true;
579                    (ver, change)
580                } else {
581                    let change = if was_present {
582                        VersionChangeType::Removed
583                    } else {
584                        VersionChangeType::Absent
585                    };
586                    was_present = false;
587                    prev_version = None;
588                    (None, change)
589                };
590
591                history.push(VersionAtPoint {
592                    sbom_index: i,
593                    sbom_name: name.to_string(),
594                    version,
595                    change_type,
596                });
597            }
598
599            version_history.insert(comp_id.clone(), history);
600
601            // Track added/removed
602            if let Some((first_idx, first_ver)) = first_seen {
603                let still_present = last_seen == Some(sboms.len() - 1);
604                let current_version = if still_present {
605                    sbom_maps
606                        .last()
607                        .and_then(|map| map.get(comp_id.as_str()))
608                        .and_then(|c| c.version.clone())
609                } else {
610                    None
611                };
612
613                let name = sbom_maps
614                    .iter()
615                    .find_map(|map| map.get(comp_id.as_str()).map(|c| c.name.clone()))
616                    .unwrap_or_else(|| comp_id.clone());
617
618                let evolution = ComponentEvolution {
619                    id: comp_id.clone(),
620                    name,
621                    first_seen_index: first_idx,
622                    first_seen_version: first_ver,
623                    last_seen_index: if still_present { None } else { last_seen },
624                    current_version,
625                    version_change_count,
626                };
627
628                if first_idx > 0 {
629                    components_added.push(evolution.clone());
630                }
631                if !still_present {
632                    components_removed.push(evolution);
633                }
634            }
635        }
636
637        // Build vulnerability trend
638        let vulnerability_trend: Vec<VulnerabilitySnapshot> = sbom_infos
639            .iter()
640            .enumerate()
641            .map(|(i, info)| VulnerabilitySnapshot {
642                sbom_index: i,
643                sbom_name: info.name.clone(),
644                counts: info.vulnerability_counts.clone(),
645                new_vulnerabilities: vec![],
646                resolved_vulnerabilities: vec![],
647            })
648            .collect();
649
650        // Build dependency trend, computing transitive deps from edge depth data
651        let dependency_trend: Vec<DependencySnapshot> = sboms
652            .iter()
653            .enumerate()
654            .map(|(i, (sbom, _, _))| {
655                let total_edges = sbom.edges.len();
656                // Count root nodes (no incoming edges) to determine direct vs transitive
657                let targets: HashSet<_> = sbom.edges.iter().map(|e| &e.to).collect();
658                let sources: HashSet<_> = sbom.edges.iter().map(|e| &e.from).collect();
659                let roots: HashSet<_> = sources.difference(&targets).collect();
660                let direct = sbom
661                    .edges
662                    .iter()
663                    .filter(|e| roots.contains(&&e.from))
664                    .count();
665                let transitive = total_edges.saturating_sub(direct);
666
667                DependencySnapshot {
668                    sbom_index: i,
669                    sbom_name: sbom_infos[i].name.clone(),
670                    direct_dependencies: direct,
671                    transitive_dependencies: transitive,
672                    total_edges,
673                }
674            })
675            .collect();
676
677        // Build compliance trend
678        let compliance_trend: Vec<ComplianceSnapshot> = sboms
679            .iter()
680            .enumerate()
681            .map(|(i, (sbom, name, _))| {
682                use crate::quality::{ComplianceChecker, ComplianceLevel};
683                let scores = ComplianceLevel::all()
684                    .iter()
685                    .map(|level| {
686                        let result = ComplianceChecker::new(*level).check(sbom);
687                        ComplianceScoreEntry {
688                            standard: level.name().to_string(),
689                            error_count: result.error_count,
690                            warning_count: result.warning_count,
691                            info_count: result.info_count,
692                            is_compliant: result.is_compliant,
693                        }
694                    })
695                    .collect();
696                ComplianceSnapshot {
697                    sbom_index: i,
698                    sbom_name: name.to_string(),
699                    scores,
700                }
701            })
702            .collect();
703
704        components_added.sort_by(|a, b| a.id.cmp(&b.id));
705        components_removed.sort_by(|a, b| a.id.cmp(&b.id));
706
707        EvolutionSummary {
708            components_added,
709            components_removed,
710            version_history: version_history.into_iter().collect(),
711            vulnerability_trend,
712            license_changes: vec![],
713            dependency_trend,
714            compliance_trend,
715        }
716    }
717
718    /// Perform N×N matrix comparison
719    ///
720    /// # Errors
721    ///
722    /// Returns an error if any pairwise diff computation fails.
723    pub fn matrix(
724        &mut self,
725        sboms: &[(&NormalizedSbom, &str, &str)], // (sbom, name, path)
726        similarity_threshold: Option<f64>,
727    ) -> Result<MatrixResult, SbomDiffError> {
728        let sbom_infos: Vec<SbomInfo> = sboms
729            .iter()
730            .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
731            .collect();
732
733        let n = sboms.len();
734        let num_pairs = n * (n - 1) / 2;
735
736        let mut diffs: Vec<Option<DiffResult>> = vec![None; num_pairs];
737        let mut similarity_scores: Vec<f64> = vec![0.0; num_pairs];
738
739        // Compute upper triangle
740        let mut idx = 0;
741        for i in 0..n {
742            for j in (i + 1)..n {
743                let diff = self.cached_diff(sboms[i].0, sboms[j].0)?;
744                let similarity = diff.semantic_score / 100.0;
745                similarity_scores[idx] = similarity;
746                diffs[idx] = Some(diff);
747                idx += 1;
748            }
749        }
750
751        // Optional clustering
752        let clustering = similarity_threshold
753            .map(|threshold| self.cluster_sboms(&sbom_infos, &similarity_scores, threshold));
754
755        Ok(MatrixResult {
756            sboms: sbom_infos,
757            diffs,
758            similarity_scores,
759            clustering,
760        })
761    }
762
763    fn cluster_sboms(
764        &self,
765        sboms: &[SbomInfo],
766        similarity_scores: &[f64],
767        threshold: f64,
768    ) -> SbomClustering {
769        let n = sboms.len();
770        let mut clusters: Vec<SbomCluster> = vec![];
771        let mut assigned: HashSet<usize> = HashSet::new();
772
773        // Simple greedy clustering. A seed is only marked assigned when it
774        // actually forms a cluster: unconditionally assigning every seed made
775        // singleton SBOMs vanish from the output entirely (in no cluster) and
776        // left the outliers list structurally empty.
777        for i in 0..n {
778            if assigned.contains(&i) {
779                continue;
780            }
781
782            let mut cluster_members = vec![i];
783
784            for j in (i + 1)..n {
785                if assigned.contains(&j) {
786                    continue;
787                }
788
789                // Get similarity between i and j
790                let idx = i * (2 * n - i - 1) / 2 + (j - i - 1);
791                let similarity = similarity_scores.get(idx).copied().unwrap_or(0.0);
792
793                if similarity >= threshold {
794                    cluster_members.push(j);
795                }
796            }
797
798            if cluster_members.len() > 1 {
799                for &member in &cluster_members {
800                    assigned.insert(member);
801                }
802                // Calculate average internal similarity
803                let mut total_sim = 0.0;
804                let mut count = 0;
805                for (mi, &a) in cluster_members.iter().enumerate() {
806                    for &b in cluster_members.iter().skip(mi + 1) {
807                        let (x, y) = if a < b { (a, b) } else { (b, a) };
808                        let idx = x * (2 * n - x - 1) / 2 + (y - x - 1);
809                        total_sim += similarity_scores.get(idx).copied().unwrap_or(0.0);
810                        count += 1;
811                    }
812                }
813
814                clusters.push(SbomCluster {
815                    members: cluster_members.clone(),
816                    centroid_index: cluster_members[0],
817                    internal_similarity: if count > 0 {
818                        total_sim / f64::from(count)
819                    } else {
820                        1.0
821                    },
822                    label: None,
823                });
824            }
825        }
826
827        // Find outliers
828        let outliers: Vec<usize> = (0..n).filter(|i| !assigned.contains(i)).collect();
829
830        SbomClustering {
831            clusters,
832            outliers,
833            algorithm: "greedy".to_string(),
834            threshold,
835        }
836    }
837}
838
839impl Default for MultiDiffEngine {
840    fn default() -> Self {
841        Self::new()
842    }
843}
844
845/// Version-independent component identity for cross-SBOM presence/version
846/// aggregation.
847///
848/// Purl-shaped canonical ids embed the version (`pkg:npm/lodash@4.17.20`), so
849/// keying presence sets on the raw id counted every version bump as TWO
850/// distinct components — one "missing" from the new SBOMs and one "missing"
851/// from the old ones — inflating the Inconsistent count past the number of
852/// packages in the fleet and hiding upgrades from the Variable list. Strips
853/// the `@version` suffix (and any qualifiers) from purl ids; non-purl ids are
854/// returned unchanged. Note: if one SBOM genuinely vendors two versions of the
855/// same purl, they intentionally aggregate as one logical component here.
856pub(crate) fn strip_purl_version(id: &str) -> &str {
857    if !id.starts_with("pkg:") {
858        return id;
859    }
860    // Version (if any) sits before qualifiers (`?`) / subpath (`#`).
861    let core_end = id.find(['?', '#']).unwrap_or(id.len());
862    let core = &id[..core_end];
863    match core.rfind('@') {
864        // An '@' directly after '/' is an npm scope ("pkg:npm/@scope/name"),
865        // not a version separator.
866        Some(pos) if pos > 0 && !core[..pos].ends_with('/') => &id[..pos],
867        _ => id,
868    }
869}
870
871/// Classify security impact based on component name
872fn classify_security_impact(name: &str) -> SecurityImpact {
873    let name_lower = name.to_lowercase();
874    let critical_components = [
875        "openssl",
876        "curl",
877        "libcurl",
878        "gnutls",
879        "mbedtls",
880        "wolfssl",
881        "boringssl",
882    ];
883    let high_components = [
884        "zlib", "libssh", "openssh", "gnupg", "gpg", "sqlite", "kernel", "glibc",
885    ];
886
887    if critical_components.iter().any(|c| name_lower.contains(c)) {
888        SecurityImpact::Critical
889    } else if high_components.iter().any(|c| name_lower.contains(c)) {
890        SecurityImpact::High
891    } else {
892        SecurityImpact::Low
893    }
894}
895
896/// Calculate major version spread from a list of version strings
897fn calculate_major_version_spread(versions: &[String]) -> u32 {
898    let mut major_versions: HashSet<u64> = HashSet::new();
899
900    for version in versions {
901        // Try to parse as semver first
902        if let Ok(v) = semver::Version::parse(version) {
903            major_versions.insert(v.major);
904        } else {
905            // Fallback: try to extract leading number
906            if let Some(major_str) = version.split(['.', '-', '_']).next()
907                && let Ok(major) = major_str.parse::<u64>()
908            {
909                major_versions.insert(major);
910            }
911        }
912    }
913
914    match (major_versions.iter().min(), major_versions.iter().max()) {
915        (Some(&min), Some(&max)) => (max - min) as u32,
916        _ => 0,
917    }
918}
919
920/// Compute vulnerability matrix with unique and common vulnerabilities
921fn compute_vulnerability_matrix(
922    baseline: &NormalizedSbom,
923    baseline_name: &str,
924    targets: &[(&NormalizedSbom, &str, &str)],
925) -> VulnerabilityMatrix {
926    // Collect all vulnerabilities per SBOM
927    let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
928    let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
929
930    // Baseline vulnerabilities
931    let baseline_vulns: HashSet<String> = baseline
932        .all_vulnerabilities()
933        .iter()
934        .map(|(_, v)| v.id.clone())
935        .collect();
936    vuln_sets.insert(baseline_name.to_string(), baseline_vulns);
937    per_sbom.insert(baseline_name.to_string(), baseline.vulnerability_counts());
938
939    // Target vulnerabilities
940    for (sbom, name, _) in targets {
941        let target_vulns: HashSet<String> = sbom
942            .all_vulnerabilities()
943            .iter()
944            .map(|(_, v)| v.id.clone())
945            .collect();
946        vuln_sets.insert(name.to_string(), target_vulns);
947        per_sbom.insert(name.to_string(), sbom.vulnerability_counts());
948    }
949
950    // Find common vulnerabilities (in ALL SBOMs)
951    let mut common_vulnerabilities: HashSet<String> =
952        vuln_sets.values().next().cloned().unwrap_or_default();
953
954    for vulns in vuln_sets.values() {
955        common_vulnerabilities = common_vulnerabilities
956            .intersection(vulns)
957            .cloned()
958            .collect();
959    }
960
961    // Find unique vulnerabilities per SBOM
962    let mut unique_vulnerabilities: HashMap<String, Vec<String>> = HashMap::new();
963
964    for (sbom_name, vulns) in &vuln_sets {
965        let mut unique: HashSet<String> = vulns.clone();
966
967        // Remove vulnerabilities that exist in any other SBOM
968        for (other_name, other_vulns) in &vuln_sets {
969            if other_name != sbom_name {
970                unique = unique.difference(other_vulns).cloned().collect();
971            }
972        }
973
974        if !unique.is_empty() {
975            unique_vulnerabilities.insert(sbom_name.clone(), unique.into_iter().collect());
976        }
977    }
978
979    let mut common: Vec<String> = common_vulnerabilities.into_iter().collect();
980    common.sort_unstable();
981    VulnerabilityMatrix {
982        per_sbom: per_sbom.into_iter().collect(),
983        unique_vulnerabilities: unique_vulnerabilities
984            .into_iter()
985            .map(|(k, mut v)| {
986                v.sort_unstable();
987                (k, v)
988            })
989            .collect(),
990        common_vulnerabilities: common,
991    }
992}
993
994/// Classify version change type.
995///
996/// Semver-aware, including pre-release ordering: `1.0.0-alpha -> 1.0.0` and
997/// `1.0.0-alpha -> 1.0.0-beta` are upgrades (the old comparison ignored the
998/// pre-release field and fell through to Downgrade), and a build-metadata-only
999/// change is Unchanged. Non-semver schemes are compared by numeric
1000/// dot-segments (`9.0 -> 10.0` is a major upgrade, not the lexicographic
1001/// Downgrade the old fallback produced); genuinely incomparable strings
1002/// report [`VersionChangeType::Changed`] rather than a fabricated direction.
1003fn classify_version_change(old: Option<&String>, new: Option<&String>) -> VersionChangeType {
1004    match (old, new) {
1005        (None, Some(_)) => VersionChangeType::Initial,
1006        (Some(_), None) => VersionChangeType::Removed,
1007        (Some(o), Some(n)) if o == n => VersionChangeType::Unchanged,
1008        (Some(o), Some(n)) => classify_version_strings(o, n),
1009        (None, None) => VersionChangeType::Absent,
1010    }
1011}
1012
1013pub(crate) fn classify_version_strings(old: &str, new: &str) -> VersionChangeType {
1014    use std::cmp::Ordering;
1015
1016    if let (Some(old_v), Some(new_v)) = (parse_semver_lenient(old), parse_semver_lenient(new)) {
1017        // cmp_precedence implements spec precedence, which ignores build
1018        // metadata — differing strings can still compare equal, and a
1019        // build-metadata-only change is not a version change. (Version::cmp
1020        // would tie-break on build metadata.)
1021        return match new_v.cmp_precedence(&old_v) {
1022            Ordering::Equal => VersionChangeType::Unchanged,
1023            Ordering::Less => VersionChangeType::Downgrade,
1024            Ordering::Greater => {
1025                if new_v.major > old_v.major {
1026                    VersionChangeType::MajorUpgrade
1027                } else if new_v.minor > old_v.minor {
1028                    VersionChangeType::MinorUpgrade
1029                } else {
1030                    // Patch bump, or a pre-release promotion within the same
1031                    // major.minor.patch triple
1032                    VersionChangeType::PatchUpgrade
1033                }
1034            }
1035        };
1036    }
1037
1038    // Non-semver schemes (e.g. "1.2.3.4", "20240101"): compare numeric
1039    // dot-segments positionally.
1040    if let Some(change) = classify_numeric_segments(old, new) {
1041        return change;
1042    }
1043
1044    VersionChangeType::Changed
1045}
1046
1047/// Lenient semver parse: trims whitespace and a leading `v`/`V`, and pads
1048/// missing minor/patch components (`9` -> `9.0.0`, `1.2-rc1` -> `1.2.0-rc1`).
1049fn parse_semver_lenient(version: &str) -> Option<semver::Version> {
1050    let version = version.trim();
1051    let version = version.strip_prefix(['v', 'V']).unwrap_or(version);
1052    if let Ok(v) = semver::Version::parse(version) {
1053        return Some(v);
1054    }
1055    // Pad a 1- or 2-segment numeric core, preserving pre-release/build parts.
1056    let split_at = version.find(['-', '+']).unwrap_or(version.len());
1057    let (core, rest) = version.split_at(split_at);
1058    let padded = match core.matches('.').count() {
1059        0 => format!("{core}.0.0{rest}"),
1060        1 => format!("{core}.0{rest}"),
1061        _ => return None,
1062    };
1063    semver::Version::parse(&padded).ok()
1064}
1065
1066/// Compare dot-separated numeric segments (shorter side zero-padded).
1067/// Returns `None` when any differing segment pair is non-numeric.
1068fn classify_numeric_segments(old: &str, new: &str) -> Option<VersionChangeType> {
1069    let old = old.trim();
1070    let old = old.strip_prefix(['v', 'V']).unwrap_or(old);
1071    let new = new.trim();
1072    let new = new.strip_prefix(['v', 'V']).unwrap_or(new);
1073    let old_segments: Vec<&str> = old.split('.').collect();
1074    let new_segments: Vec<&str> = new.split('.').collect();
1075    let len = old_segments.len().max(new_segments.len());
1076
1077    for position in 0..len {
1078        let old_seg = old_segments.get(position).copied().unwrap_or("0");
1079        let new_seg = new_segments.get(position).copied().unwrap_or("0");
1080        if old_seg == new_seg {
1081            continue;
1082        }
1083        let (old_num, new_num) = (old_seg.parse::<u64>().ok()?, new_seg.parse::<u64>().ok()?);
1084        if old_num == new_num {
1085            continue; // e.g. "02" vs "2"
1086        }
1087        let upgrade = new_num > old_num;
1088        return Some(match (upgrade, position) {
1089            (false, _) => VersionChangeType::Downgrade,
1090            (true, 0) => VersionChangeType::MajorUpgrade,
1091            (true, 1) => VersionChangeType::MinorUpgrade,
1092            (true, _) => VersionChangeType::PatchUpgrade,
1093        });
1094    }
1095
1096    // All segments numerically or textually equal (e.g. "1.02" vs "1.2")
1097    Some(VersionChangeType::Unchanged)
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103    use crate::model::{Component, DocumentMetadata};
1104
1105    fn classify(old: &str, new: &str) -> VersionChangeType {
1106        classify_version_change(Some(&old.to_string()), Some(&new.to_string()))
1107    }
1108
1109    /// The full classification matrix, including the cases the old
1110    /// implementation got wrong: pre-release transitions and non-semver
1111    /// numeric versions were all reported as Downgrade.
1112    #[test]
1113    fn classify_version_change_matrix() {
1114        use VersionChangeType::{
1115            Absent, Changed, Downgrade, Initial, MajorUpgrade, MinorUpgrade, PatchUpgrade, Removed,
1116            Unchanged,
1117        };
1118
1119        // Plain semver
1120        assert_eq!(classify("1.0.0", "2.0.0"), MajorUpgrade);
1121        assert_eq!(classify("1.2.0", "1.3.0"), MinorUpgrade);
1122        assert_eq!(classify("1.2.3", "1.2.4"), PatchUpgrade);
1123        assert_eq!(classify("2.0.0", "1.9.9"), Downgrade);
1124
1125        // Pre-release ordering (previously all Downgrade)
1126        assert_eq!(classify("1.0.0-alpha", "1.0.0"), PatchUpgrade);
1127        assert_eq!(classify("1.0.0-alpha", "1.0.0-beta"), PatchUpgrade);
1128        assert_eq!(classify("1.0.0", "1.0.0-alpha"), Downgrade);
1129
1130        // Build metadata is not a version change
1131        assert_eq!(classify("1.0.0", "1.0.0+build2"), Unchanged);
1132
1133        // Non-semver numeric (previously lexicographic: 9.0 -> 10.0 was a
1134        // Downgrade and 10.0 -> 9.0 a PatchUpgrade)
1135        assert_eq!(classify("9.0", "10.0"), MajorUpgrade);
1136        assert_eq!(classify("10.0", "9.0"), Downgrade);
1137        assert_eq!(classify("1.2.3.4", "1.2.3.5"), PatchUpgrade);
1138        assert_eq!(classify("1.02", "1.2"), Unchanged);
1139
1140        // Lenient parsing
1141        assert_eq!(classify("v1.2.3", "v2.0.0"), MajorUpgrade);
1142        assert_eq!(classify("1.2", "1.3"), MinorUpgrade);
1143        assert_eq!(classify("2", "3"), MajorUpgrade);
1144
1145        // Incomparable schemes report Changed, never a fabricated direction
1146        assert_eq!(classify("abc", "def"), Changed);
1147        assert_eq!(classify("release-A", "release-B"), Changed);
1148
1149        // Presence transitions
1150        assert_eq!(
1151            classify_version_change(None, Some(&"1.0.0".to_string())),
1152            Initial
1153        );
1154        assert_eq!(
1155            classify_version_change(Some(&"1.0.0".to_string()), None),
1156            Removed
1157        );
1158        assert_eq!(classify_version_change(None, None), Absent);
1159        assert_eq!(classify("1.0.0", "1.0.0"), Unchanged);
1160    }
1161
1162    fn info(name: &str) -> SbomInfo {
1163        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1164        SbomInfo::from_sbom(&sbom, name.to_string(), format!("{name}.json"))
1165    }
1166
1167    /// Singleton SBOMs must surface as outliers: previously every seed was
1168    /// marked assigned unconditionally, so singletons appeared in neither
1169    /// clusters nor outliers and the outliers list was structurally empty.
1170    #[test]
1171    fn cluster_sboms_reports_singletons_as_outliers() {
1172        let engine = MultiDiffEngine::new();
1173        let sboms = vec![info("a"), info("b"), info("c")];
1174
1175        // Upper triangle for n=3: [s(0,1), s(0,2), s(1,2)]
1176        let scores = vec![0.95, 0.10, 0.10];
1177        let clustering = engine.cluster_sboms(&sboms, &scores, 0.9);
1178        assert_eq!(clustering.clusters.len(), 1);
1179        assert_eq!(clustering.clusters[0].members, vec![0, 1]);
1180        assert_eq!(
1181            clustering.outliers,
1182            vec![2],
1183            "the dissimilar SBOM must be an outlier"
1184        );
1185
1186        // All dissimilar: no clusters, everything an outlier
1187        let scores = vec![0.1, 0.1, 0.1];
1188        let clustering = engine.cluster_sboms(&sboms, &scores, 0.9);
1189        assert!(clustering.clusters.is_empty());
1190        assert_eq!(clustering.outliers, vec![0, 1, 2]);
1191    }
1192
1193    fn timeline_sbom(component_version: Option<&str>) -> NormalizedSbom {
1194        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1195        // A stable second component so the SBOM is never empty
1196        let mut anchor = Component::new("anchor".to_string(), "pkg:npm/anchor@1.0.0".to_string());
1197        anchor.version = Some("1.0.0".to_string());
1198        anchor.calculate_content_hash();
1199        sbom.add_component(anchor);
1200        if let Some(version) = component_version {
1201            let mut c = Component::new("libgap".to_string(), "pkg:npm/libgap".to_string());
1202            c.version = Some(version.to_string());
1203            c.calculate_content_hash();
1204            sbom.add_component(c);
1205        }
1206        sbom.calculate_content_hash();
1207        sbom
1208    }
1209
1210    /// A purl-versioned upgrade is ONE evolution with a version change, not a
1211    /// Removed(old version) + Added(new version) pair.
1212    #[test]
1213    fn timeline_upgrade_is_one_evolution_not_added_plus_removed() {
1214        let v1 = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1215        let v2 = purl_sbom(&[("lodash", "4.17.21"), ("react", "18.0.0")]);
1216
1217        let mut engine = MultiDiffEngine::new();
1218        let sboms: Vec<(&NormalizedSbom, &str, &str)> =
1219            vec![(&v1, "v1", "v1.json"), (&v2, "v2", "v2.json")];
1220        let result = engine.timeline(&sboms).expect("timeline");
1221        let summary = &result.evolution_summary;
1222
1223        assert!(
1224            summary.components_added.is_empty(),
1225            "nothing appeared after v1: {:?}",
1226            summary.components_added
1227        );
1228        assert!(
1229            summary.components_removed.is_empty(),
1230            "nothing is absent from the latest version: {:?}",
1231            summary.components_removed
1232        );
1233
1234        let history = summary
1235            .version_history
1236            .get("pkg:npm/lodash")
1237            .expect("logical lodash history");
1238        let changes: Vec<_> = history.iter().map(|p| p.change_type.clone()).collect();
1239        assert_eq!(
1240            changes,
1241            vec![VersionChangeType::Initial, VersionChangeType::PatchUpgrade],
1242            "the upgrade must be visible as a version change in ONE history"
1243        );
1244    }
1245
1246    /// Gap handling: Removed marks only the FIRST absent point; later gap
1247    /// points are Absent; a reappearing component re-enters as Initial
1248    /// rather than being version-compared against the stale pre-gap version
1249    /// (previously: Removed, Removed, then MajorUpgrade against a version
1250    /// from two revisions ago).
1251    #[test]
1252    fn timeline_gap_and_reappearance_handling() {
1253        let r0 = timeline_sbom(Some("1.0.0"));
1254        let r1 = timeline_sbom(None);
1255        let r2 = timeline_sbom(None);
1256        let r3 = timeline_sbom(Some("2.0.0"));
1257
1258        let mut engine = MultiDiffEngine::new();
1259        let sboms: Vec<(&NormalizedSbom, &str, &str)> = vec![
1260            (&r0, "r0", "r0.json"),
1261            (&r1, "r1", "r1.json"),
1262            (&r2, "r2", "r2.json"),
1263            (&r3, "r3", "r3.json"),
1264        ];
1265        let result = engine.timeline(&sboms).expect("timeline");
1266
1267        let history = result
1268            .evolution_summary
1269            .version_history
1270            .iter()
1271            .find(|(id, _)| id.contains("libgap"))
1272            .map(|(_, h)| h)
1273            .expect("libgap history");
1274
1275        let changes: Vec<_> = history.iter().map(|p| p.change_type.clone()).collect();
1276        assert_eq!(
1277            changes,
1278            vec![
1279                VersionChangeType::Initial,
1280                VersionChangeType::Removed,
1281                VersionChangeType::Absent,
1282                VersionChangeType::Initial,
1283            ],
1284            "gap must be Removed-then-Absent and reappearance must be Initial"
1285        );
1286    }
1287
1288    /// `strip_purl_version` removes only the version segment of purl-shaped
1289    /// ids and leaves everything else alone.
1290    #[test]
1291    fn strip_purl_version_matrix() {
1292        assert_eq!(
1293            strip_purl_version("pkg:npm/lodash@4.17.20"),
1294            "pkg:npm/lodash"
1295        );
1296        assert_eq!(
1297            strip_purl_version("pkg:npm/@scope/name@1.2.3"),
1298            "pkg:npm/@scope/name"
1299        );
1300        // Scoped purl WITHOUT a version: the scope '@' must survive.
1301        assert_eq!(
1302            strip_purl_version("pkg:npm/@scope/name"),
1303            "pkg:npm/@scope/name"
1304        );
1305        assert_eq!(
1306            strip_purl_version("pkg:maven/org.apache/log4j@2.17.0?type=jar"),
1307            "pkg:maven/org.apache/log4j"
1308        );
1309        assert_eq!(strip_purl_version("pkg:npm/lodash"), "pkg:npm/lodash");
1310        // Non-purl ids pass through untouched (even with embedded '@').
1311        assert_eq!(strip_purl_version("acme-webapp"), "acme-webapp");
1312        assert_eq!(strip_purl_version("SPDXRef-Package-a"), "SPDXRef-Package-a");
1313        assert_eq!(strip_purl_version("name@1.0"), "name@1.0");
1314    }
1315
1316    fn purl_sbom(entries: &[(&str, &str)]) -> NormalizedSbom {
1317        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1318        for (name, version) in entries {
1319            let mut c = Component::new((*name).to_string(), format!("pkg:npm/{name}@{version}"));
1320            c.version = Some((*version).to_string());
1321            c.calculate_content_hash();
1322            sbom.add_component(c);
1323        }
1324        sbom.calculate_content_hash();
1325        sbom
1326    }
1327
1328    /// The three similarity/deviation scales must stay in their documented
1329    /// relationship: the embedded per-pair `DiffResult` keeps the 0-100
1330    /// single-diff `semantic_score`, while the multi-SBOM layer exposes
1331    /// 0-1 fractions (`similarity = semantic_score / 100`, `deviation =
1332    /// 1 - similarity`). They drifted apart silently once already.
1333    #[test]
1334    fn similarity_and_deviation_scales_match_their_documented_contract() {
1335        let a = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1336        let b = purl_sbom(&[("lodash", "4.17.21"), ("zod", "3.0.0")]);
1337
1338        let mut engine = MultiDiffEngine::new();
1339        let matrix = engine
1340            .matrix(&[(&a, "a", "a.json"), (&b, "b", "b.json")], None)
1341            .expect("matrix must succeed");
1342
1343        let similarity = matrix.similarity_scores[0];
1344        assert!(
1345            (0.0..=1.0).contains(&similarity),
1346            "matrix similarity must be a 0-1 fraction, got {similarity}"
1347        );
1348        let embedded = matrix.diffs[0]
1349            .as_ref()
1350            .expect("pair diff present")
1351            .semantic_score;
1352        assert!(
1353            (0.0..=100.0).contains(&embedded),
1354            "embedded semantic_score must stay on the 0-100 scale, got {embedded}"
1355        );
1356        assert!(
1357            (similarity - embedded / 100.0).abs() < 1e-9,
1358            "similarity ({similarity}) must equal semantic_score/100 ({})",
1359            embedded / 100.0
1360        );
1361
1362        let multi = engine
1363            .diff_multi(&a, "a", "a.json", &[(&b, "b", "b.json")])
1364            .expect("diff_multi must succeed");
1365        let deviation = multi.summary.deviation_scores["b"];
1366        assert!(
1367            (0.0..=1.0).contains(&deviation),
1368            "deviation must be a 0-1 fraction, got {deviation}"
1369        );
1370        let pair_score = multi.comparisons[0].diff.semantic_score;
1371        assert!(
1372            (deviation - (1.0 - pair_score / 100.0)).abs() < 1e-9,
1373            "deviation ({deviation}) must equal 1 - semantic_score/100"
1374        );
1375    }
1376
1377    /// A version bump must be ONE variable component, never TWO inconsistent
1378    /// ones (raw purl-with-version keying counted `lodash@1` missing from the
1379    /// target and `lodash@2` missing from the baseline). Deviation scores must
1380    /// be 0-1 fractions — consumers multiply by 100 for display.
1381    #[test]
1382    fn version_bump_is_variable_not_double_inconsistent_and_deviation_is_fraction() {
1383        let baseline = purl_sbom(&[("lodash", "4.17.20"), ("react", "18.0.0")]);
1384        let target = purl_sbom(&[("lodash", "4.17.21"), ("react", "18.0.0")]);
1385
1386        let mut engine = MultiDiffEngine::new();
1387        let result = engine
1388            .diff_multi(
1389                &baseline,
1390                "baseline",
1391                "baseline.json",
1392                &[(&target, "target", "target.json")],
1393            )
1394            .expect("diff_multi");
1395
1396        let summary = &result.summary;
1397        assert!(
1398            summary.inconsistent_components.is_empty(),
1399            "a version bump is not a presence inconsistency: {:?}",
1400            summary.inconsistent_components
1401        );
1402        assert_eq!(
1403            summary
1404                .variable_components
1405                .iter()
1406                .map(|vc| vc.name.as_str())
1407                .collect::<Vec<_>>(),
1408            vec!["lodash"],
1409            "the bumped package must surface exactly once as variable"
1410        );
1411        let lodash = &summary.variable_components[0];
1412        assert_eq!(lodash.id, "pkg:npm/lodash", "id must be version-stripped");
1413        assert_eq!(
1414            lodash.targets_with_component.len(),
1415            2,
1416            "present (at some version) in baseline and target"
1417        );
1418        // react is untouched and present everywhere -> universal, counted once.
1419        assert_eq!(
1420            summary.universal_components,
1421            vec!["pkg:npm/lodash", "pkg:npm/react"]
1422        );
1423
1424        // Deviation contract: 0-1 fraction, never the 0-100 semantic scale.
1425        assert!(
1426            summary.max_deviation >= 0.0 && summary.max_deviation <= 1.0,
1427            "deviation must be a 0-1 fraction, got {}",
1428            summary.max_deviation
1429        );
1430        for (name, dev) in &summary.deviation_scores {
1431            assert!(
1432                (0.0..=1.0).contains(dev),
1433                "deviation for {name} must be a 0-1 fraction, got {dev}"
1434            );
1435        }
1436
1437        // Divergence: the bump is one VersionMismatch, not Added+Removed.
1438        let divergent = &result.comparisons[0].divergent_components;
1439        assert_eq!(divergent.len(), 1, "only lodash diverges: {divergent:?}");
1440        assert_eq!(
1441            divergent[0].divergence_type,
1442            DivergenceType::VersionMismatch
1443        );
1444        assert_eq!(divergent[0].baseline_version.as_deref(), Some("4.17.20"));
1445        assert_eq!(divergent[0].target_version, "4.17.21");
1446    }
1447
1448    /// An identical target deviates 0.0; a fully disjoint target still stays
1449    /// within the 0-1 fraction contract (the old code stored 100.0).
1450    #[test]
1451    fn deviation_bounds_identical_and_disjoint() {
1452        let baseline = purl_sbom(&[("a", "1.0.0"), ("b", "1.0.0")]);
1453        let same = purl_sbom(&[("a", "1.0.0"), ("b", "1.0.0")]);
1454        let disjoint = purl_sbom(&[("x", "1.0.0"), ("y", "1.0.0")]);
1455
1456        let mut engine = MultiDiffEngine::new();
1457        let result = engine
1458            .diff_multi(
1459                &baseline,
1460                "baseline",
1461                "baseline.json",
1462                &[
1463                    (&same, "same", "same.json"),
1464                    (&disjoint, "disjoint", "disjoint.json"),
1465                ],
1466            )
1467            .expect("diff_multi");
1468
1469        let same_dev = result.summary.deviation_scores["same"];
1470        let disjoint_dev = result.summary.deviation_scores["disjoint"];
1471        assert!(
1472            same_dev.abs() < f64::EPSILON,
1473            "identical target must deviate 0.0, got {same_dev}"
1474        );
1475        assert!(
1476            disjoint_dev > same_dev && disjoint_dev <= 1.0,
1477            "disjoint target deviation must be in (0, 1], got {disjoint_dev}"
1478        );
1479        assert!(result.summary.max_deviation <= 1.0);
1480    }
1481
1482    /// A baseline component that is present but versionless (SPDX without
1483    /// versionInfo) must not be reported as Added in every target.
1484    #[test]
1485    fn versionless_baseline_component_is_not_added() {
1486        let make = |version: Option<&str>| {
1487            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1488            let mut c = Component::new("libfoo".to_string(), "SPDXRef-Package-libfoo".to_string());
1489            c.version = version.map(str::to_string);
1490            c.calculate_content_hash();
1491            sbom.add_component(c);
1492            sbom.calculate_content_hash();
1493            sbom
1494        };
1495
1496        let baseline = make(None);
1497        let same = make(None);
1498        let versioned = make(Some("2.0.0"));
1499
1500        let engine = MultiDiffEngine::new();
1501        let all_versions = HashMap::new();
1502
1503        let divergent = engine.find_divergent_components(&baseline, &same, "same", &all_versions);
1504        assert!(
1505            divergent.is_empty(),
1506            "identical versionless components must not diverge: {divergent:?}"
1507        );
1508
1509        let divergent =
1510            engine.find_divergent_components(&baseline, &versioned, "versioned", &all_versions);
1511        assert_eq!(divergent.len(), 1);
1512        assert_eq!(
1513            divergent[0].divergence_type,
1514            DivergenceType::VersionMismatch,
1515            "present-but-versionless baseline is a version mismatch, not Added"
1516        );
1517    }
1518}