Skip to main content

sbom_diff/
lib.rs

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