Skip to main content

sbom_diff/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use sbom_model::versions::{is_version_downgrade_for_ecosystem, Version};
4use sbom_model::{licensings_equivalent, Component, ComponentId, DependencyKind, Sbom};
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::collections::{BTreeMap, BTreeSet, HashSet};
8
9pub mod renderer;
10
11/// structured tracking of document metadata changes between two SBOMs.
12///
13/// instead of a simple boolean, this captures exactly which metadata fields
14/// differ, making it possible to render meaningful output and gate CI on
15/// specific metadata changes.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct MetadataChange {
18    /// timestamp changed: (old, new).
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub timestamp: Option<(Option<String>, Option<String>)>,
21    /// tools changed: (old, new).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub tools: Option<(Vec<String>, Vec<String>)>,
24    /// authors changed: (old, new).
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub authors: Option<(Vec<String>, Vec<String>)>,
27}
28
29impl MetadataChange {
30    /// returns true if no metadata fields actually differ.
31    pub fn is_empty(&self) -> bool {
32        self.timestamp.is_none() && self.tools.is_none() && self.authors.is_none()
33    }
34}
35
36/// per-ecosystem counts of added, removed, and changed components.
37#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38pub struct EcosystemCounts {
39    pub added: usize,
40    pub removed: usize,
41    pub changed: usize,
42}
43
44/// the result of comparing two SBOMs.
45///
46/// contains lists of added, removed, and changed components,
47/// as well as dependency edge changes.
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct Diff {
50    /// components present in the new SBOM but not the old.
51    pub added: Vec<Component>,
52    /// components present in the old SBOM but not the new.
53    pub removed: Vec<Component>,
54    /// components present in both with field-level changes.
55    pub changed: Vec<ComponentChange>,
56    /// dependency edge changes between components.
57    pub edge_diffs: Vec<EdgeDiff>,
58    /// structured metadata change details, or `None` if metadata is unchanged.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub metadata_changed: Option<MetadataChange>,
61    /// total number of components in the old SBOM.
62    pub old_total: usize,
63    /// total number of components in the new SBOM.
64    pub new_total: usize,
65    /// number of components present in both SBOMs with no changes.
66    pub unchanged: usize,
67    /// human-readable display names for component IDs that appear in edge diffs.
68    ///
69    /// maps hash-based IDs (`h:...`) to `name@version` or `name` so that edge
70    /// diff output is readable without cross-referencing the full component list.
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub component_names: BTreeMap<ComponentId, String>,
73}
74
75impl Diff {
76    /// returns `true` if the diff contains no changes of any kind.
77    pub fn is_empty(&self) -> bool {
78        self.added.is_empty()
79            && self.removed.is_empty()
80            && self.changed.is_empty()
81            && self.edge_diffs.is_empty()
82            && self.metadata_changed.is_none()
83    }
84
85    /// returns a human-readable display name for a component ID.
86    ///
87    /// looks up the ID in `component_names`; falls back to the raw ID string.
88    pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
89        self.component_names
90            .get(id)
91            .map(String::as_str)
92            .unwrap_or_else(|| id.as_str())
93    }
94
95    /// groups added/removed/changed counts by package ecosystem.
96    ///
97    /// components without an ecosystem are grouped under `"unknown"`.
98    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
99        let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
100
101        for comp in &self.added {
102            let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
103            breakdown.entry(eco).or_default().added += 1;
104        }
105
106        for comp in &self.removed {
107            let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
108            breakdown.entry(eco).or_default().removed += 1;
109        }
110
111        for change in &self.changed {
112            let eco = change
113                .new
114                .ecosystem
115                .as_deref()
116                .unwrap_or("unknown")
117                .to_string();
118            breakdown.entry(eco).or_default().changed += 1;
119        }
120
121        breakdown
122    }
123
124    /// groups the full diff by ecosystem, returning per-ecosystem slices.
125    ///
126    /// components without an ecosystem are grouped under `"unknown"`.
127    /// this clones components out of the diff; use
128    /// [`into_group_by_ecosystem`](Self::into_group_by_ecosystem) to move
129    /// them instead when you own the diff.
130    pub fn group_by_ecosystem(&self) -> GroupedDiff {
131        group_components_by_ecosystem(
132            self.added.iter().cloned(),
133            self.removed.iter().cloned(),
134            self.changed.iter().cloned(),
135            self.edge_diffs.clone(),
136            self.metadata_changed.clone(),
137        )
138    }
139
140    /// consuming variant of [`group_by_ecosystem`](Self::group_by_ecosystem)
141    /// that moves components instead of cloning them.
142    pub fn into_group_by_ecosystem(self) -> GroupedDiff {
143        group_components_by_ecosystem(
144            self.added,
145            self.removed,
146            self.changed,
147            self.edge_diffs,
148            self.metadata_changed,
149        )
150    }
151
152    /// filters the diff to only include components whose ecosystem matches
153    /// the given predicate. adjusts `old_total`, `new_total`, and `unchanged`
154    /// to reflect the filtered view.
155    ///
156    /// `filtered_old_total` and `filtered_new_total` are the pre-counted
157    /// number of components in each SBOM that pass the predicate. these must
158    /// be computed before [`Differ::diff_owned`] consumes the SBOMs.
159    ///
160    /// `component_ecosystems` maps component IDs to their ecosystem, built
161    /// from both SBOMs before they are consumed. this is used to filter
162    /// edge diffs by the parent component's ecosystem.
163    pub fn filter_by_ecosystem<F: Fn(Option<&str>) -> bool>(
164        &mut self,
165        matches: &F,
166        filtered_old_total: usize,
167        filtered_new_total: usize,
168        component_ecosystems: &BTreeMap<ComponentId, Option<String>>,
169    ) {
170        self.added.retain(|c| matches(c.ecosystem.as_deref()));
171        self.removed.retain(|c| matches(c.ecosystem.as_deref()));
172        self.changed.retain(|c| matches(c.new.ecosystem.as_deref()));
173
174        // filter edge diffs by parent ecosystem; keep edges whose parent is
175        // unknown (not in the map) as a conservative default.
176        self.edge_diffs.retain(|edge| {
177            component_ecosystems
178                .get(&edge.parent)
179                .map(|eco| matches(eco.as_deref()))
180                .unwrap_or(true)
181        });
182
183        // prune component_names to only IDs still referenced in edge diffs
184        let mut referenced_ids = BTreeSet::new();
185        for edge in &self.edge_diffs {
186            referenced_ids.insert(&edge.parent);
187            referenced_ids.extend(edge.added.keys());
188            referenced_ids.extend(edge.removed.keys());
189            referenced_ids.extend(edge.kind_changed.keys());
190        }
191        self.component_names
192            .retain(|id, _| referenced_ids.contains(id));
193
194        self.old_total = filtered_old_total;
195        self.new_total = filtered_new_total;
196        // derive unchanged from the NEW side, consistent with added/changed
197        // (both retained by new-side ecosystem). deriving from the old side
198        // over-counts a matched pair whose ecosystem changes across the filter
199        // boundary, which can push unchanged above new_total.
200        self.unchanged = filtered_new_total
201            .saturating_sub(self.added.len())
202            .saturating_sub(self.changed.len());
203    }
204}
205
206/// shared implementation for [`Diff::group_by_ecosystem`] and
207/// [`Diff::into_group_by_ecosystem`]. accepts owned iterators so both the
208/// cloning and consuming callers can share the same loop logic.
209fn group_components_by_ecosystem(
210    added: impl IntoIterator<Item = Component>,
211    removed: impl IntoIterator<Item = Component>,
212    changed: impl IntoIterator<Item = ComponentChange>,
213    edge_diffs: Vec<EdgeDiff>,
214    metadata_changed: Option<MetadataChange>,
215) -> GroupedDiff {
216    let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
217
218    for c in added {
219        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
220        ecosystems.entry(eco).or_default().added.push(c);
221    }
222    for c in removed {
223        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
224        ecosystems.entry(eco).or_default().removed.push(c);
225    }
226    for c in changed {
227        let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
228        ecosystems.entry(eco).or_default().changed.push(c);
229    }
230
231    GroupedDiff {
232        by_ecosystem: ecosystems,
233        edge_diffs,
234        metadata_changed,
235    }
236}
237
238/// diff grouped by package ecosystem.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct GroupedDiff {
241    pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
242    pub edge_diffs: Vec<EdgeDiff>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub metadata_changed: Option<MetadataChange>,
245}
246
247impl GroupedDiff {
248    /// derives per-ecosystem counts from the already-grouped data.
249    ///
250    /// this avoids a redundant traversal when both grouped components and
251    /// counts are needed — call [`Diff::group_by_ecosystem`] once, then
252    /// derive counts from the result.
253    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
254        self.by_ecosystem
255            .iter()
256            .map(|(eco, eco_diff)| {
257                (
258                    eco.clone(),
259                    EcosystemCounts {
260                        added: eco_diff.added.len(),
261                        removed: eco_diff.removed.len(),
262                        changed: eco_diff.changed.len(),
263                    },
264                )
265            })
266            .collect()
267    }
268}
269
270/// per-ecosystem slice of added, removed, and changed components.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct EcosystemDiff {
273    pub added: Vec<Component>,
274    pub removed: Vec<Component>,
275    pub changed: Vec<ComponentChange>,
276}
277
278/// a component that exists in both SBOMs with detected changes.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct ComponentChange {
281    /// the component identifier (from the new SBOM).
282    pub id: ComponentId,
283    /// the component as it appeared in the old SBOM.
284    pub old: Component,
285    /// the component as it appears in the new SBOM.
286    pub new: Component,
287    /// list of specific field changes detected.
288    pub changes: Vec<FieldChange>,
289    /// true when the version change is a downgrade (higher to lower).
290    #[serde(default, skip_serializing_if = "is_false")]
291    pub is_downgrade: bool,
292}
293
294fn is_false(b: &bool) -> bool {
295    !b
296}
297
298/// the ecosystem a matched pair's versions should be ordered in: the side that
299/// declares one, or `None` when the two sides declare different ecosystems and
300/// neither ruleset applies. feeds
301/// [`sbom_model::versions::is_version_downgrade_for_ecosystem`].
302pub fn pair_ecosystem<'a>(old: &'a Component, new: &'a Component) -> Option<&'a str> {
303    match (old.ecosystem.as_deref(), new.ecosystem.as_deref()) {
304        (Some(a), Some(b)) if a != b => None,
305        (a, b) => a.or(b),
306    }
307}
308
309/// a dependency edge change for a single parent component.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct EdgeDiff {
312    /// the parent component whose dependencies changed.
313    pub parent: ComponentId,
314    /// dependencies added in the new SBOM, with their dependency kind.
315    pub added: BTreeMap<ComponentId, DependencyKind>,
316    /// dependencies removed from the old SBOM, with their dependency kind.
317    pub removed: BTreeMap<ComponentId, DependencyKind>,
318    /// dependencies whose kind changed between old and new (old_kind, new_kind).
319    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
320    pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
321}
322
323/// a specific field that changed between two versions of a component.
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325pub enum FieldChange {
326    /// version changed: (old, new).
327    Version(Option<String>, Option<String>),
328    /// licenses changed: (old, new).
329    License(BTreeSet<String>, BTreeSet<String>),
330    /// the declared SPDX license expression changed while the flattened
331    /// identifier set stayed the same: (old, new). a `WITH` exception being
332    /// added or dropped, or `OR` becoming `AND`, shows up here.
333    LicenseExpression(Option<String>, Option<String>),
334    /// supplier changed: (old, new).
335    Supplier(Option<String>, Option<String>),
336    /// package URL changed: (old, new).
337    Purl(Option<String>, Option<String>),
338    /// description changed: (old, new).
339    Description(Option<String>, Option<String>),
340    /// hashes changed: (old, new).
341    Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
342    /// ecosystem changed: (old, new).
343    Ecosystem(Option<String>, Option<String>),
344}
345
346/// fields that can be compared and filtered.
347///
348/// use with [`Differ::diff`] to limit comparison to specific fields.
349#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
350pub enum Field {
351    /// package version.
352    Version,
353    /// license identifiers.
354    License,
355    /// supplier/publisher.
356    Supplier,
357    /// package URL.
358    Purl,
359    /// human-readable description.
360    Description,
361    /// checksums.
362    Hashes,
363    /// package ecosystem.
364    Ecosystem,
365    /// dependency edges.
366    Deps,
367}
368
369/// how many same-identity candidates a reconciliation bucket may hold before
370/// the version alignment gives up and pairs them by id.
371const MAX_ALIGNED_CANDIDATES: usize = 256;
372
373/// SBOM comparison engine.
374///
375/// compares two SBOMs and produces a [`Diff`] describing the changes.
376/// components are matched first by ID (purl), then by identity (name + ecosystem).
377pub struct Differ;
378
379impl Differ {
380    /// compares two SBOMs and returns the differences.
381    ///
382    /// both SBOMs are normalized before comparison to ignore irrelevant differences
383    /// like ordering or metadata timestamps. this method clones both SBOMs
384    /// internally; use [`diff_owned`](Self::diff_owned) to avoid cloning when
385    /// you already own the SBOMs.
386    ///
387    /// # Arguments
388    ///
389    /// * `old` - The baseline SBOM
390    /// * `new` - The SBOM to compare against the baseline
391    /// * `only` - Optional filter to limit comparison to specific fields
392    ///
393    /// # Example
394    ///
395    /// ```
396    /// use sbom_diff::{Differ, Field};
397    /// use sbom_model::Sbom;
398    ///
399    /// let old = Sbom::default();
400    /// let new = Sbom::default();
401    ///
402    /// // Compare all fields
403    /// let diff = Differ::diff(&old, &new, None);
404    ///
405    /// // compare only version and license changes
406    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
407    /// ```
408    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
409        Self::diff_owned(old.clone(), new.clone(), only)
410    }
411
412    /// consuming variant of [`diff`](Self::diff) that normalizes in place,
413    /// avoiding two full SBOM clones.
414    pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
415        // compare metadata before normalize() strips volatile fields
416        let metadata_changed = {
417            let mut mc = MetadataChange {
418                timestamp: None,
419                tools: None,
420                authors: None,
421            };
422            if old.metadata.timestamp != new.metadata.timestamp {
423                mc.timestamp = Some((
424                    old.metadata.timestamp.clone(),
425                    new.metadata.timestamp.clone(),
426                ));
427            }
428            if old.metadata.tools != new.metadata.tools {
429                mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
430            }
431            if old.metadata.authors != new.metadata.authors {
432                mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
433            }
434            if mc.is_empty() {
435                None
436            } else {
437                Some(mc)
438            }
439        };
440
441        old.normalize();
442        new.normalize();
443
444        // phase 1: collect match decisions using only borrows — no component
445        // clones. we record (old_id, new_id, field_changes) triples for pairs
446        // that actually differ and track all matched IDs for later draining.
447        let mut changed_pairs: Vec<(ComponentId, ComponentId, Vec<FieldChange>)> = Vec::new();
448        let mut matched_old: HashSet<ComponentId> = HashSet::new();
449        let mut matched_new: HashSet<ComponentId> = HashSet::new();
450
451        // track old_id -> new_id mappings for edge reconciliation
452        let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
453
454        // 1. match by ID
455        for (id, new_comp) in &new.components {
456            if let Some(old_comp) = old.components.get(id) {
457                matched_old.insert(id.clone());
458                matched_new.insert(id.clone());
459                id_mapping.insert(id.clone(), id.clone());
460
461                let fields = Self::compute_fields(old_comp, new_comp, only);
462                if !fields.is_empty() {
463                    changed_pairs.push((id.clone(), id.clone(), fields));
464                }
465            }
466        }
467
468        // 2. reconciliation: match by "identity" (name + ecosystem)
469        // when purls are absent or change, we match by (ecosystem, name).
470        // if either ecosystem is None, we treat it as a wildcard and match by name alone.
471        //
472        // the map is keyed by name, then by ecosystem, so the wildcard lookup
473        // (new has no ecosystem → match any old with the same name) is O(k)
474        // where k is the number of distinct ecosystems sharing that name,
475        // rather than a linear scan of the entire map.
476        let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
477            BTreeMap::new();
478        for (id, comp) in &old.components {
479            if !matched_old.contains(id) {
480                old_identity_map
481                    .entry(comp.name.clone())
482                    .or_default()
483                    .entry(comp.ecosystem.clone())
484                    .or_default()
485                    .push(id.clone());
486            }
487        }
488        let mut new_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
489            BTreeMap::new();
490        for (id, comp) in &new.components {
491            if !matched_new.contains(id) {
492                new_identity_map
493                    .entry(comp.name.clone())
494                    .or_default()
495                    .entry(comp.ecosystem.clone())
496                    .or_default()
497                    .push(id.clone());
498            }
499        }
500
501        // 2a. exact (ecosystem, name) matches are resolved a whole bucket at a
502        // time, so several versions of one package pair up in version order.
503        let mut identity_pairs: Vec<(ComponentId, ComponentId)> = Vec::new();
504        for (name, new_eco_map) in &new_identity_map {
505            let Some(old_eco_map) = old_identity_map.get_mut(name) else {
506                continue;
507            };
508            for (ecosystem, new_ids) in new_eco_map {
509                let Some(old_ids) = old_eco_map.get_mut(ecosystem) else {
510                    continue;
511                };
512                let pairs = Self::align_by_version(old_ids, new_ids, &old, &new);
513                let consumed: HashSet<ComponentId> =
514                    pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
515                old_ids.retain(|id| !consumed.contains(id));
516                identity_pairs.extend(pairs);
517            }
518        }
519        for (_, new_id) in &identity_pairs {
520            matched_new.insert(new_id.clone());
521        }
522
523        // 2b. what is left falls through to the wildcard cases, aligned by
524        // version as well; an ecosystem-less new component pools every old
525        // ecosystem of that name, so the version-nearest candidate wins over
526        // the alphabetically first.
527        for (name, new_eco_map) in &new_identity_map {
528            let Some(old_eco_map) = old_identity_map.get_mut(name) else {
529                continue;
530            };
531            for (ecosystem, new_ids) in new_eco_map {
532                let new_ids: Vec<ComponentId> = new_ids
533                    .iter()
534                    .filter(|id| !matched_new.contains(*id))
535                    .cloned()
536                    .collect();
537                if new_ids.is_empty() {
538                    continue;
539                }
540                let old_ids: Vec<ComponentId> = if ecosystem.is_some() {
541                    old_eco_map.get(&None).cloned().unwrap_or_default()
542                } else {
543                    old_eco_map.values().flatten().cloned().collect()
544                };
545                let pairs = Self::align_by_version(&old_ids, &new_ids, &old, &new);
546                let consumed: HashSet<ComponentId> =
547                    pairs.iter().map(|(old_id, _)| old_id.clone()).collect();
548                for ids in old_eco_map.values_mut() {
549                    ids.retain(|id| !consumed.contains(id));
550                }
551                for (_, new_id) in &pairs {
552                    matched_new.insert(new_id.clone());
553                }
554                identity_pairs.extend(pairs);
555            }
556        }
557
558        identity_pairs.sort_by(|a, b| a.1.cmp(&b.1));
559        for (old_id, new_id) in identity_pairs {
560            let (Some(old_comp), Some(new_comp)) =
561                (old.components.get(&old_id), new.components.get(&new_id))
562            else {
563                continue;
564            };
565            matched_old.insert(old_id.clone());
566            matched_new.insert(new_id.clone());
567            id_mapping.insert(old_id.clone(), new_id.clone());
568
569            let fields = Self::compute_fields(old_comp, new_comp, only);
570            if !fields.is_empty() {
571                changed_pairs.push((old_id, new_id, fields));
572            }
573        }
574
575        // 3. compute totals (must happen before draining the maps)
576        let old_total = old.components.len();
577        let new_total = new.components.len();
578        let matched = matched_old.len();
579        let unchanged = matched - changed_pairs.len();
580
581        // 4. compute edge diffs (needs dependencies, not component values)
582        let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
583        let edge_diffs = if should_include_deps {
584            Self::compute_edge_diffs(&old, &new, &id_mapping)
585        } else {
586            Vec::new()
587        };
588
589        // 5. build human-readable name map (needs component maps intact)
590        let component_names = Self::build_component_names(&old, &new, &edge_diffs);
591
592        // phase 2: drain components by moving them out of the maps, avoiding
593        // all Component::clone() calls.
594
595        // 6. drain changed pairs — swap_remove moves values out of the IndexMap
596        let mut changed = Vec::with_capacity(changed_pairs.len());
597        for (old_id, new_id, fields) in changed_pairs {
598            let old_comp = old.components.swap_remove(&old_id).unwrap();
599            let new_comp = new.components.swap_remove(&new_id).unwrap();
600            let ecosystem = pair_ecosystem(&old_comp, &new_comp);
601            let downgrade = fields.iter().any(|f| match f {
602                FieldChange::Version(Some(old_ver), Some(new_ver)) => {
603                    is_version_downgrade_for_ecosystem(ecosystem, old_ver, new_ver)
604                }
605                _ => false,
606            });
607            changed.push(ComponentChange {
608                id: new_comp.id.clone(),
609                old: old_comp,
610                new: new_comp,
611                changes: fields,
612                is_downgrade: downgrade,
613            });
614        }
615
616        // 7. remove unchanged matched components (already drained changed ones
617        //    above, so swap_remove returns None for those — that's fine)
618        for id in &matched_old {
619            old.components.swap_remove(id);
620        }
621        for id in &matched_new {
622            new.components.swap_remove(id);
623        }
624
625        // 8. drain remaining: everything left is unmatched
626        let added: Vec<Component> = new.components.into_values().collect();
627        let removed: Vec<Component> = old.components.into_values().collect();
628
629        Diff {
630            added,
631            removed,
632            changed,
633            edge_diffs,
634            metadata_changed,
635            old_total,
636            new_total,
637            unchanged,
638            component_names,
639        }
640    }
641
642    /// pairs same-identity candidates by aligning them in version order, so that
643    /// pairings never cross; unequal counts and ties resolve to the smallest
644    /// total distance in the merged version order, then to the fewest downgrades.
645    ///
646    /// candidates whose versions admit no total order — absent, opaque, or of
647    /// mixed [`Version`] variants — are paired by id instead, as are buckets
648    /// above [`MAX_ALIGNED_CANDIDATES`].
649    fn align_by_version(
650        old_ids: &[ComponentId],
651        new_ids: &[ComponentId],
652        old: &Sbom,
653        new: &Sbom,
654    ) -> Vec<(ComponentId, ComponentId)> {
655        let by_id = || -> Vec<(ComponentId, ComponentId)> {
656            new_ids
657                .iter()
658                .zip(old_ids.iter().rev())
659                .map(|(new_id, old_id)| (old_id.clone(), new_id.clone()))
660                .collect()
661        };
662
663        if old_ids.is_empty() || new_ids.is_empty() {
664            return Vec::new();
665        }
666        if old_ids.len() > MAX_ALIGNED_CANDIDATES || new_ids.len() > MAX_ALIGNED_CANDIDATES {
667            return by_id();
668        }
669
670        let mut merged: Vec<(Version, u8, &ComponentId)> =
671            Vec::with_capacity(old_ids.len() + new_ids.len());
672        for (side, ids, sbom) in [(0u8, old_ids, old), (1u8, new_ids, new)] {
673            for id in ids {
674                let component = sbom.components.get(id);
675                let Some(version) = component.and_then(|c| c.version.as_deref()) else {
676                    return by_id();
677                };
678                let ecosystem = component.and_then(|c| c.ecosystem.as_deref());
679                merged.push((Version::parse_for_ecosystem(ecosystem, version), side, id));
680            }
681        }
682        // `sort_by` needs a strict weak ordering; comparability is an equivalence, so
683        // one class check against the first element rules out a mixed-class bucket.
684        let first = &merged[0].0;
685        if first.partial_cmp_lenient(first).is_none()
686            || merged
687                .iter()
688                .any(|(version, ..)| first.partial_cmp_lenient(version).is_none())
689        {
690            return by_id();
691        }
692        // comparability is not enough: `Semver` against `Numeric` drops the pre-release,
693        // so `Equal` stops being transitive once a bucket holds both.
694        let (mut numeric, mut prerelease) = (false, false);
695        for (version, ..) in &merged {
696            match version {
697                Version::Numeric(_) => numeric = true,
698                Version::Semver(v) => prerelease |= !v.pre.is_empty(),
699                _ => {}
700            }
701        }
702        if numeric && prerelease {
703            return by_id();
704        }
705        merged.sort_by(|a, b| {
706            a.0.partial_cmp_lenient(&b.0)
707                .unwrap_or(Ordering::Equal)
708                .then_with(|| a.1.cmp(&b.1))
709                .then_with(|| a.2.cmp(b.2))
710        });
711
712        let (mut old_ranked, mut new_ranked) = (Vec::new(), Vec::new());
713        for (rank, (version, side, id)) in merged.iter().enumerate() {
714            if *side == 0 {
715                old_ranked.push((rank, *id, version));
716            } else {
717                new_ranked.push((rank, *id, version));
718            }
719        }
720
721        let (n, m) = (old_ranked.len(), new_ranked.len());
722        let cost = |i: usize, j: usize| {
723            (
724                old_ranked[i].0.abs_diff(new_ranked[j].0),
725                usize::from(old_ranked[i].2.is_downgrade(new_ranked[j].2)),
726            )
727        };
728        let better = |a: (usize, usize, usize), b: (usize, usize, usize)| {
729            a.0 > b.0 || (a.0 == b.0 && (a.1, a.2) < (b.1, b.2))
730        };
731        let paired_with = |(pairs, distance, downgrades): (usize, usize, usize), i, j| {
732            let (extra_distance, extra_downgrade) = cost(i, j);
733            (
734                pairs + 1,
735                distance + extra_distance,
736                downgrades + extra_downgrade,
737            )
738        };
739
740        // score[i][j]: best (pairs, total distance, downgrades) from candidates i and j on
741        let mut score = vec![vec![(0usize, 0usize, 0usize); m + 1]; n + 1];
742        for i in (0..n).rev() {
743            for j in (0..m).rev() {
744                let mut best = paired_with(score[i + 1][j + 1], i, j);
745                if better(score[i + 1][j], best) {
746                    best = score[i + 1][j];
747                }
748                if better(score[i][j + 1], best) {
749                    best = score[i][j + 1];
750                }
751                score[i][j] = best;
752            }
753        }
754
755        let mut pairs = Vec::with_capacity(n.min(m));
756        let (mut i, mut j) = (0, 0);
757        while i < n && j < m {
758            let take = paired_with(score[i + 1][j + 1], i, j);
759            if !better(score[i + 1][j], take) && !better(score[i][j + 1], take) {
760                pairs.push((old_ranked[i].1.clone(), new_ranked[j].1.clone()));
761                i += 1;
762                j += 1;
763            } else if better(score[i + 1][j], score[i][j + 1]) {
764                i += 1;
765            } else {
766                j += 1;
767            }
768        }
769        pairs
770    }
771
772    /// computes dependency edge differences between two SBOMs.
773    ///
774    /// uses the id_mapping to translate old component IDs to new IDs when
775    /// components were matched by identity rather than exact ID match.
776    /// tracks dependency kind for added/removed edges and detects kind changes
777    /// (e.g. a dependency moving from dev to runtime).
778    fn compute_edge_diffs(
779        old: &Sbom,
780        new: &Sbom,
781        id_mapping: &BTreeMap<ComponentId, ComponentId>,
782    ) -> Vec<EdgeDiff> {
783        let mut edge_diffs = Vec::new();
784
785        // borrow references instead of cloning every ID pair
786        let reverse_mapping: BTreeMap<&ComponentId, &ComponentId> = id_mapping
787            .iter()
788            .map(|(old_id, new_id)| (new_id, old_id))
789            .collect();
790
791        // collect parent IDs as references — avoids cloning every key
792        let mut all_parents: BTreeSet<&ComponentId> = new.dependencies.keys().collect();
793        for old_parent in old.dependencies.keys() {
794            all_parents.insert(id_mapping.get(old_parent).unwrap_or(old_parent));
795        }
796
797        let empty_deps = BTreeMap::new();
798
799        for parent_id in all_parents {
800            // borrow the new dependency map instead of cloning it
801            let new_children = new.dependencies.get(parent_id).unwrap_or(&empty_deps);
802
803            let old_parent_id = reverse_mapping.get(parent_id).copied().unwrap_or(parent_id);
804
805            // old children needs translated keys, but use reference keys
806            let old_children: BTreeMap<&ComponentId, DependencyKind> = old
807                .dependencies
808                .get(old_parent_id)
809                .map(|children| {
810                    children
811                        .iter()
812                        .map(|(id, &kind)| (id_mapping.get(id).unwrap_or(id), kind))
813                        .collect()
814                })
815                .unwrap_or_default();
816
817            let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
818            let old_keys: BTreeSet<&ComponentId> = old_children.keys().copied().collect();
819
820            // clone IDs only for entries that actually differ
821            let added: BTreeMap<ComponentId, DependencyKind> = new_keys
822                .difference(&old_keys)
823                .map(|&id| (id.clone(), new_children[id]))
824                .collect();
825            let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
826                .difference(&new_keys)
827                .map(|&id| (id.clone(), old_children[id]))
828                .collect();
829
830            let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
831                .intersection(&old_keys)
832                .filter_map(|&id| {
833                    let old_kind = old_children[id];
834                    let new_kind = new_children[id];
835                    if old_kind != new_kind {
836                        Some((id.clone(), (old_kind, new_kind)))
837                    } else {
838                        None
839                    }
840                })
841                .collect();
842
843            if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
844                edge_diffs.push(EdgeDiff {
845                    parent: parent_id.clone(),
846                    added,
847                    removed,
848                    kind_changed,
849                });
850            }
851        }
852
853        edge_diffs
854    }
855
856    /// builds a human-readable display name map for component IDs in edge diffs.
857    ///
858    /// only includes entries for hash-based IDs (`h:...`) since purl-based IDs
859    /// are already human-readable. looks up component names from both SBOMs.
860    fn build_component_names(
861        old: &Sbom,
862        new: &Sbom,
863        edge_diffs: &[EdgeDiff],
864    ) -> BTreeMap<ComponentId, String> {
865        let mut names = BTreeMap::new();
866
867        // collect all IDs that appear in edge diffs
868        let mut ids = BTreeSet::new();
869        for edge in edge_diffs {
870            ids.insert(&edge.parent);
871            ids.extend(edge.added.keys());
872            ids.extend(edge.removed.keys());
873            ids.extend(edge.kind_changed.keys());
874        }
875
876        // only resolve hash-based IDs — purls are already readable
877        for id in ids {
878            if !id.as_str().starts_with("h:") {
879                continue;
880            }
881
882            // try new SBOM first (edge diffs use new-SBOM IDs), then old
883            let comp = new.components.get(id).or_else(|| old.components.get(id));
884            if let Some(comp) = comp {
885                let display = match &comp.version {
886                    Some(v) => format!("{}@{}", comp.name, v),
887                    None => comp.name.clone(),
888                };
889                names.insert(id.clone(), display);
890            }
891        }
892
893        names
894    }
895
896    /// compares two components field-by-field, returning the list of
897    /// [`FieldChange`]s. an empty vector means the components are identical
898    /// (modulo fields excluded by `only`).
899    ///
900    /// this is a pure comparison — it does not construct a [`ComponentChange`]
901    /// or clone either component. the caller is responsible for building the
902    /// final struct from owned values.
903    fn compute_fields(
904        old: &Component,
905        new: &Component,
906        only: Option<&[Field]>,
907    ) -> Vec<FieldChange> {
908        let mut changes = Vec::new();
909
910        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
911
912        if should_include(Field::Version) && old.version != new.version {
913            changes.push(FieldChange::Version(
914                old.version.clone(),
915                new.version.clone(),
916            ));
917        }
918
919        if should_include(Field::License) {
920            if old.licenses != new.licenses {
921                changes.push(FieldChange::License(
922                    old.licenses.clone(),
923                    new.licenses.clone(),
924                ));
925            } else if !licensings_equivalent(old.licensing(), new.licensing()) {
926                changes.push(FieldChange::LicenseExpression(
927                    old.license_expression.clone(),
928                    new.license_expression.clone(),
929                ));
930            }
931        }
932
933        if should_include(Field::Supplier) && old.supplier != new.supplier {
934            changes.push(FieldChange::Supplier(
935                old.supplier.clone(),
936                new.supplier.clone(),
937            ));
938        }
939
940        if should_include(Field::Purl) && old.purl != new.purl {
941            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
942        }
943
944        if should_include(Field::Description) && old.description != new.description {
945            changes.push(FieldChange::Description(
946                old.description.clone(),
947                new.description.clone(),
948            ));
949        }
950
951        if should_include(Field::Hashes) && old.hashes != new.hashes {
952            changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
953        }
954
955        if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
956            changes.push(FieldChange::Ecosystem(
957                old.ecosystem.clone(),
958                new.ecosystem.clone(),
959            ));
960        }
961
962        changes
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn test_diff_added_removed() {
972        let mut old = Sbom::default();
973        let mut new = Sbom::default();
974
975        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
976        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
977
978        old.components.insert(c1.id.clone(), c1);
979        new.components.insert(c2.id.clone(), c2);
980
981        let diff = Differ::diff(&old, &new, None);
982        assert_eq!(diff.added.len(), 1);
983        assert_eq!(diff.removed.len(), 1);
984        assert_eq!(diff.changed.len(), 0);
985    }
986
987    #[test]
988    fn test_diff_changed() {
989        let mut old = Sbom::default();
990        let mut new = Sbom::default();
991
992        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
993        let mut c2 = c1.clone();
994        c2.version = Some("1.1".to_string());
995
996        old.components.insert(c1.id.clone(), c1);
997        new.components.insert(c2.id.clone(), c2);
998
999        let diff = Differ::diff(&old, &new, None);
1000        assert_eq!(diff.added.len(), 0);
1001        assert_eq!(diff.removed.len(), 0);
1002        assert_eq!(diff.changed.len(), 1);
1003        assert!(matches!(
1004            diff.changed[0].changes[0],
1005            FieldChange::Version(_, _)
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_diff_identity_reconciliation() {
1011        let mut old = Sbom::default();
1012        let mut new = Sbom::default();
1013
1014        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1015        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
1016
1017        old.components.insert(c1.id.clone(), c1);
1018        new.components.insert(c2.id.clone(), c2);
1019
1020        let diff = Differ::diff(&old, &new, None);
1021        assert_eq!(diff.changed.len(), 1);
1022        assert_eq!(diff.added.len(), 0);
1023    }
1024
1025    #[test]
1026    fn test_diff_license_change() {
1027        let mut old = Sbom::default();
1028        let mut new = Sbom::default();
1029
1030        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1031        c1.licenses.insert("MIT".into());
1032        let mut c2 = c1.clone();
1033        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1034
1035        old.components.insert(c1.id.clone(), c1);
1036        new.components.insert(c2.id.clone(), c2);
1037
1038        let diff = Differ::diff(&old, &new, None);
1039        assert_eq!(diff.changed.len(), 1);
1040        assert!(diff.changed[0]
1041            .changes
1042            .iter()
1043            .any(|c| matches!(c, FieldChange::License(_, _))));
1044    }
1045
1046    #[test]
1047    fn test_diff_supplier_change() {
1048        let mut old = Sbom::default();
1049        let mut new = Sbom::default();
1050
1051        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1052        c1.supplier = Some("Acme Corp".into());
1053        let mut c2 = c1.clone();
1054        c2.supplier = Some("New Corp".into());
1055
1056        old.components.insert(c1.id.clone(), c1);
1057        new.components.insert(c2.id.clone(), c2);
1058
1059        let diff = Differ::diff(&old, &new, None);
1060        assert_eq!(diff.changed.len(), 1);
1061        assert!(diff.changed[0]
1062            .changes
1063            .iter()
1064            .any(|c| matches!(c, FieldChange::Supplier(_, _))));
1065    }
1066
1067    #[test]
1068    fn test_diff_hashes_change() {
1069        let mut old = Sbom::default();
1070        let mut new = Sbom::default();
1071
1072        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1073        c1.hashes.insert("sha256".into(), "aaa".into());
1074        let mut c2 = c1.clone();
1075        c2.hashes.insert("sha256".into(), "bbb".into());
1076
1077        old.components.insert(c1.id.clone(), c1);
1078        new.components.insert(c2.id.clone(), c2);
1079
1080        let diff = Differ::diff(&old, &new, None);
1081        assert_eq!(diff.changed.len(), 1);
1082        assert!(diff.changed[0]
1083            .changes
1084            .iter()
1085            .any(|c| matches!(c, FieldChange::Hashes(_, _))));
1086    }
1087
1088    #[test]
1089    fn test_diff_description_change() {
1090        let mut old = Sbom::default();
1091        let mut new = Sbom::default();
1092
1093        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1094        c1.description = Some("Old description".into());
1095        let mut c2 = c1.clone();
1096        c2.description = Some("New description".into());
1097
1098        old.components.insert(c1.id.clone(), c1);
1099        new.components.insert(c2.id.clone(), c2);
1100
1101        let diff = Differ::diff(&old, &new, None);
1102        assert_eq!(diff.changed.len(), 1);
1103        assert!(diff.changed[0]
1104            .changes
1105            .iter()
1106            .any(|c| matches!(c, FieldChange::Description(_, _))));
1107    }
1108
1109    #[test]
1110    fn test_diff_description_added() {
1111        let mut old = Sbom::default();
1112        let mut new = Sbom::default();
1113
1114        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1115        let mut c2 = c1.clone();
1116        c2.description = Some("A new description".into());
1117
1118        old.components.insert(c1.id.clone(), c1);
1119        new.components.insert(c2.id.clone(), c2);
1120
1121        let diff = Differ::diff(&old, &new, None);
1122        assert_eq!(diff.changed.len(), 1);
1123        assert!(diff.changed[0]
1124            .changes
1125            .iter()
1126            .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
1127    }
1128
1129    #[test]
1130    fn test_diff_description_removed() {
1131        let mut old = Sbom::default();
1132        let mut new = Sbom::default();
1133
1134        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1135        c1.description = Some("Had a description".into());
1136        let mut c2 = c1.clone();
1137        c2.description = None;
1138
1139        old.components.insert(c1.id.clone(), c1);
1140        new.components.insert(c2.id.clone(), c2);
1141
1142        let diff = Differ::diff(&old, &new, None);
1143        assert_eq!(diff.changed.len(), 1);
1144        assert!(diff.changed[0]
1145            .changes
1146            .iter()
1147            .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
1148    }
1149
1150    #[test]
1151    fn test_diff_description_unchanged() {
1152        let mut old = Sbom::default();
1153        let mut new = Sbom::default();
1154
1155        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1156        c1.description = Some("Same description".into());
1157        let c2 = c1.clone();
1158
1159        old.components.insert(c1.id.clone(), c1);
1160        new.components.insert(c2.id.clone(), c2);
1161
1162        let diff = Differ::diff(&old, &new, None);
1163        assert!(diff.changed.is_empty());
1164    }
1165
1166    #[test]
1167    fn test_diff_description_filtering() {
1168        let mut old = Sbom::default();
1169        let mut new = Sbom::default();
1170
1171        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1172        c1.description = Some("Old".into());
1173        let mut c2 = c1.clone();
1174        c2.version = Some("2.0".into());
1175        c2.description = Some("New".into());
1176
1177        old.components.insert(c1.id.clone(), c1);
1178        new.components.insert(c2.id.clone(), c2);
1179
1180        // only description: should see description change but not version
1181        let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
1182        assert_eq!(diff.changed.len(), 1);
1183        assert_eq!(diff.changed[0].changes.len(), 1);
1184        assert!(matches!(
1185            diff.changed[0].changes[0],
1186            FieldChange::Description(_, _)
1187        ));
1188
1189        // only version: should see version change but not description
1190        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1191        assert_eq!(diff.changed.len(), 1);
1192        assert_eq!(diff.changed[0].changes.len(), 1);
1193        assert!(matches!(
1194            diff.changed[0].changes[0],
1195            FieldChange::Version(_, _)
1196        ));
1197    }
1198
1199    #[test]
1200    fn test_diff_ecosystem_change() {
1201        let mut old = Sbom::default();
1202        let mut new = Sbom::default();
1203
1204        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1205        c1.ecosystem = Some("npm".to_string());
1206        let mut c2 = c1.clone();
1207        c2.ecosystem = Some("cargo".to_string());
1208
1209        old.components.insert(c1.id.clone(), c1);
1210        new.components.insert(c2.id.clone(), c2);
1211
1212        let diff = Differ::diff(&old, &new, None);
1213        assert_eq!(diff.changed.len(), 1);
1214        assert_eq!(diff.changed[0].changes.len(), 1);
1215        assert!(matches!(
1216            diff.changed[0].changes[0],
1217            FieldChange::Ecosystem(_, _)
1218        ));
1219
1220        if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
1221            assert_eq!(o.as_deref(), Some("npm"));
1222            assert_eq!(n.as_deref(), Some("cargo"));
1223        }
1224    }
1225
1226    #[test]
1227    fn test_diff_ecosystem_change_from_none() {
1228        let mut old = Sbom::default();
1229        let mut new = Sbom::default();
1230
1231        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1232        let mut c2 = c1.clone();
1233        c2.ecosystem = Some("npm".to_string());
1234
1235        old.components.insert(c1.id.clone(), c1);
1236        new.components.insert(c2.id.clone(), c2);
1237
1238        let diff = Differ::diff(&old, &new, None);
1239        assert_eq!(diff.changed.len(), 1);
1240        assert_eq!(diff.changed[0].changes.len(), 1);
1241        assert!(matches!(
1242            diff.changed[0].changes[0],
1243            FieldChange::Ecosystem(None, Some(_))
1244        ));
1245    }
1246
1247    #[test]
1248    fn test_diff_ecosystem_filtering() {
1249        let mut old = Sbom::default();
1250        let mut new = Sbom::default();
1251
1252        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1253        c1.ecosystem = Some("npm".to_string());
1254        let mut c2 = c1.clone();
1255        c2.version = Some("2.0".into());
1256        c2.ecosystem = Some("cargo".to_string());
1257
1258        old.components.insert(c1.id.clone(), c1);
1259        new.components.insert(c2.id.clone(), c2);
1260
1261        // only ecosystem: should see ecosystem change but not version
1262        let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
1263        assert_eq!(diff.changed.len(), 1);
1264        assert_eq!(diff.changed[0].changes.len(), 1);
1265        assert!(matches!(
1266            diff.changed[0].changes[0],
1267            FieldChange::Ecosystem(_, _)
1268        ));
1269
1270        // only version: should see version change but not ecosystem
1271        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1272        assert_eq!(diff.changed.len(), 1);
1273        assert_eq!(diff.changed[0].changes.len(), 1);
1274        assert!(matches!(
1275            diff.changed[0].changes[0],
1276            FieldChange::Version(_, _)
1277        ));
1278    }
1279
1280    #[test]
1281    fn test_diff_ecosystem_no_change() {
1282        let mut old = Sbom::default();
1283        let mut new = Sbom::default();
1284
1285        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1286        c1.ecosystem = Some("npm".to_string());
1287        let c2 = c1.clone();
1288
1289        old.components.insert(c1.id.clone(), c1);
1290        new.components.insert(c2.id.clone(), c2);
1291
1292        let diff = Differ::diff(&old, &new, None);
1293        assert!(diff.changed.is_empty());
1294    }
1295
1296    #[test]
1297    fn test_diff_multiple_field_changes() {
1298        let mut old = Sbom::default();
1299        let mut new = Sbom::default();
1300
1301        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1302        c1.licenses.insert("MIT".into());
1303        c1.supplier = Some("Old Corp".into());
1304        c1.hashes.insert("sha256".into(), "aaa".into());
1305
1306        let mut c2 = c1.clone();
1307        c2.version = Some("2.0".into());
1308        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1309        c2.supplier = Some("New Corp".into());
1310        c2.hashes.insert("sha256".into(), "bbb".into());
1311
1312        old.components.insert(c1.id.clone(), c1);
1313        new.components.insert(c2.id.clone(), c2);
1314
1315        let diff = Differ::diff(&old, &new, None);
1316        assert_eq!(diff.changed.len(), 1);
1317        assert_eq!(diff.changed[0].changes.len(), 4);
1318    }
1319
1320    #[test]
1321    fn test_diff_no_changes() {
1322        let mut old = Sbom::default();
1323        let mut new = Sbom::default();
1324
1325        let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1326        old.components.insert(c.id.clone(), c.clone());
1327        new.components.insert(c.id.clone(), c);
1328
1329        let diff = Differ::diff(&old, &new, None);
1330        assert!(diff.added.is_empty());
1331        assert!(diff.removed.is_empty());
1332        assert!(diff.changed.is_empty());
1333        assert!(diff.edge_diffs.is_empty());
1334    }
1335
1336    #[test]
1337    fn test_diff_metadata_changed_timestamp() {
1338        let mut old = Sbom::default();
1339        let mut new = Sbom::default();
1340
1341        old.metadata.timestamp = Some("2024-01-01".into());
1342        new.metadata.timestamp = Some("2024-01-02".into());
1343
1344        let diff = Differ::diff(&old, &new, None);
1345        let mc = diff.metadata_changed.as_ref().unwrap();
1346        assert_eq!(
1347            mc.timestamp,
1348            Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1349        );
1350        assert!(mc.tools.is_none());
1351        assert!(mc.authors.is_none());
1352        assert!(!diff.is_empty());
1353    }
1354
1355    #[test]
1356    fn test_diff_metadata_changed_tools() {
1357        let mut old = Sbom::default();
1358        let mut new = Sbom::default();
1359
1360        old.metadata.tools = vec!["syft".into()];
1361        new.metadata.tools = vec!["trivy".into()];
1362
1363        let diff = Differ::diff(&old, &new, None);
1364        let mc = diff.metadata_changed.as_ref().unwrap();
1365        assert!(mc.timestamp.is_none());
1366        assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1367        assert!(mc.authors.is_none());
1368    }
1369
1370    #[test]
1371    fn test_diff_metadata_changed_authors() {
1372        let mut old = Sbom::default();
1373        let mut new = Sbom::default();
1374
1375        old.metadata.authors = vec!["alice".into()];
1376        new.metadata.authors = vec!["bob".into()];
1377
1378        let diff = Differ::diff(&old, &new, None);
1379        let mc = diff.metadata_changed.as_ref().unwrap();
1380        assert!(mc.timestamp.is_none());
1381        assert!(mc.tools.is_none());
1382        assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1383    }
1384
1385    #[test]
1386    fn test_diff_metadata_unchanged() {
1387        let mut old = Sbom::default();
1388        let mut new = Sbom::default();
1389
1390        old.metadata.timestamp = Some("2024-01-01".into());
1391        new.metadata.timestamp = Some("2024-01-01".into());
1392        old.metadata.tools = vec!["syft".into()];
1393        new.metadata.tools = vec!["syft".into()];
1394
1395        let diff = Differ::diff(&old, &new, None);
1396        assert!(diff.metadata_changed.is_none());
1397    }
1398
1399    #[test]
1400    fn test_diff_metadata_changed_multiple_fields() {
1401        let mut old = Sbom::default();
1402        let mut new = Sbom::default();
1403
1404        old.metadata.timestamp = Some("2024-01-01".into());
1405        new.metadata.timestamp = Some("2024-01-02".into());
1406        old.metadata.tools = vec!["syft".into()];
1407        new.metadata.tools = vec!["trivy".into()];
1408        old.metadata.authors = vec!["alice".into()];
1409        new.metadata.authors = vec!["bob".into()];
1410
1411        let diff = Differ::diff(&old, &new, None);
1412        let mc = diff.metadata_changed.as_ref().unwrap();
1413        assert!(mc.timestamp.is_some());
1414        assert!(mc.tools.is_some());
1415        assert!(mc.authors.is_some());
1416    }
1417
1418    #[test]
1419    fn test_diff_filtering() {
1420        let mut old = Sbom::default();
1421        let mut new = Sbom::default();
1422
1423        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1424        c1.licenses.insert("MIT".into());
1425
1426        let mut c2 = c1.clone();
1427        c2.version = Some("1.1".to_string());
1428        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1429
1430        old.components.insert(c1.id.clone(), c1);
1431        new.components.insert(c2.id.clone(), c2);
1432
1433        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1434        assert_eq!(diff.changed.len(), 1);
1435        assert_eq!(diff.changed[0].changes.len(), 1);
1436        assert!(matches!(
1437            diff.changed[0].changes[0],
1438            FieldChange::Version(_, _)
1439        ));
1440    }
1441
1442    #[test]
1443    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1444        // component with purl in old, different purl in new (same ecosystem+name)
1445        // should be treated as a CHANGE with Purl field change, not add/remove
1446        let mut old = Sbom::default();
1447        let mut new = Sbom::default();
1448
1449        // old: lodash with one purl
1450        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1451        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1452        c_old.ecosystem = Some("npm".to_string());
1453        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1454
1455        // new: lodash with updated purl (version bump)
1456        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1457        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1458        c_new.ecosystem = Some("npm".to_string());
1459        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1460
1461        old.components.insert(c_old.id.clone(), c_old);
1462        new.components.insert(c_new.id.clone(), c_new);
1463
1464        let diff = Differ::diff(&old, &new, None);
1465
1466        assert_eq!(diff.added.len(), 0, "Should not have added components");
1467        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1468
1469        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1470
1471        let changes = &diff.changed[0].changes;
1472        assert!(changes
1473            .iter()
1474            .any(|c| matches!(c, FieldChange::Version(_, _))));
1475        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1476    }
1477
1478    #[test]
1479    fn test_purl_removed_is_change() {
1480        // component with purl in old, no purl in new (same name)
1481        // this is realistic: old SBOM from tool that adds purls, new from tool that doesn't
1482        let mut old = Sbom::default();
1483        let mut new = Sbom::default();
1484
1485        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1486        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1487        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
1488        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1489
1490        // new component without purl - ecosystem is None (realistic!)
1491        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1492        c_new.purl = None;
1493        c_new.ecosystem = None; // No purl means no ecosystem extraction
1494                                // ID will be hash-based since no purl
1495        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1496
1497        old.components.insert(c_old.id.clone(), c_old);
1498        new.components.insert(c_new.id.clone(), c_new);
1499
1500        let diff = Differ::diff(&old, &new, None);
1501
1502        assert_eq!(diff.added.len(), 0, "Should not have added components");
1503        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1504        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1505
1506        assert!(diff.changed[0]
1507            .changes
1508            .iter()
1509            .any(|c| matches!(c, FieldChange::Purl(_, _))));
1510    }
1511
1512    #[test]
1513    fn test_purl_added_is_change() {
1514        // component with no purl in old, purl in new
1515        // this is realistic: old SBOM without purls, new from better tooling
1516        let mut old = Sbom::default();
1517        let mut new = Sbom::default();
1518
1519        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1520        c_old.purl = None;
1521        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
1522        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1523
1524        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1525        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1526        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
1527        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1528
1529        old.components.insert(c_old.id.clone(), c_old);
1530        new.components.insert(c_new.id.clone(), c_new);
1531
1532        let diff = Differ::diff(&old, &new, None);
1533
1534        assert_eq!(diff.added.len(), 0, "Should not have added components");
1535        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1536        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1537    }
1538
1539    #[test]
1540    fn test_same_name_different_ecosystems_not_matched() {
1541        // two components with same name but different ecosystems should NOT match
1542        let mut old = Sbom::default();
1543        let mut new = Sbom::default();
1544
1545        // old: "utils" from npm
1546        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1547        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1548        c_old.ecosystem = Some("npm".to_string());
1549        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1550
1551        // new: "utils" from pypi (different ecosystem!)
1552        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1553        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1554        c_new.ecosystem = Some("pypi".to_string());
1555        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1556
1557        old.components.insert(c_old.id.clone(), c_old);
1558        new.components.insert(c_new.id.clone(), c_new);
1559
1560        let diff = Differ::diff(&old, &new, None);
1561
1562        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1563        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1564        assert_eq!(
1565            diff.changed.len(),
1566            0,
1567            "Should not match different ecosystems"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_same_name_both_no_ecosystem_matched() {
1573        // components with same name and both having None ecosystem should match
1574        // (backwards compatibility for SBOMs without purls)
1575        let mut old = Sbom::default();
1576        let mut new = Sbom::default();
1577
1578        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1579        c_old.ecosystem = None;
1580
1581        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1582        c_new.ecosystem = None;
1583
1584        old.components.insert(c_old.id.clone(), c_old);
1585        new.components.insert(c_new.id.clone(), c_new);
1586
1587        let diff = Differ::diff(&old, &new, None);
1588
1589        assert_eq!(diff.added.len(), 0);
1590        assert_eq!(diff.removed.len(), 0);
1591        assert_eq!(
1592            diff.changed.len(),
1593            1,
1594            "Same name with None ecosystems should match"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_edge_diff_added_removed() {
1600        let mut old = Sbom::default();
1601        let mut new = Sbom::default();
1602
1603        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1604        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1605        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1606
1607        let parent_id = c1.id.clone();
1608        let child_a_id = c2.id.clone();
1609        let child_b_id = c3.id.clone();
1610
1611        // add all components to both SBOMs
1612        old.components.insert(c1.id.clone(), c1.clone());
1613        old.components.insert(c2.id.clone(), c2.clone());
1614        old.components.insert(c3.id.clone(), c3.clone());
1615
1616        new.components.insert(c1.id.clone(), c1);
1617        new.components.insert(c2.id.clone(), c2);
1618        new.components.insert(c3.id.clone(), c3);
1619
1620        // old: parent -> child-a
1621        old.dependencies
1622            .entry(parent_id.clone())
1623            .or_default()
1624            .insert(child_a_id.clone(), DependencyKind::Runtime);
1625
1626        // new: parent -> child-b (removed child-a, added child-b)
1627        new.dependencies
1628            .entry(parent_id.clone())
1629            .or_default()
1630            .insert(child_b_id.clone(), DependencyKind::Runtime);
1631
1632        let diff = Differ::diff(&old, &new, None);
1633
1634        assert_eq!(diff.edge_diffs.len(), 1);
1635        assert_eq!(diff.edge_diffs[0].parent, parent_id);
1636        assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1637        assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1638    }
1639
1640    #[test]
1641    fn test_edge_diff_with_identity_reconciliation() {
1642        // test that edge diffs work when components are matched by identity
1643        // (different IDs but same name/ecosystem)
1644        let mut old = Sbom::default();
1645        let mut new = Sbom::default();
1646
1647        // parent with purl in old
1648        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1649        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1650        parent_old.ecosystem = Some("npm".to_string());
1651        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1652
1653        // parent with different purl in new (same name/ecosystem)
1654        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1655        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1656        parent_new.ecosystem = Some("npm".to_string());
1657        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1658
1659        // child component (same in both)
1660        let child = Component::new("child".to_string(), Some("1.0".to_string()));
1661
1662        old.components
1663            .insert(parent_old.id.clone(), parent_old.clone());
1664        old.components.insert(child.id.clone(), child.clone());
1665
1666        new.components
1667            .insert(parent_new.id.clone(), parent_new.clone());
1668        new.components.insert(child.id.clone(), child.clone());
1669
1670        // old: parent -> child
1671        old.dependencies
1672            .entry(parent_old.id.clone())
1673            .or_default()
1674            .insert(child.id.clone(), DependencyKind::Runtime);
1675
1676        // new: parent -> child (same edge, but parent has different ID)
1677        new.dependencies
1678            .entry(parent_new.id.clone())
1679            .or_default()
1680            .insert(child.id.clone(), DependencyKind::Runtime);
1681
1682        let diff = Differ::diff(&old, &new, None);
1683
1684        // components should be matched by identity, so no spurious edge changes
1685        // (the edge parent->child exists in both, just under different parent IDs)
1686        assert_eq!(
1687            diff.edge_diffs.len(),
1688            0,
1689            "No edge changes expected when parent is reconciled by identity"
1690        );
1691    }
1692
1693    #[test]
1694    fn test_edge_diff_filtering() {
1695        // test that --only filtering excludes edge diffs when deps not included
1696        let mut old = Sbom::default();
1697        let mut new = Sbom::default();
1698
1699        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1700        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1701
1702        let parent_id = c1.id.clone();
1703        let child_id = c2.id.clone();
1704
1705        old.components.insert(c1.id.clone(), c1.clone());
1706        old.components.insert(c2.id.clone(), c2.clone());
1707
1708        new.components.insert(c1.id.clone(), c1);
1709        new.components.insert(c2.id.clone(), c2);
1710
1711        // new has an edge that old doesn't
1712        new.dependencies
1713            .entry(parent_id.clone())
1714            .or_default()
1715            .insert(child_id, DependencyKind::Runtime);
1716
1717        // without filtering - should have edge diff
1718        let diff = Differ::diff(&old, &new, None);
1719        assert_eq!(diff.edge_diffs.len(), 1);
1720
1721        // with filtering to only Version - should NOT have edge diff
1722        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1723        assert_eq!(diff_filtered.edge_diffs.len(), 0);
1724
1725        // with filtering to include Deps - should have edge diff
1726        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1727        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1728    }
1729
1730    #[test]
1731    fn test_ecosystem_breakdown() {
1732        let mut old = Sbom::default();
1733        let mut new = Sbom::default();
1734
1735        // npm component in old only (removed)
1736        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1737        c1.ecosystem = Some("npm".into());
1738        old.components.insert(c1.id.clone(), c1);
1739
1740        // npm component in new only (added)
1741        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1742        c2.ecosystem = Some("npm".into());
1743        new.components.insert(c2.id.clone(), c2);
1744
1745        // cargo component in new only (added)
1746        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1747        c3.ecosystem = Some("cargo".into());
1748        new.components.insert(c3.id.clone(), c3);
1749
1750        // npm component changed (present in both, different version)
1751        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1752        c4_old.ecosystem = Some("npm".into());
1753        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1754        c4_new.ecosystem = Some("npm".into());
1755        old.components.insert(c4_old.id.clone(), c4_old);
1756        new.components.insert(c4_new.id.clone(), c4_new);
1757
1758        // component with no ecosystem (added)
1759        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1760        new.components.insert(c5.id.clone(), c5);
1761
1762        let diff = Differ::diff(&old, &new, None);
1763        let breakdown = diff.ecosystem_breakdown();
1764
1765        let npm = breakdown.get("npm").unwrap();
1766        assert_eq!(npm.added, 1);
1767        assert_eq!(npm.removed, 1);
1768        assert_eq!(npm.changed, 1);
1769
1770        let cargo = breakdown.get("cargo").unwrap();
1771        assert_eq!(cargo.added, 1);
1772        assert_eq!(cargo.removed, 0);
1773        assert_eq!(cargo.changed, 0);
1774
1775        let unknown = breakdown.get("unknown").unwrap();
1776        assert_eq!(unknown.added, 1);
1777        assert_eq!(unknown.removed, 0);
1778        assert_eq!(unknown.changed, 0);
1779    }
1780
1781    #[test]
1782    fn test_ecosystem_breakdown_empty_diff() {
1783        let old = Sbom::default();
1784        let new = Sbom::default();
1785
1786        let diff = Differ::diff(&old, &new, None);
1787        assert!(diff.is_empty());
1788        assert!(diff.ecosystem_breakdown().is_empty());
1789    }
1790
1791    #[test]
1792    fn test_group_by_ecosystem_empty_diff() {
1793        let old = Sbom::default();
1794        let new = Sbom::default();
1795
1796        let diff = Differ::diff(&old, &new, None);
1797        let grouped = diff.group_by_ecosystem();
1798        assert!(grouped.by_ecosystem.is_empty());
1799        assert!(grouped.edge_diffs.is_empty());
1800        assert!(grouped.metadata_changed.is_none());
1801        assert!(grouped.ecosystem_breakdown().is_empty());
1802    }
1803
1804    #[test]
1805    fn test_group_by_ecosystem_groups_correctly() {
1806        let mut old = Sbom::default();
1807        let mut new = Sbom::default();
1808
1809        // npm removed
1810        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1811        c1.ecosystem = Some("npm".into());
1812        old.components.insert(c1.id.clone(), c1);
1813
1814        // npm added
1815        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1816        c2.ecosystem = Some("npm".into());
1817        new.components.insert(c2.id.clone(), c2);
1818
1819        // cargo added
1820        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1821        c3.ecosystem = Some("cargo".into());
1822        new.components.insert(c3.id.clone(), c3);
1823
1824        // npm changed
1825        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1826        c4_old.ecosystem = Some("npm".into());
1827        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1828        c4_new.ecosystem = Some("npm".into());
1829        old.components.insert(c4_old.id.clone(), c4_old);
1830        new.components.insert(c4_new.id.clone(), c4_new);
1831
1832        // unknown added
1833        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1834        new.components.insert(c5.id.clone(), c5);
1835
1836        let diff = Differ::diff(&old, &new, None);
1837        let grouped = diff.group_by_ecosystem();
1838
1839        let npm = grouped.by_ecosystem.get("npm").unwrap();
1840        assert_eq!(npm.added.len(), 1);
1841        assert_eq!(npm.removed.len(), 1);
1842        assert_eq!(npm.changed.len(), 1);
1843
1844        let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1845        assert_eq!(cargo.added.len(), 1);
1846        assert_eq!(cargo.removed.len(), 0);
1847        assert_eq!(cargo.changed.len(), 0);
1848
1849        let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1850        assert_eq!(unknown.added.len(), 1);
1851        assert_eq!(unknown.removed.len(), 0);
1852        assert_eq!(unknown.changed.len(), 0);
1853
1854        // derived breakdown should match direct breakdown
1855        let grouped_counts = grouped.ecosystem_breakdown();
1856        let direct_counts = diff.ecosystem_breakdown();
1857        assert_eq!(grouped_counts, direct_counts);
1858    }
1859
1860    #[test]
1861    fn test_totals_no_changes() {
1862        let mut old = Sbom::default();
1863        let mut new = Sbom::default();
1864
1865        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1866        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1867
1868        old.components.insert(c1.id.clone(), c1.clone());
1869        old.components.insert(c2.id.clone(), c2.clone());
1870        new.components.insert(c1.id.clone(), c1);
1871        new.components.insert(c2.id.clone(), c2);
1872
1873        let diff = Differ::diff(&old, &new, None);
1874        assert_eq!(diff.old_total, 2);
1875        assert_eq!(diff.new_total, 2);
1876        assert_eq!(diff.unchanged, 2);
1877    }
1878
1879    #[test]
1880    fn test_totals_with_changes() {
1881        let mut old = Sbom::default();
1882        let mut new = Sbom::default();
1883
1884        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1885        let mut c1_updated = c1.clone();
1886        c1_updated.version = Some("1.1".to_string());
1887        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1888        let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1889        let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1890
1891        old.components.insert(c1.id.clone(), c1);
1892        old.components.insert(c2.id.clone(), c2.clone());
1893        old.components.insert(c3.id.clone(), c3);
1894        new.components.insert(c1_updated.id.clone(), c1_updated);
1895        new.components.insert(c2.id.clone(), c2);
1896        new.components.insert(c4.id.clone(), c4);
1897
1898        let diff = Differ::diff(&old, &new, None);
1899        assert_eq!(diff.old_total, 3);
1900        assert_eq!(diff.new_total, 3);
1901        assert_eq!(diff.added.len(), 1); // c4
1902        assert_eq!(diff.removed.len(), 1); // c3
1903        assert_eq!(diff.changed.len(), 1); // c1
1904        assert_eq!(diff.unchanged, 1); // c2
1905    }
1906
1907    #[test]
1908    fn test_component_names_for_hash_ids_in_edge_diffs() {
1909        let mut old = Sbom::default();
1910        let mut new = Sbom::default();
1911
1912        // components without purls → hash-based IDs
1913        let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1914        let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1915        let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1916
1917        old.components.insert(parent.id.clone(), parent.clone());
1918        old.components.insert(child_a.id.clone(), child_a.clone());
1919        new.components.insert(parent.id.clone(), parent.clone());
1920        new.components.insert(child_b.id.clone(), child_b.clone());
1921
1922        // set up edges: old parent -> child_a, new parent -> child_b
1923        old.dependencies.insert(
1924            parent.id.clone(),
1925            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1926        );
1927        new.dependencies.insert(
1928            parent.id.clone(),
1929            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1930        );
1931
1932        let diff = Differ::diff(&old, &new, None);
1933
1934        // all IDs in edge diffs should be hash-based (no purls)
1935        assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1936
1937        // component_names should resolve all hash IDs to readable names
1938        assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1939        for added in diff.edge_diffs[0].added.keys() {
1940            assert!(!diff.display_name(added).starts_with("h:"));
1941        }
1942        for removed in diff.edge_diffs[0].removed.keys() {
1943            assert!(!diff.display_name(removed).starts_with("h:"));
1944        }
1945    }
1946
1947    #[test]
1948    fn test_component_names_skips_purl_ids() {
1949        let mut old = Sbom::default();
1950        let mut new = Sbom::default();
1951
1952        let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1953        parent.purl = Some("pkg:npm/parent@1.0".to_string());
1954        parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1955
1956        let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1957        child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1958        child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1959
1960        let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1961        child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1962        child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1963
1964        old.components.insert(parent.id.clone(), parent.clone());
1965        old.components.insert(child_a.id.clone(), child_a.clone());
1966        new.components.insert(parent.id.clone(), parent.clone());
1967        new.components.insert(child_b.id.clone(), child_b.clone());
1968
1969        old.dependencies.insert(
1970            parent.id.clone(),
1971            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1972        );
1973        new.dependencies.insert(
1974            parent.id.clone(),
1975            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1976        );
1977
1978        let diff = Differ::diff(&old, &new, None);
1979
1980        // component_names should be empty — all IDs are purl-based
1981        assert!(diff.component_names.is_empty());
1982
1983        // display_name should fall back to the purl-based ID string
1984        assert!(diff
1985            .display_name(&diff.edge_diffs[0].parent)
1986            .starts_with("pkg:npm/parent@"));
1987    }
1988
1989    #[test]
1990    fn test_display_name_fallback() {
1991        let diff = Diff::default();
1992        let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1993        // no entry in component_names → falls back to raw ID
1994        assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1995    }
1996
1997    #[test]
1998    fn test_filter_by_ecosystem_include() {
1999        let mut old = Sbom::default();
2000        let mut new = Sbom::default();
2001
2002        // npm components
2003        let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
2004        npm1.ecosystem = Some("npm".into());
2005        let mut npm2 = Component::new("lodash".into(), Some("4.17.21".into()));
2006        npm2.ecosystem = Some("npm".into());
2007
2008        // cargo component
2009        let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
2010        cargo1.ecosystem = Some("cargo".into());
2011
2012        // pypi component
2013        let mut pypi1 = Component::new("requests".into(), Some("2.28.0".into()));
2014        pypi1.ecosystem = Some("pypi".into());
2015
2016        old.components.insert(npm2.id.clone(), npm2.clone());
2017        old.components.insert(cargo1.id.clone(), cargo1.clone());
2018
2019        new.components.insert(npm1.id.clone(), npm1);
2020        new.components.insert(npm2.id.clone(), npm2);
2021        new.components.insert(pypi1.id.clone(), pypi1);
2022
2023        // old has npm2 + cargo1 (2 components)
2024        // new has npm1 + npm2 + pypi1 (3 components)
2025        // npm2 is unchanged, npm1 is added (npm), cargo1 is removed, pypi1 is added (pypi)
2026
2027        let mut diff = Differ::diff(&old, &new, None);
2028
2029        // pre-filtered totals for npm: old has 1 npm (npm2), new has 2 npm (npm1, npm2)
2030        diff.filter_by_ecosystem(
2031            &|eco| eco == Some("npm"),
2032            1, // old npm count
2033            2, // new npm count
2034            &BTreeMap::new(),
2035        );
2036
2037        assert_eq!(diff.added.len(), 1); // npm1
2038        assert_eq!(diff.added[0].name, "express");
2039        assert_eq!(diff.removed.len(), 0); // cargo1 was filtered out
2040        assert_eq!(diff.changed.len(), 0);
2041        assert_eq!(diff.old_total, 1);
2042        assert_eq!(diff.new_total, 2);
2043        assert_eq!(diff.unchanged, 1); // npm2
2044    }
2045
2046    #[test]
2047    fn test_filter_by_ecosystem_exclude() {
2048        let mut old = Sbom::default();
2049        let mut new = Sbom::default();
2050
2051        let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
2052        npm1.ecosystem = Some("npm".into());
2053        let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
2054        cargo1.ecosystem = Some("cargo".into());
2055        let mut cargo2 = Component::new("tokio".into(), Some("1.0.0".into()));
2056        cargo2.ecosystem = Some("cargo".into());
2057
2058        old.components.insert(cargo1.id.clone(), cargo1.clone());
2059        new.components.insert(npm1.id.clone(), npm1);
2060        new.components.insert(cargo2.id.clone(), cargo2);
2061
2062        // exclude npm: should only see cargo changes
2063        let mut diff = Differ::diff(&old, &new, None);
2064        diff.filter_by_ecosystem(
2065            &|eco| eco != Some("npm"),
2066            1, // old non-npm count
2067            1, // new non-npm count
2068            &BTreeMap::new(),
2069        );
2070
2071        assert_eq!(diff.added.len(), 1); // cargo2
2072        assert_eq!(diff.added[0].name, "tokio");
2073        assert_eq!(diff.removed.len(), 1); // cargo1
2074        assert_eq!(diff.removed[0].name, "serde");
2075    }
2076
2077    #[test]
2078    fn test_filter_by_ecosystem_unknown() {
2079        // components without ecosystem are treated as "unknown"
2080        let old = Sbom::default();
2081        let mut new = Sbom::default();
2082
2083        let no_eco = Component::new("mystery".into(), Some("1.0".into()));
2084        let mut npm = Component::new("express".into(), Some("4.18.0".into()));
2085        npm.ecosystem = Some("npm".into());
2086
2087        new.components.insert(no_eco.id.clone(), no_eco);
2088        new.components.insert(npm.id.clone(), npm);
2089
2090        let mut diff = Differ::diff(&old, &new, None);
2091
2092        // include "unknown" - should keep only the component without ecosystem
2093        diff.filter_by_ecosystem(
2094            &|eco| eco.is_none(),
2095            0,
2096            1, // one component without ecosystem in new
2097            &BTreeMap::new(),
2098        );
2099
2100        assert_eq!(diff.added.len(), 1);
2101        assert_eq!(diff.added[0].name, "mystery");
2102    }
2103
2104    #[test]
2105    fn test_filter_by_ecosystem_changed_uses_new_ecosystem() {
2106        // when old has no ecosystem but new gained one (e.g. purl added),
2107        // they match by name and the change uses the new component's ecosystem.
2108        let mut old = Sbom::default();
2109        let mut new = Sbom::default();
2110
2111        // old: no ecosystem (wildcard match)
2112        let c_old = Component::new("pkg".into(), Some("1.0".into()));
2113        // new: gains npm ecosystem + version bump
2114        let mut c_new = Component::new("pkg".into(), Some("2.0".into()));
2115        c_new.ecosystem = Some("npm".into());
2116
2117        old.components.insert(c_old.id.clone(), c_old);
2118        new.components.insert(c_new.id.clone(), c_new);
2119
2120        let mut diff = Differ::diff(&old, &new, None);
2121        assert_eq!(diff.changed.len(), 1);
2122
2123        // filter to npm: should keep the changed component (new ecosystem is npm)
2124        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 0, 1, &BTreeMap::new());
2125        assert_eq!(diff.changed.len(), 1);
2126
2127        // filter to cargo: should exclude (new ecosystem is npm, not cargo)
2128        let old2 = {
2129            let mut s = Sbom::default();
2130            let c = Component::new("pkg".into(), Some("1.0".into()));
2131            s.components.insert(c.id.clone(), c);
2132            s
2133        };
2134        let new2 = {
2135            let mut s = Sbom::default();
2136            let mut c = Component::new("pkg".into(), Some("2.0".into()));
2137            c.ecosystem = Some("npm".into());
2138            s.components.insert(c.id.clone(), c);
2139            s
2140        };
2141        let mut diff = Differ::diff(&old2, &new2, None);
2142        diff.filter_by_ecosystem(&|eco| eco == Some("cargo"), 0, 0, &BTreeMap::new());
2143        assert_eq!(diff.changed.len(), 0);
2144    }
2145
2146    #[test]
2147    fn test_filter_by_ecosystem_empty_diff() {
2148        let mut diff = Diff::default();
2149        diff.filter_by_ecosystem(&|_| true, 0, 0, &BTreeMap::new());
2150        assert!(diff.is_empty());
2151    }
2152
2153    #[test]
2154    fn test_filter_by_ecosystem_totals_adjusted() {
2155        let mut old = Sbom::default();
2156        let mut new = Sbom::default();
2157
2158        // old: 2 npm, 1 cargo
2159        let mut n1 = Component::new("a".into(), Some("1".into()));
2160        n1.ecosystem = Some("npm".into());
2161        let mut n2 = Component::new("b".into(), Some("1".into()));
2162        n2.ecosystem = Some("npm".into());
2163        let mut c1 = Component::new("c".into(), Some("1".into()));
2164        c1.ecosystem = Some("cargo".into());
2165
2166        old.components.insert(n1.id.clone(), n1.clone());
2167        old.components.insert(n2.id.clone(), n2.clone());
2168        old.components.insert(c1.id.clone(), c1);
2169
2170        // new: same 2 npm (unchanged), no cargo
2171        new.components.insert(n1.id.clone(), n1);
2172        new.components.insert(n2.id.clone(), n2);
2173
2174        let mut diff = Differ::diff(&old, &new, None);
2175        assert_eq!(diff.old_total, 3);
2176        assert_eq!(diff.new_total, 2);
2177
2178        // filter to npm only
2179        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2180
2181        assert_eq!(diff.old_total, 2);
2182        assert_eq!(diff.new_total, 2);
2183        assert_eq!(diff.unchanged, 2);
2184        assert_eq!(diff.added.len(), 0);
2185        assert_eq!(diff.removed.len(), 0);
2186        assert_eq!(diff.changed.len(), 0);
2187    }
2188
2189    #[test]
2190    fn test_filter_by_ecosystem_matched_pair_changes_ecosystem() {
2191        // a matched pair (same name+version → same hash id) whose ecosystem
2192        // goes npm → None because its purl was dropped. two differently-schemed
2193        // purls would get distinct ids and diff as add+remove, so a dropped (or
2194        // added) purl — ecosystem Some↔None — is the reachable way a *matched*
2195        // pair straddles the ecosystem filter.
2196        let mut old = Sbom::default();
2197        let mut new = Sbom::default();
2198
2199        // migrator: matched pair whose purl is dropped, so ecosystem npm -> None.
2200        let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2201        mig_old.ecosystem = Some("npm".into());
2202        let mut mig_new = Component::new("migrator".into(), Some("1.0".into()));
2203        mig_new.ecosystem = None; // purl dropped on the new side
2204        assert_eq!(mig_old.id, mig_new.id, "same name+version share a hash id");
2205
2206        // left-pad: genuinely unchanged npm pair.
2207        let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2208        lp.ecosystem = Some("npm".into());
2209
2210        old.components.insert(mig_old.id.clone(), mig_old);
2211        old.components.insert(lp.id.clone(), lp.clone());
2212        new.components.insert(mig_new.id.clone(), mig_new);
2213        new.components.insert(lp.id.clone(), lp);
2214
2215        let mut diff = Differ::diff(&old, &new, None);
2216        assert_eq!(diff.changed.len(), 1); // migrator (ecosystem npm -> None)
2217        assert_eq!(diff.unchanged, 1); // left-pad
2218
2219        // filter to npm: old side has 2 npm (migrator-old, left-pad),
2220        // new side has 1 npm (left-pad only; migrator-new has no ecosystem).
2221        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 1, &BTreeMap::new());
2222
2223        assert_eq!(diff.changed.len(), 0); // migrator dropped (new has no ecosystem)
2224        assert_eq!(diff.added.len(), 0);
2225        assert_eq!(diff.removed.len(), 0);
2226        assert_eq!(diff.unchanged, 1); // only left-pad remains on the npm side
2227        assert_eq!(diff.new_total, 1);
2228        // the core invariant: unchanged must never exceed new_total.
2229        assert!(
2230            diff.unchanged <= diff.new_total,
2231            "unchanged ({}) exceeds new_total ({})",
2232            diff.unchanged,
2233            diff.new_total
2234        );
2235    }
2236
2237    #[test]
2238    fn test_filter_by_ecosystem_no_ecosystem_change_unaffected() {
2239        // control: with no pair crossing the ecosystem boundary, the new-side
2240        // derivation of `unchanged` agrees with the old-side one.
2241        let mut old = Sbom::default();
2242        let mut new = Sbom::default();
2243
2244        // migrator: version bump but stays npm (changed pair, npm both sides).
2245        let mut mig_old = Component::new("migrator".into(), Some("1.0".into()));
2246        mig_old.ecosystem = Some("npm".into());
2247        let mut mig_new = Component::new("migrator".into(), Some("1.1".into()));
2248        mig_new.ecosystem = Some("npm".into());
2249
2250        // left-pad: genuinely unchanged npm pair.
2251        let mut lp = Component::new("left-pad".into(), Some("2.0".into()));
2252        lp.ecosystem = Some("npm".into());
2253
2254        old.components.insert(mig_old.id.clone(), mig_old);
2255        old.components.insert(lp.id.clone(), lp.clone());
2256        new.components.insert(mig_new.id.clone(), mig_new);
2257        new.components.insert(lp.id.clone(), lp);
2258
2259        let mut diff = Differ::diff(&old, &new, None);
2260        assert_eq!(diff.changed.len(), 1);
2261
2262        // both old and new npm totals are 2 (nothing crosses the boundary).
2263        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
2264
2265        assert_eq!(diff.changed.len(), 1);
2266        assert_eq!(diff.unchanged, 1); // left-pad; identical to old-side derivation
2267        assert_eq!(diff.new_total, 2);
2268        assert!(diff.unchanged <= diff.new_total);
2269    }
2270
2271    #[test]
2272    fn test_filter_by_ecosystem_filters_edge_diffs() {
2273        let mut old = Sbom::default();
2274        let mut new = Sbom::default();
2275
2276        // npm parent with an edge change
2277        let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2278        npm_parent.ecosystem = Some("npm".into());
2279        let npm_child_old = Component::new("npm-dep-old".into(), Some("1.0".into()));
2280        let npm_child_new = Component::new("npm-dep-new".into(), Some("1.0".into()));
2281
2282        // cargo parent with an edge change
2283        let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2284        cargo_parent.ecosystem = Some("cargo".into());
2285        let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2286
2287        // old: both parents, npm-dep-old as child of npm-app
2288        old.components
2289            .insert(npm_parent.id.clone(), npm_parent.clone());
2290        old.components
2291            .insert(npm_child_old.id.clone(), npm_child_old.clone());
2292        old.components
2293            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2294
2295        // new: both parents, npm-dep-new replaces npm-dep-old, cargo gets new dep
2296        new.components
2297            .insert(npm_parent.id.clone(), npm_parent.clone());
2298        new.components
2299            .insert(npm_child_new.id.clone(), npm_child_new.clone());
2300        new.components
2301            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2302        new.components
2303            .insert(cargo_child.id.clone(), cargo_child.clone());
2304
2305        old.dependencies.insert(
2306            npm_parent.id.clone(),
2307            BTreeMap::from([(npm_child_old.id.clone(), DependencyKind::Runtime)]),
2308        );
2309        new.dependencies.insert(
2310            npm_parent.id.clone(),
2311            BTreeMap::from([(npm_child_new.id.clone(), DependencyKind::Runtime)]),
2312        );
2313        new.dependencies.insert(
2314            cargo_parent.id.clone(),
2315            BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2316        );
2317
2318        // build ecosystem map
2319        let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2320        for (id, comp) in old.components.iter().chain(new.components.iter()) {
2321            eco_map.insert(id.clone(), comp.ecosystem.clone());
2322        }
2323
2324        let mut diff = Differ::diff(&old, &new, None);
2325        // before filtering: should have edge diffs for both ecosystems
2326        assert!(diff.edge_diffs.len() >= 2);
2327
2328        // filter to npm only
2329        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2330
2331        // should only have the npm parent's edge diff
2332        assert_eq!(diff.edge_diffs.len(), 1);
2333        assert_eq!(diff.edge_diffs[0].parent, npm_parent.id);
2334    }
2335
2336    #[test]
2337    fn test_filter_by_ecosystem_prunes_component_names() {
2338        let mut old = Sbom::default();
2339        let mut new = Sbom::default();
2340
2341        // npm parent (hash-based IDs → entries in component_names)
2342        let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2343        npm_parent.ecosystem = Some("npm".into());
2344        let npm_child = Component::new("npm-dep".into(), Some("1.0".into()));
2345
2346        // cargo parent
2347        let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2348        cargo_parent.ecosystem = Some("cargo".into());
2349        let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2350
2351        old.components
2352            .insert(npm_parent.id.clone(), npm_parent.clone());
2353        old.components
2354            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2355
2356        new.components
2357            .insert(npm_parent.id.clone(), npm_parent.clone());
2358        new.components
2359            .insert(npm_child.id.clone(), npm_child.clone());
2360        new.components
2361            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2362        new.components
2363            .insert(cargo_child.id.clone(), cargo_child.clone());
2364
2365        new.dependencies.insert(
2366            npm_parent.id.clone(),
2367            BTreeMap::from([(npm_child.id.clone(), DependencyKind::Runtime)]),
2368        );
2369        new.dependencies.insert(
2370            cargo_parent.id.clone(),
2371            BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2372        );
2373
2374        let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2375        for (id, comp) in old.components.iter().chain(new.components.iter()) {
2376            eco_map.insert(id.clone(), comp.ecosystem.clone());
2377        }
2378
2379        let mut diff = Differ::diff(&old, &new, None);
2380        let names_before = diff.component_names.len();
2381        assert!(names_before > 0, "should have component names for hash IDs");
2382
2383        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2384
2385        // component_names should not contain IDs only from the cargo edge diff
2386        assert!(diff.component_names.len() <= names_before);
2387        for id in diff.component_names.keys() {
2388            // every remaining name should be referenced by a remaining edge diff
2389            let referenced = diff.edge_diffs.iter().any(|e| {
2390                &e.parent == id
2391                    || e.added.contains_key(id)
2392                    || e.removed.contains_key(id)
2393                    || e.kind_changed.contains_key(id)
2394            });
2395            assert!(referenced, "stale component_name entry for {}", id);
2396        }
2397    }
2398
2399    #[test]
2400    fn test_diff_owned_identity() {
2401        let mut sbom = Sbom::default();
2402
2403        // build a non-trivial SBOM with varied component fields
2404        let mut parent = Component::new("my-app".to_string(), Some("2.0.0".to_string()));
2405        parent.purl = Some("pkg:cargo/my-app@2.0.0".to_string());
2406        parent.ecosystem = Some("cargo".to_string());
2407        parent.licenses.insert("MIT".into());
2408        parent.supplier = Some("Acme Corp".into());
2409        parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
2410
2411        let mut dep_a = Component::new("dep-a".to_string(), Some("1.0.0".to_string()));
2412        dep_a.purl = Some("pkg:cargo/dep-a@1.0.0".to_string());
2413        dep_a.ecosystem = Some("cargo".to_string());
2414        dep_a.licenses.insert("Apache-2.0".into());
2415        dep_a
2416            .hashes
2417            .insert("sha256".into(), "abcdef1234567890".into());
2418        dep_a.id = ComponentId::new(dep_a.purl.as_deref(), &[]);
2419
2420        let mut dep_b = Component::new("dep-b".to_string(), Some("0.5.0".to_string()));
2421        dep_b.ecosystem = Some("cargo".to_string());
2422        dep_b.description = Some("A helper library".into());
2423
2424        sbom.components.insert(parent.id.clone(), parent.clone());
2425        sbom.components.insert(dep_a.id.clone(), dep_a.clone());
2426        sbom.components.insert(dep_b.id.clone(), dep_b.clone());
2427
2428        // add dependency edges: parent -> dep-a (runtime), parent -> dep-b (dev)
2429        sbom.dependencies
2430            .entry(parent.id.clone())
2431            .or_default()
2432            .insert(dep_a.id.clone(), DependencyKind::Runtime);
2433        sbom.dependencies
2434            .entry(parent.id.clone())
2435            .or_default()
2436            .insert(dep_b.id.clone(), DependencyKind::Dev);
2437
2438        let copy = sbom.clone();
2439        let diff = Differ::diff_owned(sbom, copy, None);
2440
2441        assert_eq!(
2442            diff.added.len(),
2443            0,
2444            "identical SBOMs should have no added components"
2445        );
2446        assert_eq!(
2447            diff.removed.len(),
2448            0,
2449            "identical SBOMs should have no removed components"
2450        );
2451        assert_eq!(
2452            diff.changed.len(),
2453            0,
2454            "identical SBOMs should have no changed components"
2455        );
2456        assert_eq!(
2457            diff.edge_diffs.len(),
2458            0,
2459            "identical SBOMs should have no edge diffs"
2460        );
2461        assert_eq!(
2462            diff.metadata_changed, None,
2463            "identical SBOMs should have no metadata changes"
2464        );
2465        assert_eq!(diff.old_total, 3);
2466        assert_eq!(diff.new_total, 3);
2467        assert_eq!(diff.unchanged, 3);
2468    }
2469
2470    #[test]
2471    fn test_diff_detects_version_downgrade() {
2472        let mut old = Sbom::default();
2473        let mut new = Sbom::default();
2474
2475        let c1 = Component::new("pkg-a".to_string(), Some("2.0.0".to_string()));
2476        let mut c2 = c1.clone();
2477        c2.version = Some("1.0.0".to_string());
2478
2479        old.components.insert(c1.id.clone(), c1);
2480        new.components.insert(c2.id.clone(), c2);
2481
2482        let diff = Differ::diff(&old, &new, None);
2483        assert_eq!(diff.changed.len(), 1);
2484        assert!(diff.changed[0].is_downgrade);
2485    }
2486
2487    #[test]
2488    fn test_diff_upgrade_not_marked_as_downgrade() {
2489        let mut old = Sbom::default();
2490        let mut new = Sbom::default();
2491
2492        let c1 = Component::new("pkg-a".to_string(), Some("1.0.0".to_string()));
2493        let mut c2 = c1.clone();
2494        c2.version = Some("2.0.0".to_string());
2495
2496        old.components.insert(c1.id.clone(), c1);
2497        new.components.insert(c2.id.clone(), c2);
2498
2499        let diff = Differ::diff(&old, &new, None);
2500        assert_eq!(diff.changed.len(), 1);
2501        assert!(!diff.changed[0].is_downgrade);
2502    }
2503
2504    #[test]
2505    fn deb_revision_upgrade_is_not_a_downgrade() {
2506        for (old_ver, new_ver) in [
2507            ("1.2.3-1ubuntu2", "1.2.3-2"),
2508            ("1.2.3-1build1", "1.2.3-2"),
2509            ("1.2.3-1+deb11u1", "1.2.3-1+deb11u2"),
2510            ("1.0~rc1", "1.0"),
2511            ("1.0", "1.0-1"),
2512        ] {
2513            let old = sbom_of(vec![deb_component("libfoo", old_ver)]);
2514            let new = sbom_of(vec![deb_component("libfoo", new_ver)]);
2515
2516            let diff = Differ::diff(&old, &new, None);
2517            assert_eq!(diff.changed.len(), 1, "{old_ver} -> {new_ver}");
2518            assert!(
2519                !diff.changed[0].is_downgrade,
2520                "{old_ver} -> {new_ver} is an upgrade per dpkg"
2521            );
2522        }
2523    }
2524
2525    #[test]
2526    fn deb_revision_downgrade_is_still_flagged() {
2527        for (old_ver, new_ver) in [
2528            ("1.2.3-2", "1.2.3-1ubuntu2"),
2529            ("1.0", "1.0~rc1"),
2530            ("2:1.0-1", "1:9.0-1"),
2531        ] {
2532            let old = sbom_of(vec![deb_component("libfoo", old_ver)]);
2533            let new = sbom_of(vec![deb_component("libfoo", new_ver)]);
2534
2535            let diff = Differ::diff(&old, &new, None);
2536            assert_eq!(diff.changed.len(), 1, "{old_ver} -> {new_ver}");
2537            assert!(
2538                diff.changed[0].is_downgrade,
2539                "{old_ver} -> {new_ver} is a downgrade per dpkg"
2540            );
2541        }
2542    }
2543
2544    #[test]
2545    fn npm_prerelease_downgrade_is_unaffected() {
2546        let old = sbom_of(vec![npm_component("libfoo", "1.0.0")]);
2547        let new = sbom_of(vec![npm_component("libfoo", "1.0.0-alpha.1")]);
2548
2549        let diff = Differ::diff(&old, &new, None);
2550        assert_eq!(diff.changed.len(), 1);
2551        assert!(diff.changed[0].is_downgrade);
2552    }
2553
2554    #[test]
2555    fn deb_versions_align_by_dpkg_order() {
2556        let versions =
2557            |vs: [&str; 2]| sbom_of(vs.iter().map(|v| deb_component("libfoo", v)).collect());
2558        let old = versions(["1.2.3-1ubuntu2", "2.0.0-1"]);
2559        let new = versions(["1.2.3-2", "2.0.1-1"]);
2560
2561        let diff = Differ::diff(&old, &new, None);
2562        assert_eq!(
2563            version_pairs(&diff),
2564            expect_pairs(&[("1.2.3-1ubuntu2", "1.2.3-2"), ("2.0.0-1", "2.0.1-1")])
2565        );
2566        assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2567    }
2568
2569    #[test]
2570    fn pair_ecosystem_needs_the_two_sides_to_agree() {
2571        let deb = deb_component("libfoo", "1.0");
2572        let npm = npm_component("libfoo", "1.0");
2573        let plain = plain_component("libfoo", "1.0");
2574
2575        assert_eq!(pair_ecosystem(&deb, &deb), Some("deb"));
2576        assert_eq!(pair_ecosystem(&deb, &plain), Some("deb"));
2577        assert_eq!(pair_ecosystem(&plain, &deb), Some("deb"));
2578        assert_eq!(pair_ecosystem(&deb, &npm), None);
2579        assert_eq!(pair_ecosystem(&plain, &plain), None);
2580    }
2581
2582    #[test]
2583    fn a_pair_whose_ecosystem_changed_keeps_the_string_only_reading() {
2584        let mut old = npm_component("libfoo", "1.2.3-1ubuntu2");
2585        let mut new = deb_component("libfoo", "1.2.3-2");
2586        old.id = ComponentId::new(None, &[("name", "libfoo")]);
2587        new.id = old.id.clone();
2588
2589        let diff = Differ::diff(&sbom_of(vec![old]), &sbom_of(vec![new]), None);
2590        assert_eq!(diff.changed.len(), 1);
2591        assert!(diff.changed[0].is_downgrade);
2592    }
2593
2594    fn deb_component(name: &str, version: &str) -> Component {
2595        purl_component("deb", name, version)
2596    }
2597
2598    fn purl_component(ecosystem: &str, name: &str, version: &str) -> Component {
2599        let purl = format!("pkg:{ecosystem}/{name}@{version}");
2600        let mut comp = Component::new(name.to_string(), Some(version.to_string()));
2601        comp.ecosystem = Some(ecosystem.to_string());
2602        comp.id = ComponentId::new(Some(&purl), &[]);
2603        comp.purl = Some(purl);
2604        comp
2605    }
2606
2607    fn npm_component(name: &str, version: &str) -> Component {
2608        purl_component("npm", name, version)
2609    }
2610
2611    fn plain_component(name: &str, version: &str) -> Component {
2612        Component::new(name.to_string(), Some(version.to_string()))
2613    }
2614
2615    fn sbom_of(components: Vec<Component>) -> Sbom {
2616        let mut sbom = Sbom::default();
2617        for comp in components {
2618            sbom.components.insert(comp.id.clone(), comp);
2619        }
2620        sbom
2621    }
2622
2623    /// the `(old version, new version)` of every changed component, sorted.
2624    fn version_pairs(diff: &Diff) -> Vec<(String, String)> {
2625        let mut pairs: Vec<(String, String)> = diff
2626            .changed
2627            .iter()
2628            .map(|c| {
2629                (
2630                    c.old.version.clone().unwrap_or_default(),
2631                    c.new.version.clone().unwrap_or_default(),
2632                )
2633            })
2634            .collect();
2635        pairs.sort();
2636        pairs
2637    }
2638
2639    fn expect_pairs(expected: &[(&str, &str)]) -> Vec<(String, String)> {
2640        let mut pairs: Vec<(String, String)> = expected
2641            .iter()
2642            .map(|(o, n)| (o.to_string(), n.to_string()))
2643            .collect();
2644        pairs.sort();
2645        pairs
2646    }
2647
2648    #[test]
2649    fn test_identity_reconciliation_pairs_same_version_line() {
2650        for (old_versions, new_versions, expected) in [
2651            (
2652                ["9.0.0", "10.0.0"],
2653                ["9.0.1", "10.0.1"],
2654                [("9.0.0", "9.0.1"), ("10.0.0", "10.0.1")],
2655            ),
2656            (
2657                ["1.0.0", "2.0.0"],
2658                ["1.1.0", "2.1.0"],
2659                [("1.0.0", "1.1.0"), ("2.0.0", "2.1.0")],
2660            ),
2661        ] {
2662            let old = sbom_of(
2663                old_versions
2664                    .iter()
2665                    .map(|v| npm_component("libfoo", v))
2666                    .collect(),
2667            );
2668            let new = sbom_of(
2669                new_versions
2670                    .iter()
2671                    .map(|v| npm_component("libfoo", v))
2672                    .collect(),
2673            );
2674
2675            let diff = Differ::diff(&old, &new, None);
2676            assert_eq!(version_pairs(&diff), expect_pairs(&expected));
2677            assert_eq!(diff.added.len(), 0);
2678            assert_eq!(diff.removed.len(), 0);
2679            assert!(
2680                !diff.changed.iter().any(|c| c.is_downgrade),
2681                "upgrading both version lines must not report a downgrade, got {:?}",
2682                version_pairs(&diff)
2683            );
2684        }
2685    }
2686
2687    #[test]
2688    fn test_identity_reconciliation_without_purls_pairs_same_version_line() {
2689        let versions = |vs: [&str; 3]| {
2690            sbom_of(
2691                vs.iter()
2692                    .map(|v| Component::new("libfoo".to_string(), Some(v.to_string())))
2693                    .collect(),
2694            )
2695        };
2696        let old = versions(["1.0.0", "2.0.0", "3.0.0"]);
2697        let new = versions(["1.0.1", "2.0.1", "3.0.1"]);
2698
2699        let diff = Differ::diff(&old, &new, None);
2700        assert_eq!(
2701            version_pairs(&diff),
2702            expect_pairs(&[("1.0.0", "1.0.1"), ("2.0.0", "2.0.1"), ("3.0.0", "3.0.1")])
2703        );
2704        assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2705    }
2706
2707    #[test]
2708    fn test_identity_reconciliation_more_old_than_new() {
2709        for (survivor, expected) in [("3.0.1", ("3.0.0", "3.0.1")), ("1.0.1", ("1.0.0", "1.0.1"))] {
2710            let old = sbom_of(
2711                ["1.0.0", "2.0.0", "3.0.0"]
2712                    .iter()
2713                    .map(|v| npm_component("libfoo", v))
2714                    .collect(),
2715            );
2716            let new = sbom_of(vec![npm_component("libfoo", survivor)]);
2717
2718            let diff = Differ::diff(&old, &new, None);
2719            assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2720            assert_eq!(diff.removed.len(), 2);
2721            assert_eq!(diff.added.len(), 0);
2722            assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2723        }
2724    }
2725
2726    #[test]
2727    fn test_identity_reconciliation_more_new_than_old() {
2728        for (survivor, others, expected) in [
2729            ("1.0.0", ["1.0.1", "2.0.0", "3.0.0"], ("1.0.0", "1.0.1")),
2730            ("3.0.0", ["1.0.0", "2.0.0", "3.0.1"], ("3.0.0", "3.0.1")),
2731        ] {
2732            let old = sbom_of(vec![npm_component("libfoo", survivor)]);
2733            let new = sbom_of(others.iter().map(|v| npm_component("libfoo", v)).collect());
2734
2735            let diff = Differ::diff(&old, &new, None);
2736            assert_eq!(version_pairs(&diff), expect_pairs(&[expected]));
2737            assert_eq!(diff.added.len(), 2);
2738            assert_eq!(diff.removed.len(), 0);
2739            assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2740        }
2741    }
2742
2743    #[test]
2744    fn test_identity_reconciliation_opaque_versions_stay_deterministic() {
2745        let old = sbom_of(vec![
2746            npm_component("libfoo", "nightly-zeta"),
2747            npm_component("libfoo", "nightly-alpha"),
2748        ]);
2749        let new = sbom_of(vec![
2750            npm_component("libfoo", "nightly-omega"),
2751            npm_component("libfoo", "nightly-beta"),
2752        ]);
2753
2754        let diff = Differ::diff(&old, &new, None);
2755        assert_eq!(
2756            version_pairs(&diff),
2757            expect_pairs(&[
2758                ("nightly-zeta", "nightly-beta"),
2759                ("nightly-alpha", "nightly-omega"),
2760            ])
2761        );
2762        assert_eq!(diff.added.len(), 0);
2763        assert_eq!(diff.removed.len(), 0);
2764    }
2765
2766    /// the bucket must be big enough for std's sort to check its comparator; a
2767    /// handful of candidates cannot trip it.
2768    #[test]
2769    fn test_identity_reconciliation_mixed_version_variants_do_not_panic() {
2770        let deb_and_semver = |new: bool| -> Vec<Component> {
2771            (0..14)
2772                .map(|k| {
2773                    let (i, suffix) = (2 * k + 1, u8::from(new));
2774                    let version = if k % 2 == 0 {
2775                        format!("{i}.0.{suffix}")
2776                    } else {
2777                        format!("{i}:{}.0-{suffix}", i + 1)
2778                    };
2779                    npm_component("libfoo", &version)
2780                })
2781                .collect()
2782        };
2783        let opaque_and_semver = |new: bool| -> Vec<Component> {
2784            (0..14)
2785                .map(|k| {
2786                    let (i, side) = (2 * k + 1, if new { 'b' } else { 'a' });
2787                    let version = if k % 2 == 0 {
2788                        format!("{i}.0.{}", u8::from(new))
2789                    } else {
2790                        format!("{side}{i:07x}deadbeef")
2791                    };
2792                    npm_component("libfoo", &version)
2793                })
2794                .collect()
2795        };
2796
2797        let builders: [&dyn Fn(bool) -> Vec<Component>; 2] = [&deb_and_semver, &opaque_and_semver];
2798        for build in builders {
2799            let old = sbom_of(build(false));
2800            let new = sbom_of(build(true));
2801
2802            let diff = Differ::diff(&old, &new, None);
2803            assert_eq!(diff.changed.len(), 14);
2804            assert_eq!(diff.added.len(), 0);
2805            assert_eq!(diff.removed.len(), 0);
2806        }
2807    }
2808
2809    /// a four-part version and a pre-release are mutually comparable, so the
2810    /// class check passes and only the ordering check can catch this.
2811    #[test]
2812    fn test_identity_reconciliation_prerelease_with_numeric_does_not_panic() {
2813        for groups in [12, 26] {
2814            let old = sbom_of(
2815                (1..=groups)
2816                    .flat_map(|i| {
2817                        [
2818                            npm_component("libfoo", &format!("{i}.2.3")),
2819                            npm_component("libfoo", &format!("{i}.2.3.0")),
2820                        ]
2821                    })
2822                    .collect(),
2823            );
2824            let new = sbom_of(
2825                (1..=groups)
2826                    .map(|i| npm_component("libfoo", &format!("{i}.2.3-rc.1")))
2827                    .collect(),
2828            );
2829
2830            let diff = Differ::diff(&old, &new, None);
2831            assert_eq!(diff.changed.len(), groups);
2832            assert_eq!(diff.added.len(), 0);
2833            assert_eq!(diff.removed.len(), groups);
2834        }
2835    }
2836
2837    #[test]
2838    fn test_identity_reconciliation_ignores_insertion_order() {
2839        for versions in [["9.0.0", "10.0.0"], ["nightly-zeta", "nightly-alpha"]] {
2840            let forward = Differ::diff(
2841                &sbom_of(vec![
2842                    npm_component("libfoo", versions[0]),
2843                    npm_component("libfoo", versions[1]),
2844                ]),
2845                &sbom_of(vec![
2846                    npm_component("libfoo", "4.0.0"),
2847                    npm_component("libfoo", "5.0.0"),
2848                ]),
2849                None,
2850            );
2851            let reversed = Differ::diff(
2852                &sbom_of(vec![
2853                    npm_component("libfoo", versions[1]),
2854                    npm_component("libfoo", versions[0]),
2855                ]),
2856                &sbom_of(vec![
2857                    npm_component("libfoo", "5.0.0"),
2858                    npm_component("libfoo", "4.0.0"),
2859                ]),
2860                None,
2861            );
2862            assert_eq!(version_pairs(&forward), version_pairs(&reversed));
2863        }
2864    }
2865
2866    #[test]
2867    fn test_edge_diff_with_two_versions_of_one_package() {
2868        let child_a = Component::new("child-a".to_string(), Some("1.0.0".to_string()));
2869        let child_b = Component::new("child-b".to_string(), Some("1.0.0".to_string()));
2870
2871        let build = |parent_versions: [&str; 2]| {
2872            let parents = parent_versions.map(|v| npm_component("libfoo", v));
2873            let mut sbom = sbom_of(vec![
2874                parents[0].clone(),
2875                parents[1].clone(),
2876                child_a.clone(),
2877                child_b.clone(),
2878            ]);
2879            for (parent, child) in parents.iter().zip([&child_a, &child_b]) {
2880                sbom.dependencies
2881                    .entry(parent.id.clone())
2882                    .or_default()
2883                    .insert(child.id.clone(), DependencyKind::Runtime);
2884            }
2885            sbom
2886        };
2887
2888        let diff = Differ::diff(&build(["1.0.0", "2.0.0"]), &build(["1.0.1", "2.0.1"]), None);
2889        assert_eq!(
2890            diff.edge_diffs.len(),
2891            0,
2892            "both parents kept their dependency, got {:?}",
2893            diff.edge_diffs
2894        );
2895    }
2896
2897    /// a purl-less re-export drops the ecosystem, so every component takes the
2898    /// wildcard path.
2899    #[test]
2900    fn test_wildcard_reconciliation_pairs_same_version_line() {
2901        for (old_comps, new_comps) in [
2902            (
2903                vec![
2904                    npm_component("libfoo", "1.0.0"),
2905                    npm_component("libfoo", "2.0.0"),
2906                ],
2907                vec![
2908                    plain_component("libfoo", "1.0.0"),
2909                    plain_component("libfoo", "2.0.0"),
2910                ],
2911            ),
2912            (
2913                vec![
2914                    plain_component("libfoo", "1.0.0"),
2915                    plain_component("libfoo", "2.0.0"),
2916                ],
2917                vec![
2918                    npm_component("libfoo", "1.0.0"),
2919                    npm_component("libfoo", "2.0.0"),
2920                ],
2921            ),
2922        ] {
2923            let diff = Differ::diff(&sbom_of(old_comps), &sbom_of(new_comps), None);
2924            assert_eq!(
2925                version_pairs(&diff),
2926                expect_pairs(&[("1.0.0", "1.0.0"), ("2.0.0", "2.0.0")])
2927            );
2928            assert_eq!(diff.added.len(), 0);
2929            assert_eq!(diff.removed.len(), 0);
2930            assert!(
2931                !diff.changed.iter().any(|c| c.is_downgrade),
2932                "re-serializing the same versions must not report a downgrade, got {:?}",
2933                version_pairs(&diff)
2934            );
2935        }
2936    }
2937
2938    #[test]
2939    fn test_wildcard_reconciliation_prefers_version_nearest_candidate() {
2940        let old = sbom_of(vec![
2941            npm_component("libfoo", "2.0.0"),
2942            purl_component("pypi", "libfoo", "1.0.0"),
2943        ]);
2944        let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2945
2946        let diff = Differ::diff(&old, &new, None);
2947        assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "1.0.1")]));
2948        assert_eq!(diff.removed.len(), 1);
2949        assert_eq!(diff.added.len(), 0);
2950        assert!(!diff.changed.iter().any(|c| c.is_downgrade));
2951    }
2952
2953    #[test]
2954    fn test_wildcard_reconciliation_breaks_version_ties_deterministically() {
2955        let old = sbom_of(
2956            ["cargo", "npm", "pypi"]
2957                .iter()
2958                .map(|eco| purl_component(eco, "libfoo", "1.0.0"))
2959                .collect(),
2960        );
2961        let new = sbom_of(vec![plain_component("libfoo", "1.0.1")]);
2962
2963        let diff = Differ::diff(&old, &new, None);
2964        assert_eq!(diff.changed.len(), 1);
2965        assert_eq!(
2966            diff.changed[0].old.id.as_str(),
2967            "pkg:pypi/libfoo@1.0.0",
2968            "equal versions must resolve by merged version order, not ecosystem name"
2969        );
2970        assert_eq!(diff.removed.len(), 2);
2971        assert_eq!(diff.added.len(), 0);
2972    }
2973
2974    #[test]
2975    fn test_exact_ecosystem_match_beats_a_nearer_wildcard() {
2976        let old = sbom_of(vec![
2977            npm_component("libfoo", "1.0.0"),
2978            plain_component("libfoo", "2.0.0"),
2979        ]);
2980        let new = sbom_of(vec![npm_component("libfoo", "2.0.1")]);
2981
2982        let diff = Differ::diff(&old, &new, None);
2983        assert_eq!(version_pairs(&diff), expect_pairs(&[("1.0.0", "2.0.1")]));
2984        assert_eq!(diff.removed.len(), 1);
2985        assert_eq!(diff.added.len(), 0);
2986    }
2987}