Skip to main content

sbom_diff/
lib.rs

1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{Component, ComponentId, DependencyKind, Sbom};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7pub mod renderer;
8
9/// structured tracking of document metadata changes between two SBOMs.
10///
11/// instead of a simple boolean, this captures exactly which metadata fields
12/// differ, making it possible to render meaningful output and gate CI on
13/// specific metadata changes.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct MetadataChange {
16    /// timestamp changed: (old, new).
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub timestamp: Option<(Option<String>, Option<String>)>,
19    /// tools changed: (old, new).
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub tools: Option<(Vec<String>, Vec<String>)>,
22    /// authors changed: (old, new).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub authors: Option<(Vec<String>, Vec<String>)>,
25}
26
27impl MetadataChange {
28    /// returns true if no metadata fields actually differ.
29    pub fn is_empty(&self) -> bool {
30        self.timestamp.is_none() && self.tools.is_none() && self.authors.is_none()
31    }
32}
33
34/// per-ecosystem counts of added, removed, and changed components.
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36pub struct EcosystemCounts {
37    pub added: usize,
38    pub removed: usize,
39    pub changed: usize,
40}
41
42/// the result of comparing two SBOMs.
43///
44/// contains lists of added, removed, and changed components,
45/// as well as dependency edge changes.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct Diff {
48    /// components present in the new SBOM but not the old.
49    pub added: Vec<Component>,
50    /// components present in the old SBOM but not the new.
51    pub removed: Vec<Component>,
52    /// components present in both with field-level changes.
53    pub changed: Vec<ComponentChange>,
54    /// dependency edge changes between components.
55    pub edge_diffs: Vec<EdgeDiff>,
56    /// structured metadata change details, or `None` if metadata is unchanged.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub metadata_changed: Option<MetadataChange>,
59    /// total number of components in the old SBOM.
60    pub old_total: usize,
61    /// total number of components in the new SBOM.
62    pub new_total: usize,
63    /// number of components present in both SBOMs with no changes.
64    pub unchanged: usize,
65    /// human-readable display names for component IDs that appear in edge diffs.
66    ///
67    /// maps hash-based IDs (`h:...`) to `name@version` or `name` so that edge
68    /// diff output is readable without cross-referencing the full component list.
69    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70    pub component_names: BTreeMap<ComponentId, String>,
71}
72
73impl Diff {
74    /// returns `true` if the diff contains no changes of any kind.
75    pub fn is_empty(&self) -> bool {
76        self.added.is_empty()
77            && self.removed.is_empty()
78            && self.changed.is_empty()
79            && self.edge_diffs.is_empty()
80            && self.metadata_changed.is_none()
81    }
82
83    /// returns a human-readable display name for a component ID.
84    ///
85    /// looks up the ID in `component_names`; falls back to the raw ID string.
86    pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
87        self.component_names
88            .get(id)
89            .map(String::as_str)
90            .unwrap_or_else(|| id.as_str())
91    }
92
93    /// groups added/removed/changed counts by package ecosystem.
94    ///
95    /// components without an ecosystem are grouped under `"unknown"`.
96    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
97        let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
98
99        for comp in &self.added {
100            let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
101            breakdown.entry(eco).or_default().added += 1;
102        }
103
104        for comp in &self.removed {
105            let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
106            breakdown.entry(eco).or_default().removed += 1;
107        }
108
109        for change in &self.changed {
110            let eco = change
111                .new
112                .ecosystem
113                .as_deref()
114                .unwrap_or("unknown")
115                .to_string();
116            breakdown.entry(eco).or_default().changed += 1;
117        }
118
119        breakdown
120    }
121
122    /// groups the full diff by ecosystem, returning per-ecosystem slices.
123    ///
124    /// components without an ecosystem are grouped under `"unknown"`.
125    /// this clones components out of the diff; use
126    /// [`into_group_by_ecosystem`](Self::into_group_by_ecosystem) to move
127    /// them instead when you own the diff.
128    pub fn group_by_ecosystem(&self) -> GroupedDiff {
129        group_components_by_ecosystem(
130            self.added.iter().cloned(),
131            self.removed.iter().cloned(),
132            self.changed.iter().cloned(),
133            self.edge_diffs.clone(),
134            self.metadata_changed.clone(),
135        )
136    }
137
138    /// consuming variant of [`group_by_ecosystem`](Self::group_by_ecosystem)
139    /// that moves components instead of cloning them.
140    pub fn into_group_by_ecosystem(self) -> GroupedDiff {
141        group_components_by_ecosystem(
142            self.added,
143            self.removed,
144            self.changed,
145            self.edge_diffs,
146            self.metadata_changed,
147        )
148    }
149
150    /// filters the diff to only include components whose ecosystem matches
151    /// the given predicate. Adjusts `old_total`, `new_total`, and `unchanged`
152    /// to reflect the filtered view.
153    ///
154    /// `filtered_old_total` and `filtered_new_total` are the pre-counted
155    /// number of components in each SBOM that pass the predicate. These must
156    /// be computed before [`Differ::diff_owned`] consumes the SBOMs.
157    ///
158    /// `component_ecosystems` maps component IDs to their ecosystem, built
159    /// from both SBOMs before they are consumed. This is used to filter
160    /// edge diffs by the parent component's ecosystem.
161    pub fn filter_by_ecosystem<F: Fn(Option<&str>) -> bool>(
162        &mut self,
163        matches: &F,
164        filtered_old_total: usize,
165        filtered_new_total: usize,
166        component_ecosystems: &BTreeMap<ComponentId, Option<String>>,
167    ) {
168        self.added.retain(|c| matches(c.ecosystem.as_deref()));
169        self.removed.retain(|c| matches(c.ecosystem.as_deref()));
170        self.changed.retain(|c| matches(c.new.ecosystem.as_deref()));
171
172        // filter edge diffs by parent ecosystem; keep edges whose parent is
173        // unknown (not in the map) as a conservative default.
174        self.edge_diffs.retain(|edge| {
175            component_ecosystems
176                .get(&edge.parent)
177                .map(|eco| matches(eco.as_deref()))
178                .unwrap_or(true)
179        });
180
181        // prune component_names to only IDs still referenced in edge diffs
182        let mut referenced_ids = BTreeSet::new();
183        for edge in &self.edge_diffs {
184            referenced_ids.insert(&edge.parent);
185            referenced_ids.extend(edge.added.keys());
186            referenced_ids.extend(edge.removed.keys());
187            referenced_ids.extend(edge.kind_changed.keys());
188        }
189        self.component_names
190            .retain(|id, _| referenced_ids.contains(id));
191
192        self.old_total = filtered_old_total;
193        self.new_total = filtered_new_total;
194        // unchanged = matched_filtered - changed
195        // matched_filtered = old_total_filtered - removed_filtered
196        self.unchanged = filtered_old_total
197            .saturating_sub(self.removed.len())
198            .saturating_sub(self.changed.len());
199    }
200}
201
202/// shared implementation for [`Diff::group_by_ecosystem`] and
203/// [`Diff::into_group_by_ecosystem`]. Accepts owned iterators so both the
204/// cloning and consuming callers can share the same loop logic.
205fn group_components_by_ecosystem(
206    added: impl IntoIterator<Item = Component>,
207    removed: impl IntoIterator<Item = Component>,
208    changed: impl IntoIterator<Item = ComponentChange>,
209    edge_diffs: Vec<EdgeDiff>,
210    metadata_changed: Option<MetadataChange>,
211) -> GroupedDiff {
212    let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
213
214    for c in added {
215        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
216        ecosystems.entry(eco).or_default().added.push(c);
217    }
218    for c in removed {
219        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
220        ecosystems.entry(eco).or_default().removed.push(c);
221    }
222    for c in changed {
223        let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
224        ecosystems.entry(eco).or_default().changed.push(c);
225    }
226
227    GroupedDiff {
228        by_ecosystem: ecosystems,
229        edge_diffs,
230        metadata_changed,
231    }
232}
233
234/// diff grouped by package ecosystem.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct GroupedDiff {
237    pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
238    pub edge_diffs: Vec<EdgeDiff>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub metadata_changed: Option<MetadataChange>,
241}
242
243impl GroupedDiff {
244    /// derives per-ecosystem counts from the already-grouped data.
245    ///
246    /// this avoids a redundant traversal when both grouped components and
247    /// counts are needed — call [`Diff::group_by_ecosystem`] once, then
248    /// derive counts from the result.
249    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
250        self.by_ecosystem
251            .iter()
252            .map(|(eco, eco_diff)| {
253                (
254                    eco.clone(),
255                    EcosystemCounts {
256                        added: eco_diff.added.len(),
257                        removed: eco_diff.removed.len(),
258                        changed: eco_diff.changed.len(),
259                    },
260                )
261            })
262            .collect()
263    }
264}
265
266/// per-ecosystem slice of added, removed, and changed components.
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
268pub struct EcosystemDiff {
269    pub added: Vec<Component>,
270    pub removed: Vec<Component>,
271    pub changed: Vec<ComponentChange>,
272}
273
274/// a component that exists in both SBOMs with detected changes.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ComponentChange {
277    /// the component identifier (from the new SBOM).
278    pub id: ComponentId,
279    /// the component as it appeared in the old SBOM.
280    pub old: Component,
281    /// the component as it appears in the new SBOM.
282    pub new: Component,
283    /// list of specific field changes detected.
284    pub changes: Vec<FieldChange>,
285}
286
287/// a dependency edge change for a single parent component.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct EdgeDiff {
290    /// the parent component whose dependencies changed.
291    pub parent: ComponentId,
292    /// dependencies added in the new SBOM, with their dependency kind.
293    pub added: BTreeMap<ComponentId, DependencyKind>,
294    /// dependencies removed from the old SBOM, with their dependency kind.
295    pub removed: BTreeMap<ComponentId, DependencyKind>,
296    /// dependencies whose kind changed between old and new (old_kind, new_kind).
297    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
298    pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
299}
300
301/// a specific field that changed between two versions of a component.
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303pub enum FieldChange {
304    /// version changed: (old, new).
305    Version(Option<String>, Option<String>),
306    /// licenses changed: (old, new).
307    License(BTreeSet<String>, BTreeSet<String>),
308    /// supplier changed: (old, new).
309    Supplier(Option<String>, Option<String>),
310    /// package URL changed: (old, new).
311    Purl(Option<String>, Option<String>),
312    /// description changed: (old, new).
313    Description(Option<String>, Option<String>),
314    /// hashes changed: (old, new).
315    Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
316    /// ecosystem changed: (old, new).
317    Ecosystem(Option<String>, Option<String>),
318}
319
320/// fields that can be compared and filtered.
321///
322/// use with [`Differ::diff`] to limit comparison to specific fields.
323#[derive(Debug, Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
324pub enum Field {
325    /// package version.
326    Version,
327    /// license identifiers.
328    License,
329    /// supplier/publisher.
330    Supplier,
331    /// package URL.
332    Purl,
333    /// human-readable description.
334    Description,
335    /// checksums.
336    Hashes,
337    /// package ecosystem.
338    Ecosystem,
339    /// dependency edges.
340    Deps,
341}
342
343/// SBOM comparison engine.
344///
345/// compares two SBOMs and produces a [`Diff`] describing the changes.
346/// components are matched first by ID (purl), then by identity (name + ecosystem).
347pub struct Differ;
348
349impl Differ {
350    /// compares two SBOMs and returns the differences.
351    ///
352    /// both SBOMs are normalized before comparison to ignore irrelevant differences
353    /// like ordering or metadata timestamps. This method clones both SBOMs
354    /// internally; use [`diff_owned`](Self::diff_owned) to avoid cloning when
355    /// you already own the SBOMs.
356    ///
357    /// # Arguments
358    ///
359    /// * `old` - The baseline SBOM
360    /// * `new` - The SBOM to compare against the baseline
361    /// * `only` - Optional filter to limit comparison to specific fields
362    ///
363    /// # Example
364    ///
365    /// ```
366    /// use sbom_diff::{Differ, Field};
367    /// use sbom_model::Sbom;
368    ///
369    /// let old = Sbom::default();
370    /// let new = Sbom::default();
371    ///
372    /// // Compare all fields
373    /// let diff = Differ::diff(&old, &new, None);
374    ///
375    /// // Compare only version and license changes
376    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
377    /// ```
378    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
379        Self::diff_owned(old.clone(), new.clone(), only)
380    }
381
382    /// consuming variant of [`diff`](Self::diff) that normalizes in place,
383    /// avoiding two full SBOM clones.
384    pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
385        // compare metadata before normalize() strips volatile fields
386        let metadata_changed = {
387            let mut mc = MetadataChange {
388                timestamp: None,
389                tools: None,
390                authors: None,
391            };
392            if old.metadata.timestamp != new.metadata.timestamp {
393                mc.timestamp = Some((
394                    old.metadata.timestamp.clone(),
395                    new.metadata.timestamp.clone(),
396                ));
397            }
398            if old.metadata.tools != new.metadata.tools {
399                mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
400            }
401            if old.metadata.authors != new.metadata.authors {
402                mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
403            }
404            if mc.is_empty() {
405                None
406            } else {
407                Some(mc)
408            }
409        };
410
411        old.normalize();
412        new.normalize();
413
414        let mut added = Vec::new();
415        let mut removed = Vec::new();
416        let mut changed = Vec::new();
417
418        let mut processed_old = HashSet::new();
419        let mut processed_new = HashSet::new();
420
421        // track old_id -> new_id mappings for edge reconciliation
422        let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
423
424        // 1. Match by ID
425        for (id, new_comp) in &new.components {
426            if let Some(old_comp) = old.components.get(id) {
427                processed_old.insert(id.clone());
428                processed_new.insert(id.clone());
429                id_mapping.insert(id.clone(), id.clone());
430
431                if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
432                    changed.push(change);
433                }
434            }
435        }
436
437        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
438        // when purls are absent or change, we match by (ecosystem, name).
439        // if either ecosystem is None, we treat it as a wildcard and match by name alone.
440        //
441        // the map is keyed by name, then by ecosystem, so the wildcard lookup
442        // (case 3: new has no ecosystem → match any old with same name) is
443        // O(k) where k is the number of distinct ecosystems sharing that name,
444        // rather than a linear scan of the entire map.
445        let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
446            BTreeMap::new();
447        for (id, comp) in &old.components {
448            if !processed_old.contains(id) {
449                old_identity_map
450                    .entry(comp.name.clone())
451                    .or_default()
452                    .entry(comp.ecosystem.clone())
453                    .or_default()
454                    .push(id.clone());
455            }
456        }
457
458        for (id, new_comp) in &new.components {
459            if processed_new.contains(id) {
460                continue;
461            }
462
463            // try to find a matching old component:
464            // 1. Exact match on (ecosystem, name)
465            // 2. If new has ecosystem but no exact match, try old with None ecosystem (same name)
466            // 3. If new has no ecosystem, try any old with same name
467            let matched_old_id = old_identity_map
468                .get_mut(&new_comp.name)
469                .and_then(|eco_map| {
470                    // case 1: exact match on (ecosystem, name)
471                    eco_map
472                        .get_mut(&new_comp.ecosystem)
473                        .and_then(|ids| ids.pop())
474                        .or_else(|| {
475                            if new_comp.ecosystem.is_some() {
476                                // case 2: new has ecosystem, try old with None ecosystem
477                                eco_map.get_mut(&None).and_then(|ids| ids.pop())
478                            } else {
479                                // case 3: new has no ecosystem, try any old with same name
480                                eco_map.values_mut().find_map(|ids| ids.pop())
481                            }
482                        })
483                });
484
485            if let Some(old_id) = matched_old_id {
486                if let Some(old_comp) = old.components.get(&old_id) {
487                    processed_old.insert(old_id.clone());
488                    processed_new.insert(id.clone());
489                    id_mapping.insert(old_id.clone(), id.clone());
490
491                    if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
492                        changed.push(change);
493                    }
494                    continue;
495                }
496            }
497
498            added.push(new_comp.clone());
499            processed_new.insert(id.clone());
500        }
501
502        for (id, old_comp) in &old.components {
503            if !processed_old.contains(id) {
504                removed.push(old_comp.clone());
505            }
506        }
507
508        // 3. Compute totals
509        let old_total = old.components.len();
510        let new_total = new.components.len();
511        // matched = all components in the old SBOM that were paired with a new one
512        let matched = processed_old.len();
513        let unchanged = matched - changed.len();
514
515        // 4. Compute edge diffs (dependency graph changes)
516        let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
517        let edge_diffs = if should_include_deps {
518            Self::compute_edge_diffs(&old, &new, &id_mapping)
519        } else {
520            Vec::new()
521        };
522
523        // 5. Build human-readable name map for hash-based IDs in edge diffs
524        let component_names = Self::build_component_names(&old, &new, &edge_diffs);
525
526        Diff {
527            added,
528            removed,
529            changed,
530            edge_diffs,
531            metadata_changed,
532            old_total,
533            new_total,
534            unchanged,
535            component_names,
536        }
537    }
538
539    /// computes dependency edge differences between two SBOMs.
540    ///
541    /// uses the id_mapping to translate old component IDs to new IDs when
542    /// components were matched by identity rather than exact ID match.
543    /// tracks dependency kind for added/removed edges and detects kind changes
544    /// (e.g. a dependency moving from dev to runtime).
545    fn compute_edge_diffs(
546        old: &Sbom,
547        new: &Sbom,
548        id_mapping: &BTreeMap<ComponentId, ComponentId>,
549    ) -> Vec<EdgeDiff> {
550        let mut edge_diffs = Vec::new();
551
552        // build reverse mapping (new_id -> old_id) once upfront for O(1) lookups.
553        // the forward id_mapping is old_id -> new_id; we need the inverse for
554        // translating new parent IDs back to old parent IDs.
555        let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
556            .iter()
557            .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
558            .collect();
559
560        // helper to translate old ID to new ID (if mapped) or keep as-is
561        let translate_id = |old_id: &ComponentId| -> ComponentId {
562            id_mapping
563                .get(old_id)
564                .cloned()
565                .unwrap_or_else(|| old_id.clone())
566        };
567
568        // collect all parent IDs from new SBOM's perspective
569        // we use new IDs as the canonical reference
570        let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
571
572        // also include old parents (translated to new IDs)
573        for old_parent in old.dependencies.keys() {
574            all_parents.insert(translate_id(old_parent));
575        }
576
577        for parent_id in all_parents {
578            // get new dependencies for this parent (child -> kind)
579            let new_children: BTreeMap<ComponentId, DependencyKind> = new
580                .dependencies
581                .get(&parent_id)
582                .cloned()
583                .unwrap_or_default();
584
585            // get old dependencies, translating both parent and child IDs
586            // look up the old parent ID via the reverse map
587            let old_parent_id = reverse_mapping
588                .get(&parent_id)
589                .cloned()
590                .unwrap_or_else(|| parent_id.clone());
591
592            let old_children: BTreeMap<ComponentId, DependencyKind> = old
593                .dependencies
594                .get(&old_parent_id)
595                .map(|children| {
596                    children
597                        .iter()
598                        .map(|(id, kind)| (translate_id(id), *kind))
599                        .collect()
600                })
601                .unwrap_or_default();
602
603            let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
604            let old_keys: BTreeSet<&ComponentId> = old_children.keys().collect();
605
606            // compute added and removed edges with their kinds
607            let added: BTreeMap<ComponentId, DependencyKind> = new_keys
608                .difference(&old_keys)
609                .map(|&id| (id.clone(), new_children[id]))
610                .collect();
611            let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
612                .difference(&new_keys)
613                .map(|&id| (id.clone(), old_children[id]))
614                .collect();
615
616            // detect kind changes for edges that exist in both
617            let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
618                .intersection(&old_keys)
619                .filter_map(|&id| {
620                    let old_kind = old_children[id];
621                    let new_kind = new_children[id];
622                    if old_kind != new_kind {
623                        Some((id.clone(), (old_kind, new_kind)))
624                    } else {
625                        None
626                    }
627                })
628                .collect();
629
630            if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
631                edge_diffs.push(EdgeDiff {
632                    parent: parent_id,
633                    added,
634                    removed,
635                    kind_changed,
636                });
637            }
638        }
639
640        edge_diffs
641    }
642
643    /// builds a human-readable display name map for component IDs in edge diffs.
644    ///
645    /// only includes entries for hash-based IDs (`h:...`) since purl-based IDs
646    /// are already human-readable. Looks up component names from both SBOMs.
647    fn build_component_names(
648        old: &Sbom,
649        new: &Sbom,
650        edge_diffs: &[EdgeDiff],
651    ) -> BTreeMap<ComponentId, String> {
652        let mut names = BTreeMap::new();
653
654        // collect all IDs that appear in edge diffs
655        let mut ids = BTreeSet::new();
656        for edge in edge_diffs {
657            ids.insert(&edge.parent);
658            ids.extend(edge.added.keys());
659            ids.extend(edge.removed.keys());
660            ids.extend(edge.kind_changed.keys());
661        }
662
663        // only resolve hash-based IDs — purls are already readable
664        for id in ids {
665            if !id.as_str().starts_with("h:") {
666                continue;
667            }
668
669            // try new SBOM first (edge diffs use new-SBOM IDs), then old
670            let comp = new.components.get(id).or_else(|| old.components.get(id));
671            if let Some(comp) = comp {
672                let display = match &comp.version {
673                    Some(v) => format!("{}@{}", comp.name, v),
674                    None => comp.name.clone(),
675                };
676                names.insert(id.clone(), display);
677            }
678        }
679
680        names
681    }
682
683    fn compute_change(
684        old: &Component,
685        new: &Component,
686        only: Option<&[Field]>,
687    ) -> Option<ComponentChange> {
688        let mut changes = Vec::new();
689
690        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
691
692        if should_include(Field::Version) && old.version != new.version {
693            changes.push(FieldChange::Version(
694                old.version.clone(),
695                new.version.clone(),
696            ));
697        }
698
699        if should_include(Field::License) && old.licenses != new.licenses {
700            changes.push(FieldChange::License(
701                old.licenses.clone(),
702                new.licenses.clone(),
703            ));
704        }
705
706        if should_include(Field::Supplier) && old.supplier != new.supplier {
707            changes.push(FieldChange::Supplier(
708                old.supplier.clone(),
709                new.supplier.clone(),
710            ));
711        }
712
713        if should_include(Field::Purl) && old.purl != new.purl {
714            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
715        }
716
717        if should_include(Field::Description) && old.description != new.description {
718            changes.push(FieldChange::Description(
719                old.description.clone(),
720                new.description.clone(),
721            ));
722        }
723
724        if should_include(Field::Hashes) && old.hashes != new.hashes {
725            changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
726        }
727
728        if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
729            changes.push(FieldChange::Ecosystem(
730                old.ecosystem.clone(),
731                new.ecosystem.clone(),
732            ));
733        }
734
735        if changes.is_empty() {
736            None
737        } else {
738            Some(ComponentChange {
739                id: new.id.clone(),
740                old: old.clone(),
741                new: new.clone(),
742                changes,
743            })
744        }
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn test_diff_added_removed() {
754        let mut old = Sbom::default();
755        let mut new = Sbom::default();
756
757        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
758        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
759
760        old.components.insert(c1.id.clone(), c1);
761        new.components.insert(c2.id.clone(), c2);
762
763        let diff = Differ::diff(&old, &new, None);
764        assert_eq!(diff.added.len(), 1);
765        assert_eq!(diff.removed.len(), 1);
766        assert_eq!(diff.changed.len(), 0);
767    }
768
769    #[test]
770    fn test_diff_changed() {
771        let mut old = Sbom::default();
772        let mut new = Sbom::default();
773
774        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
775        let mut c2 = c1.clone();
776        c2.version = Some("1.1".to_string());
777
778        old.components.insert(c1.id.clone(), c1);
779        new.components.insert(c2.id.clone(), c2);
780
781        let diff = Differ::diff(&old, &new, None);
782        assert_eq!(diff.added.len(), 0);
783        assert_eq!(diff.removed.len(), 0);
784        assert_eq!(diff.changed.len(), 1);
785        assert!(matches!(
786            diff.changed[0].changes[0],
787            FieldChange::Version(_, _)
788        ));
789    }
790
791    #[test]
792    fn test_diff_identity_reconciliation() {
793        let mut old = Sbom::default();
794        let mut new = Sbom::default();
795
796        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
797        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
798
799        old.components.insert(c1.id.clone(), c1);
800        new.components.insert(c2.id.clone(), c2);
801
802        let diff = Differ::diff(&old, &new, None);
803        assert_eq!(diff.changed.len(), 1);
804        assert_eq!(diff.added.len(), 0);
805    }
806
807    #[test]
808    fn test_diff_license_change() {
809        let mut old = Sbom::default();
810        let mut new = Sbom::default();
811
812        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
813        c1.licenses.insert("MIT".into());
814        let mut c2 = c1.clone();
815        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
816
817        old.components.insert(c1.id.clone(), c1);
818        new.components.insert(c2.id.clone(), c2);
819
820        let diff = Differ::diff(&old, &new, None);
821        assert_eq!(diff.changed.len(), 1);
822        assert!(diff.changed[0]
823            .changes
824            .iter()
825            .any(|c| matches!(c, FieldChange::License(_, _))));
826    }
827
828    #[test]
829    fn test_diff_supplier_change() {
830        let mut old = Sbom::default();
831        let mut new = Sbom::default();
832
833        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
834        c1.supplier = Some("Acme Corp".into());
835        let mut c2 = c1.clone();
836        c2.supplier = Some("New Corp".into());
837
838        old.components.insert(c1.id.clone(), c1);
839        new.components.insert(c2.id.clone(), c2);
840
841        let diff = Differ::diff(&old, &new, None);
842        assert_eq!(diff.changed.len(), 1);
843        assert!(diff.changed[0]
844            .changes
845            .iter()
846            .any(|c| matches!(c, FieldChange::Supplier(_, _))));
847    }
848
849    #[test]
850    fn test_diff_hashes_change() {
851        let mut old = Sbom::default();
852        let mut new = Sbom::default();
853
854        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
855        c1.hashes.insert("sha256".into(), "aaa".into());
856        let mut c2 = c1.clone();
857        c2.hashes.insert("sha256".into(), "bbb".into());
858
859        old.components.insert(c1.id.clone(), c1);
860        new.components.insert(c2.id.clone(), c2);
861
862        let diff = Differ::diff(&old, &new, None);
863        assert_eq!(diff.changed.len(), 1);
864        assert!(diff.changed[0]
865            .changes
866            .iter()
867            .any(|c| matches!(c, FieldChange::Hashes(_, _))));
868    }
869
870    #[test]
871    fn test_diff_description_change() {
872        let mut old = Sbom::default();
873        let mut new = Sbom::default();
874
875        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
876        c1.description = Some("Old description".into());
877        let mut c2 = c1.clone();
878        c2.description = Some("New description".into());
879
880        old.components.insert(c1.id.clone(), c1);
881        new.components.insert(c2.id.clone(), c2);
882
883        let diff = Differ::diff(&old, &new, None);
884        assert_eq!(diff.changed.len(), 1);
885        assert!(diff.changed[0]
886            .changes
887            .iter()
888            .any(|c| matches!(c, FieldChange::Description(_, _))));
889    }
890
891    #[test]
892    fn test_diff_description_added() {
893        let mut old = Sbom::default();
894        let mut new = Sbom::default();
895
896        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
897        let mut c2 = c1.clone();
898        c2.description = Some("A new description".into());
899
900        old.components.insert(c1.id.clone(), c1);
901        new.components.insert(c2.id.clone(), c2);
902
903        let diff = Differ::diff(&old, &new, None);
904        assert_eq!(diff.changed.len(), 1);
905        assert!(diff.changed[0]
906            .changes
907            .iter()
908            .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
909    }
910
911    #[test]
912    fn test_diff_description_removed() {
913        let mut old = Sbom::default();
914        let mut new = Sbom::default();
915
916        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
917        c1.description = Some("Had a description".into());
918        let mut c2 = c1.clone();
919        c2.description = None;
920
921        old.components.insert(c1.id.clone(), c1);
922        new.components.insert(c2.id.clone(), c2);
923
924        let diff = Differ::diff(&old, &new, None);
925        assert_eq!(diff.changed.len(), 1);
926        assert!(diff.changed[0]
927            .changes
928            .iter()
929            .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
930    }
931
932    #[test]
933    fn test_diff_description_unchanged() {
934        let mut old = Sbom::default();
935        let mut new = Sbom::default();
936
937        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
938        c1.description = Some("Same description".into());
939        let c2 = c1.clone();
940
941        old.components.insert(c1.id.clone(), c1);
942        new.components.insert(c2.id.clone(), c2);
943
944        let diff = Differ::diff(&old, &new, None);
945        assert!(diff.changed.is_empty());
946    }
947
948    #[test]
949    fn test_diff_description_filtering() {
950        let mut old = Sbom::default();
951        let mut new = Sbom::default();
952
953        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
954        c1.description = Some("Old".into());
955        let mut c2 = c1.clone();
956        c2.version = Some("2.0".into());
957        c2.description = Some("New".into());
958
959        old.components.insert(c1.id.clone(), c1);
960        new.components.insert(c2.id.clone(), c2);
961
962        // only description: should see description change but not version
963        let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
964        assert_eq!(diff.changed.len(), 1);
965        assert_eq!(diff.changed[0].changes.len(), 1);
966        assert!(matches!(
967            diff.changed[0].changes[0],
968            FieldChange::Description(_, _)
969        ));
970
971        // only version: should see version change but not description
972        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
973        assert_eq!(diff.changed.len(), 1);
974        assert_eq!(diff.changed[0].changes.len(), 1);
975        assert!(matches!(
976            diff.changed[0].changes[0],
977            FieldChange::Version(_, _)
978        ));
979    }
980
981    #[test]
982    fn test_diff_ecosystem_change() {
983        let mut old = Sbom::default();
984        let mut new = Sbom::default();
985
986        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
987        c1.ecosystem = Some("npm".to_string());
988        let mut c2 = c1.clone();
989        c2.ecosystem = Some("cargo".to_string());
990
991        old.components.insert(c1.id.clone(), c1);
992        new.components.insert(c2.id.clone(), c2);
993
994        let diff = Differ::diff(&old, &new, None);
995        assert_eq!(diff.changed.len(), 1);
996        assert_eq!(diff.changed[0].changes.len(), 1);
997        assert!(matches!(
998            diff.changed[0].changes[0],
999            FieldChange::Ecosystem(_, _)
1000        ));
1001
1002        if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
1003            assert_eq!(o.as_deref(), Some("npm"));
1004            assert_eq!(n.as_deref(), Some("cargo"));
1005        }
1006    }
1007
1008    #[test]
1009    fn test_diff_ecosystem_change_from_none() {
1010        let mut old = Sbom::default();
1011        let mut new = Sbom::default();
1012
1013        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1014        let mut c2 = c1.clone();
1015        c2.ecosystem = Some("npm".to_string());
1016
1017        old.components.insert(c1.id.clone(), c1);
1018        new.components.insert(c2.id.clone(), c2);
1019
1020        let diff = Differ::diff(&old, &new, None);
1021        assert_eq!(diff.changed.len(), 1);
1022        assert_eq!(diff.changed[0].changes.len(), 1);
1023        assert!(matches!(
1024            diff.changed[0].changes[0],
1025            FieldChange::Ecosystem(None, Some(_))
1026        ));
1027    }
1028
1029    #[test]
1030    fn test_diff_ecosystem_filtering() {
1031        let mut old = Sbom::default();
1032        let mut new = Sbom::default();
1033
1034        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1035        c1.ecosystem = Some("npm".to_string());
1036        let mut c2 = c1.clone();
1037        c2.version = Some("2.0".into());
1038        c2.ecosystem = Some("cargo".to_string());
1039
1040        old.components.insert(c1.id.clone(), c1);
1041        new.components.insert(c2.id.clone(), c2);
1042
1043        // only ecosystem: should see ecosystem change but not version
1044        let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
1045        assert_eq!(diff.changed.len(), 1);
1046        assert_eq!(diff.changed[0].changes.len(), 1);
1047        assert!(matches!(
1048            diff.changed[0].changes[0],
1049            FieldChange::Ecosystem(_, _)
1050        ));
1051
1052        // only version: should see version change but not ecosystem
1053        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1054        assert_eq!(diff.changed.len(), 1);
1055        assert_eq!(diff.changed[0].changes.len(), 1);
1056        assert!(matches!(
1057            diff.changed[0].changes[0],
1058            FieldChange::Version(_, _)
1059        ));
1060    }
1061
1062    #[test]
1063    fn test_diff_ecosystem_no_change() {
1064        let mut old = Sbom::default();
1065        let mut new = Sbom::default();
1066
1067        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1068        c1.ecosystem = Some("npm".to_string());
1069        let c2 = c1.clone();
1070
1071        old.components.insert(c1.id.clone(), c1);
1072        new.components.insert(c2.id.clone(), c2);
1073
1074        let diff = Differ::diff(&old, &new, None);
1075        assert!(diff.changed.is_empty());
1076    }
1077
1078    #[test]
1079    fn test_diff_multiple_field_changes() {
1080        let mut old = Sbom::default();
1081        let mut new = Sbom::default();
1082
1083        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1084        c1.licenses.insert("MIT".into());
1085        c1.supplier = Some("Old Corp".into());
1086        c1.hashes.insert("sha256".into(), "aaa".into());
1087
1088        let mut c2 = c1.clone();
1089        c2.version = Some("2.0".into());
1090        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1091        c2.supplier = Some("New Corp".into());
1092        c2.hashes.insert("sha256".into(), "bbb".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_eq!(diff.changed[0].changes.len(), 4);
1100    }
1101
1102    #[test]
1103    fn test_diff_no_changes() {
1104        let mut old = Sbom::default();
1105        let mut new = Sbom::default();
1106
1107        let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1108        old.components.insert(c.id.clone(), c.clone());
1109        new.components.insert(c.id.clone(), c);
1110
1111        let diff = Differ::diff(&old, &new, None);
1112        assert!(diff.added.is_empty());
1113        assert!(diff.removed.is_empty());
1114        assert!(diff.changed.is_empty());
1115        assert!(diff.edge_diffs.is_empty());
1116    }
1117
1118    #[test]
1119    fn test_diff_metadata_changed_timestamp() {
1120        let mut old = Sbom::default();
1121        let mut new = Sbom::default();
1122
1123        old.metadata.timestamp = Some("2024-01-01".into());
1124        new.metadata.timestamp = Some("2024-01-02".into());
1125
1126        let diff = Differ::diff(&old, &new, None);
1127        let mc = diff.metadata_changed.as_ref().unwrap();
1128        assert_eq!(
1129            mc.timestamp,
1130            Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1131        );
1132        assert!(mc.tools.is_none());
1133        assert!(mc.authors.is_none());
1134        assert!(!diff.is_empty());
1135    }
1136
1137    #[test]
1138    fn test_diff_metadata_changed_tools() {
1139        let mut old = Sbom::default();
1140        let mut new = Sbom::default();
1141
1142        old.metadata.tools = vec!["syft".into()];
1143        new.metadata.tools = vec!["trivy".into()];
1144
1145        let diff = Differ::diff(&old, &new, None);
1146        let mc = diff.metadata_changed.as_ref().unwrap();
1147        assert!(mc.timestamp.is_none());
1148        assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1149        assert!(mc.authors.is_none());
1150    }
1151
1152    #[test]
1153    fn test_diff_metadata_changed_authors() {
1154        let mut old = Sbom::default();
1155        let mut new = Sbom::default();
1156
1157        old.metadata.authors = vec!["alice".into()];
1158        new.metadata.authors = vec!["bob".into()];
1159
1160        let diff = Differ::diff(&old, &new, None);
1161        let mc = diff.metadata_changed.as_ref().unwrap();
1162        assert!(mc.timestamp.is_none());
1163        assert!(mc.tools.is_none());
1164        assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1165    }
1166
1167    #[test]
1168    fn test_diff_metadata_unchanged() {
1169        let mut old = Sbom::default();
1170        let mut new = Sbom::default();
1171
1172        old.metadata.timestamp = Some("2024-01-01".into());
1173        new.metadata.timestamp = Some("2024-01-01".into());
1174        old.metadata.tools = vec!["syft".into()];
1175        new.metadata.tools = vec!["syft".into()];
1176
1177        let diff = Differ::diff(&old, &new, None);
1178        assert!(diff.metadata_changed.is_none());
1179    }
1180
1181    #[test]
1182    fn test_diff_metadata_changed_multiple_fields() {
1183        let mut old = Sbom::default();
1184        let mut new = Sbom::default();
1185
1186        old.metadata.timestamp = Some("2024-01-01".into());
1187        new.metadata.timestamp = Some("2024-01-02".into());
1188        old.metadata.tools = vec!["syft".into()];
1189        new.metadata.tools = vec!["trivy".into()];
1190        old.metadata.authors = vec!["alice".into()];
1191        new.metadata.authors = vec!["bob".into()];
1192
1193        let diff = Differ::diff(&old, &new, None);
1194        let mc = diff.metadata_changed.as_ref().unwrap();
1195        assert!(mc.timestamp.is_some());
1196        assert!(mc.tools.is_some());
1197        assert!(mc.authors.is_some());
1198    }
1199
1200    #[test]
1201    fn test_diff_filtering() {
1202        let mut old = Sbom::default();
1203        let mut new = Sbom::default();
1204
1205        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1206        c1.licenses.insert("MIT".into());
1207
1208        let mut c2 = c1.clone();
1209        c2.version = Some("1.1".to_string());
1210        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1211
1212        old.components.insert(c1.id.clone(), c1);
1213        new.components.insert(c2.id.clone(), c2);
1214
1215        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1216        assert_eq!(diff.changed.len(), 1);
1217        assert_eq!(diff.changed[0].changes.len(), 1);
1218        assert!(matches!(
1219            diff.changed[0].changes[0],
1220            FieldChange::Version(_, _)
1221        ));
1222    }
1223
1224    #[test]
1225    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1226        // component with purl in old, different purl in new (same ecosystem+name)
1227        // should be treated as a CHANGE with Purl field change, not add/remove
1228        let mut old = Sbom::default();
1229        let mut new = Sbom::default();
1230
1231        // old: lodash with one purl
1232        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1233        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1234        c_old.ecosystem = Some("npm".to_string());
1235        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1236
1237        // new: lodash with updated purl (version bump)
1238        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1239        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1240        c_new.ecosystem = Some("npm".to_string());
1241        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1242
1243        old.components.insert(c_old.id.clone(), c_old);
1244        new.components.insert(c_new.id.clone(), c_new);
1245
1246        let diff = Differ::diff(&old, &new, None);
1247
1248        // should NOT be add/remove
1249        assert_eq!(diff.added.len(), 0, "Should not have added components");
1250        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1251
1252        // should be a change
1253        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1254
1255        // should include both Version and Purl changes
1256        let changes = &diff.changed[0].changes;
1257        assert!(changes
1258            .iter()
1259            .any(|c| matches!(c, FieldChange::Version(_, _))));
1260        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1261    }
1262
1263    #[test]
1264    fn test_purl_removed_is_change() {
1265        // component with purl in old, no purl in new (same name)
1266        // this is realistic: old SBOM from tool that adds purls, new from tool that doesn't
1267        let mut old = Sbom::default();
1268        let mut new = Sbom::default();
1269
1270        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1271        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1272        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
1273        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1274
1275        // new component without purl - ecosystem is None (realistic!)
1276        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1277        c_new.purl = None;
1278        c_new.ecosystem = None; // No purl means no ecosystem extraction
1279                                // ID will be hash-based since no purl
1280        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1281
1282        old.components.insert(c_old.id.clone(), c_old);
1283        new.components.insert(c_new.id.clone(), c_new);
1284
1285        let diff = Differ::diff(&old, &new, None);
1286
1287        assert_eq!(diff.added.len(), 0, "Should not have added components");
1288        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1289        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1290
1291        // should have Purl change
1292        assert!(diff.changed[0]
1293            .changes
1294            .iter()
1295            .any(|c| matches!(c, FieldChange::Purl(_, _))));
1296    }
1297
1298    #[test]
1299    fn test_purl_added_is_change() {
1300        // component with no purl in old, purl in new
1301        // this is realistic: old SBOM without purls, new from better tooling
1302        let mut old = Sbom::default();
1303        let mut new = Sbom::default();
1304
1305        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1306        c_old.purl = None;
1307        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
1308        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1309
1310        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1311        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1312        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
1313        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1314
1315        old.components.insert(c_old.id.clone(), c_old);
1316        new.components.insert(c_new.id.clone(), c_new);
1317
1318        let diff = Differ::diff(&old, &new, None);
1319
1320        assert_eq!(diff.added.len(), 0, "Should not have added components");
1321        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1322        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1323    }
1324
1325    #[test]
1326    fn test_same_name_different_ecosystems_not_matched() {
1327        // two components with same name but different ecosystems should NOT match
1328        let mut old = Sbom::default();
1329        let mut new = Sbom::default();
1330
1331        // old: "utils" from npm
1332        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1333        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1334        c_old.ecosystem = Some("npm".to_string());
1335        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1336
1337        // new: "utils" from pypi (different ecosystem!)
1338        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1339        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1340        c_new.ecosystem = Some("pypi".to_string());
1341        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1342
1343        old.components.insert(c_old.id.clone(), c_old);
1344        new.components.insert(c_new.id.clone(), c_new);
1345
1346        let diff = Differ::diff(&old, &new, None);
1347
1348        // should be separate add/remove, NOT a change
1349        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1350        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1351        assert_eq!(
1352            diff.changed.len(),
1353            0,
1354            "Should not match different ecosystems"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_same_name_both_no_ecosystem_matched() {
1360        // components with same name and both having None ecosystem should match
1361        // (backwards compatibility for SBOMs without purls)
1362        let mut old = Sbom::default();
1363        let mut new = Sbom::default();
1364
1365        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1366        c_old.ecosystem = None;
1367
1368        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1369        c_new.ecosystem = None;
1370
1371        old.components.insert(c_old.id.clone(), c_old);
1372        new.components.insert(c_new.id.clone(), c_new);
1373
1374        let diff = Differ::diff(&old, &new, None);
1375
1376        assert_eq!(diff.added.len(), 0);
1377        assert_eq!(diff.removed.len(), 0);
1378        assert_eq!(
1379            diff.changed.len(),
1380            1,
1381            "Same name with None ecosystems should match"
1382        );
1383    }
1384
1385    #[test]
1386    fn test_edge_diff_added_removed() {
1387        let mut old = Sbom::default();
1388        let mut new = Sbom::default();
1389
1390        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1391        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1392        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1393
1394        let parent_id = c1.id.clone();
1395        let child_a_id = c2.id.clone();
1396        let child_b_id = c3.id.clone();
1397
1398        // add all components to both SBOMs
1399        old.components.insert(c1.id.clone(), c1.clone());
1400        old.components.insert(c2.id.clone(), c2.clone());
1401        old.components.insert(c3.id.clone(), c3.clone());
1402
1403        new.components.insert(c1.id.clone(), c1);
1404        new.components.insert(c2.id.clone(), c2);
1405        new.components.insert(c3.id.clone(), c3);
1406
1407        // old: parent -> child-a
1408        old.dependencies
1409            .entry(parent_id.clone())
1410            .or_default()
1411            .insert(child_a_id.clone(), DependencyKind::Runtime);
1412
1413        // new: parent -> child-b (removed child-a, added child-b)
1414        new.dependencies
1415            .entry(parent_id.clone())
1416            .or_default()
1417            .insert(child_b_id.clone(), DependencyKind::Runtime);
1418
1419        let diff = Differ::diff(&old, &new, None);
1420
1421        assert_eq!(diff.edge_diffs.len(), 1);
1422        assert_eq!(diff.edge_diffs[0].parent, parent_id);
1423        assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1424        assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1425    }
1426
1427    #[test]
1428    fn test_edge_diff_with_identity_reconciliation() {
1429        // test that edge diffs work when components are matched by identity
1430        // (different IDs but same name/ecosystem)
1431        let mut old = Sbom::default();
1432        let mut new = Sbom::default();
1433
1434        // parent with purl in old
1435        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1436        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1437        parent_old.ecosystem = Some("npm".to_string());
1438        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1439
1440        // parent with different purl in new (same name/ecosystem)
1441        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1442        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1443        parent_new.ecosystem = Some("npm".to_string());
1444        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1445
1446        // child component (same in both)
1447        let child = Component::new("child".to_string(), Some("1.0".to_string()));
1448
1449        old.components
1450            .insert(parent_old.id.clone(), parent_old.clone());
1451        old.components.insert(child.id.clone(), child.clone());
1452
1453        new.components
1454            .insert(parent_new.id.clone(), parent_new.clone());
1455        new.components.insert(child.id.clone(), child.clone());
1456
1457        // old: parent -> child
1458        old.dependencies
1459            .entry(parent_old.id.clone())
1460            .or_default()
1461            .insert(child.id.clone(), DependencyKind::Runtime);
1462
1463        // new: parent -> child (same edge, but parent has different ID)
1464        new.dependencies
1465            .entry(parent_new.id.clone())
1466            .or_default()
1467            .insert(child.id.clone(), DependencyKind::Runtime);
1468
1469        let diff = Differ::diff(&old, &new, None);
1470
1471        // components should be matched by identity, so no spurious edge changes
1472        // (the edge parent->child exists in both, just under different parent IDs)
1473        assert_eq!(
1474            diff.edge_diffs.len(),
1475            0,
1476            "No edge changes expected when parent is reconciled by identity"
1477        );
1478    }
1479
1480    #[test]
1481    fn test_edge_diff_filtering() {
1482        // test that --only filtering excludes edge diffs when deps not included
1483        let mut old = Sbom::default();
1484        let mut new = Sbom::default();
1485
1486        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1487        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1488
1489        let parent_id = c1.id.clone();
1490        let child_id = c2.id.clone();
1491
1492        old.components.insert(c1.id.clone(), c1.clone());
1493        old.components.insert(c2.id.clone(), c2.clone());
1494
1495        new.components.insert(c1.id.clone(), c1);
1496        new.components.insert(c2.id.clone(), c2);
1497
1498        // new has an edge that old doesn't
1499        new.dependencies
1500            .entry(parent_id.clone())
1501            .or_default()
1502            .insert(child_id, DependencyKind::Runtime);
1503
1504        // without filtering - should have edge diff
1505        let diff = Differ::diff(&old, &new, None);
1506        assert_eq!(diff.edge_diffs.len(), 1);
1507
1508        // with filtering to only Version - should NOT have edge diff
1509        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1510        assert_eq!(diff_filtered.edge_diffs.len(), 0);
1511
1512        // with filtering to include Deps - should have edge diff
1513        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1514        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1515    }
1516
1517    #[test]
1518    fn test_ecosystem_breakdown() {
1519        let mut old = Sbom::default();
1520        let mut new = Sbom::default();
1521
1522        // npm component in old only (removed)
1523        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1524        c1.ecosystem = Some("npm".into());
1525        old.components.insert(c1.id.clone(), c1);
1526
1527        // npm component in new only (added)
1528        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1529        c2.ecosystem = Some("npm".into());
1530        new.components.insert(c2.id.clone(), c2);
1531
1532        // cargo component in new only (added)
1533        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1534        c3.ecosystem = Some("cargo".into());
1535        new.components.insert(c3.id.clone(), c3);
1536
1537        // npm component changed (present in both, different version)
1538        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1539        c4_old.ecosystem = Some("npm".into());
1540        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1541        c4_new.ecosystem = Some("npm".into());
1542        old.components.insert(c4_old.id.clone(), c4_old);
1543        new.components.insert(c4_new.id.clone(), c4_new);
1544
1545        // component with no ecosystem (added)
1546        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1547        new.components.insert(c5.id.clone(), c5);
1548
1549        let diff = Differ::diff(&old, &new, None);
1550        let breakdown = diff.ecosystem_breakdown();
1551
1552        let npm = breakdown.get("npm").unwrap();
1553        assert_eq!(npm.added, 1);
1554        assert_eq!(npm.removed, 1);
1555        assert_eq!(npm.changed, 1);
1556
1557        let cargo = breakdown.get("cargo").unwrap();
1558        assert_eq!(cargo.added, 1);
1559        assert_eq!(cargo.removed, 0);
1560        assert_eq!(cargo.changed, 0);
1561
1562        let unknown = breakdown.get("unknown").unwrap();
1563        assert_eq!(unknown.added, 1);
1564        assert_eq!(unknown.removed, 0);
1565        assert_eq!(unknown.changed, 0);
1566    }
1567
1568    #[test]
1569    fn test_ecosystem_breakdown_empty_diff() {
1570        let old = Sbom::default();
1571        let new = Sbom::default();
1572
1573        let diff = Differ::diff(&old, &new, None);
1574        assert!(diff.is_empty());
1575        assert!(diff.ecosystem_breakdown().is_empty());
1576    }
1577
1578    #[test]
1579    fn test_group_by_ecosystem_empty_diff() {
1580        let old = Sbom::default();
1581        let new = Sbom::default();
1582
1583        let diff = Differ::diff(&old, &new, None);
1584        let grouped = diff.group_by_ecosystem();
1585        assert!(grouped.by_ecosystem.is_empty());
1586        assert!(grouped.edge_diffs.is_empty());
1587        assert!(grouped.metadata_changed.is_none());
1588        assert!(grouped.ecosystem_breakdown().is_empty());
1589    }
1590
1591    #[test]
1592    fn test_group_by_ecosystem_groups_correctly() {
1593        let mut old = Sbom::default();
1594        let mut new = Sbom::default();
1595
1596        // npm removed
1597        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1598        c1.ecosystem = Some("npm".into());
1599        old.components.insert(c1.id.clone(), c1);
1600
1601        // npm added
1602        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1603        c2.ecosystem = Some("npm".into());
1604        new.components.insert(c2.id.clone(), c2);
1605
1606        // cargo added
1607        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1608        c3.ecosystem = Some("cargo".into());
1609        new.components.insert(c3.id.clone(), c3);
1610
1611        // npm changed
1612        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1613        c4_old.ecosystem = Some("npm".into());
1614        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1615        c4_new.ecosystem = Some("npm".into());
1616        old.components.insert(c4_old.id.clone(), c4_old);
1617        new.components.insert(c4_new.id.clone(), c4_new);
1618
1619        // unknown added
1620        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1621        new.components.insert(c5.id.clone(), c5);
1622
1623        let diff = Differ::diff(&old, &new, None);
1624        let grouped = diff.group_by_ecosystem();
1625
1626        let npm = grouped.by_ecosystem.get("npm").unwrap();
1627        assert_eq!(npm.added.len(), 1);
1628        assert_eq!(npm.removed.len(), 1);
1629        assert_eq!(npm.changed.len(), 1);
1630
1631        let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1632        assert_eq!(cargo.added.len(), 1);
1633        assert_eq!(cargo.removed.len(), 0);
1634        assert_eq!(cargo.changed.len(), 0);
1635
1636        let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1637        assert_eq!(unknown.added.len(), 1);
1638        assert_eq!(unknown.removed.len(), 0);
1639        assert_eq!(unknown.changed.len(), 0);
1640
1641        // derived breakdown should match direct breakdown
1642        let grouped_counts = grouped.ecosystem_breakdown();
1643        let direct_counts = diff.ecosystem_breakdown();
1644        assert_eq!(grouped_counts, direct_counts);
1645    }
1646
1647    #[test]
1648    fn test_totals_no_changes() {
1649        let mut old = Sbom::default();
1650        let mut new = Sbom::default();
1651
1652        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1653        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1654
1655        old.components.insert(c1.id.clone(), c1.clone());
1656        old.components.insert(c2.id.clone(), c2.clone());
1657        new.components.insert(c1.id.clone(), c1);
1658        new.components.insert(c2.id.clone(), c2);
1659
1660        let diff = Differ::diff(&old, &new, None);
1661        assert_eq!(diff.old_total, 2);
1662        assert_eq!(diff.new_total, 2);
1663        assert_eq!(diff.unchanged, 2);
1664    }
1665
1666    #[test]
1667    fn test_totals_with_changes() {
1668        let mut old = Sbom::default();
1669        let mut new = Sbom::default();
1670
1671        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1672        let mut c1_updated = c1.clone();
1673        c1_updated.version = Some("1.1".to_string());
1674        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1675        let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1676        let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1677
1678        old.components.insert(c1.id.clone(), c1);
1679        old.components.insert(c2.id.clone(), c2.clone());
1680        old.components.insert(c3.id.clone(), c3);
1681        new.components.insert(c1_updated.id.clone(), c1_updated);
1682        new.components.insert(c2.id.clone(), c2);
1683        new.components.insert(c4.id.clone(), c4);
1684
1685        let diff = Differ::diff(&old, &new, None);
1686        assert_eq!(diff.old_total, 3);
1687        assert_eq!(diff.new_total, 3);
1688        assert_eq!(diff.added.len(), 1); // c4
1689        assert_eq!(diff.removed.len(), 1); // c3
1690        assert_eq!(diff.changed.len(), 1); // c1
1691        assert_eq!(diff.unchanged, 1); // c2
1692    }
1693
1694    #[test]
1695    fn test_component_names_for_hash_ids_in_edge_diffs() {
1696        let mut old = Sbom::default();
1697        let mut new = Sbom::default();
1698
1699        // components without purls → hash-based IDs
1700        let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1701        let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1702        let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1703
1704        old.components.insert(parent.id.clone(), parent.clone());
1705        old.components.insert(child_a.id.clone(), child_a.clone());
1706        new.components.insert(parent.id.clone(), parent.clone());
1707        new.components.insert(child_b.id.clone(), child_b.clone());
1708
1709        // set up edges: old parent -> child_a, new parent -> child_b
1710        old.dependencies.insert(
1711            parent.id.clone(),
1712            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1713        );
1714        new.dependencies.insert(
1715            parent.id.clone(),
1716            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1717        );
1718
1719        let diff = Differ::diff(&old, &new, None);
1720
1721        // all IDs in edge diffs should be hash-based (no purls)
1722        assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1723
1724        // component_names should resolve all hash IDs to readable names
1725        assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1726        for added in diff.edge_diffs[0].added.keys() {
1727            assert!(!diff.display_name(added).starts_with("h:"));
1728        }
1729        for removed in diff.edge_diffs[0].removed.keys() {
1730            assert!(!diff.display_name(removed).starts_with("h:"));
1731        }
1732    }
1733
1734    #[test]
1735    fn test_component_names_skips_purl_ids() {
1736        let mut old = Sbom::default();
1737        let mut new = Sbom::default();
1738
1739        let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1740        parent.purl = Some("pkg:npm/parent@1.0".to_string());
1741        parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1742
1743        let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1744        child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1745        child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1746
1747        let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1748        child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1749        child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1750
1751        old.components.insert(parent.id.clone(), parent.clone());
1752        old.components.insert(child_a.id.clone(), child_a.clone());
1753        new.components.insert(parent.id.clone(), parent.clone());
1754        new.components.insert(child_b.id.clone(), child_b.clone());
1755
1756        old.dependencies.insert(
1757            parent.id.clone(),
1758            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1759        );
1760        new.dependencies.insert(
1761            parent.id.clone(),
1762            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1763        );
1764
1765        let diff = Differ::diff(&old, &new, None);
1766
1767        // component_names should be empty — all IDs are purl-based
1768        assert!(diff.component_names.is_empty());
1769
1770        // display_name should fall back to the purl-based ID string
1771        assert!(diff
1772            .display_name(&diff.edge_diffs[0].parent)
1773            .starts_with("pkg:npm/parent@"));
1774    }
1775
1776    #[test]
1777    fn test_display_name_fallback() {
1778        let diff = Diff::default();
1779        let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1780        // no entry in component_names → falls back to raw ID
1781        assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1782    }
1783
1784    // -----------------------------------------------------------------------
1785    // Diff::filter_by_ecosystem
1786    // -----------------------------------------------------------------------
1787
1788    #[test]
1789    fn test_filter_by_ecosystem_include() {
1790        let mut old = Sbom::default();
1791        let mut new = Sbom::default();
1792
1793        // npm components
1794        let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
1795        npm1.ecosystem = Some("npm".into());
1796        let mut npm2 = Component::new("lodash".into(), Some("4.17.21".into()));
1797        npm2.ecosystem = Some("npm".into());
1798
1799        // cargo component
1800        let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
1801        cargo1.ecosystem = Some("cargo".into());
1802
1803        // pypi component
1804        let mut pypi1 = Component::new("requests".into(), Some("2.28.0".into()));
1805        pypi1.ecosystem = Some("pypi".into());
1806
1807        old.components.insert(npm2.id.clone(), npm2.clone());
1808        old.components.insert(cargo1.id.clone(), cargo1.clone());
1809
1810        new.components.insert(npm1.id.clone(), npm1);
1811        new.components.insert(npm2.id.clone(), npm2);
1812        new.components.insert(pypi1.id.clone(), pypi1);
1813
1814        // old has npm2 + cargo1 (2 components)
1815        // new has npm1 + npm2 + pypi1 (3 components)
1816        // npm2 is unchanged, npm1 is added (npm), cargo1 is removed, pypi1 is added (pypi)
1817
1818        let mut diff = Differ::diff(&old, &new, None);
1819
1820        // pre-filtered totals for npm: old has 1 npm (npm2), new has 2 npm (npm1, npm2)
1821        diff.filter_by_ecosystem(
1822            &|eco| eco == Some("npm"),
1823            1, // old npm count
1824            2, // new npm count
1825            &BTreeMap::new(),
1826        );
1827
1828        assert_eq!(diff.added.len(), 1); // npm1
1829        assert_eq!(diff.added[0].name, "express");
1830        assert_eq!(diff.removed.len(), 0); // cargo1 was filtered out
1831        assert_eq!(diff.changed.len(), 0);
1832        assert_eq!(diff.old_total, 1);
1833        assert_eq!(diff.new_total, 2);
1834        assert_eq!(diff.unchanged, 1); // npm2
1835    }
1836
1837    #[test]
1838    fn test_filter_by_ecosystem_exclude() {
1839        let mut old = Sbom::default();
1840        let mut new = Sbom::default();
1841
1842        let mut npm1 = Component::new("express".into(), Some("4.18.0".into()));
1843        npm1.ecosystem = Some("npm".into());
1844        let mut cargo1 = Component::new("serde".into(), Some("1.0.0".into()));
1845        cargo1.ecosystem = Some("cargo".into());
1846        let mut cargo2 = Component::new("tokio".into(), Some("1.0.0".into()));
1847        cargo2.ecosystem = Some("cargo".into());
1848
1849        old.components.insert(cargo1.id.clone(), cargo1.clone());
1850        new.components.insert(npm1.id.clone(), npm1);
1851        new.components.insert(cargo2.id.clone(), cargo2);
1852
1853        // exclude npm: should only see cargo changes
1854        let mut diff = Differ::diff(&old, &new, None);
1855        diff.filter_by_ecosystem(
1856            &|eco| eco != Some("npm"),
1857            1, // old non-npm count
1858            1, // new non-npm count
1859            &BTreeMap::new(),
1860        );
1861
1862        assert_eq!(diff.added.len(), 1); // cargo2
1863        assert_eq!(diff.added[0].name, "tokio");
1864        assert_eq!(diff.removed.len(), 1); // cargo1
1865        assert_eq!(diff.removed[0].name, "serde");
1866    }
1867
1868    #[test]
1869    fn test_filter_by_ecosystem_unknown() {
1870        // components without ecosystem are treated as "unknown"
1871        let old = Sbom::default();
1872        let mut new = Sbom::default();
1873
1874        let no_eco = Component::new("mystery".into(), Some("1.0".into()));
1875        let mut npm = Component::new("express".into(), Some("4.18.0".into()));
1876        npm.ecosystem = Some("npm".into());
1877
1878        new.components.insert(no_eco.id.clone(), no_eco);
1879        new.components.insert(npm.id.clone(), npm);
1880
1881        let mut diff = Differ::diff(&old, &new, None);
1882
1883        // include "unknown" - should keep only the component without ecosystem
1884        diff.filter_by_ecosystem(
1885            &|eco| eco.is_none(),
1886            0,
1887            1, // one component without ecosystem in new
1888            &BTreeMap::new(),
1889        );
1890
1891        assert_eq!(diff.added.len(), 1);
1892        assert_eq!(diff.added[0].name, "mystery");
1893    }
1894
1895    #[test]
1896    fn test_filter_by_ecosystem_changed_uses_new_ecosystem() {
1897        // when old has no ecosystem but new gained one (e.g. purl added),
1898        // they match by name and the change uses the new component's ecosystem.
1899        let mut old = Sbom::default();
1900        let mut new = Sbom::default();
1901
1902        // old: no ecosystem (wildcard match)
1903        let c_old = Component::new("pkg".into(), Some("1.0".into()));
1904        // new: gains npm ecosystem + version bump
1905        let mut c_new = Component::new("pkg".into(), Some("2.0".into()));
1906        c_new.ecosystem = Some("npm".into());
1907
1908        old.components.insert(c_old.id.clone(), c_old);
1909        new.components.insert(c_new.id.clone(), c_new);
1910
1911        let mut diff = Differ::diff(&old, &new, None);
1912        assert_eq!(diff.changed.len(), 1);
1913
1914        // filter to npm: should keep the changed component (new ecosystem is npm)
1915        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 0, 1, &BTreeMap::new());
1916        assert_eq!(diff.changed.len(), 1);
1917
1918        // filter to cargo: should exclude (new ecosystem is npm, not cargo)
1919        let old2 = {
1920            let mut s = Sbom::default();
1921            let c = Component::new("pkg".into(), Some("1.0".into()));
1922            s.components.insert(c.id.clone(), c);
1923            s
1924        };
1925        let new2 = {
1926            let mut s = Sbom::default();
1927            let mut c = Component::new("pkg".into(), Some("2.0".into()));
1928            c.ecosystem = Some("npm".into());
1929            s.components.insert(c.id.clone(), c);
1930            s
1931        };
1932        let mut diff = Differ::diff(&old2, &new2, None);
1933        diff.filter_by_ecosystem(&|eco| eco == Some("cargo"), 0, 0, &BTreeMap::new());
1934        assert_eq!(diff.changed.len(), 0);
1935    }
1936
1937    #[test]
1938    fn test_filter_by_ecosystem_empty_diff() {
1939        let mut diff = Diff::default();
1940        diff.filter_by_ecosystem(&|_| true, 0, 0, &BTreeMap::new());
1941        assert!(diff.is_empty());
1942    }
1943
1944    #[test]
1945    fn test_filter_by_ecosystem_totals_adjusted() {
1946        let mut old = Sbom::default();
1947        let mut new = Sbom::default();
1948
1949        // old: 2 npm, 1 cargo
1950        let mut n1 = Component::new("a".into(), Some("1".into()));
1951        n1.ecosystem = Some("npm".into());
1952        let mut n2 = Component::new("b".into(), Some("1".into()));
1953        n2.ecosystem = Some("npm".into());
1954        let mut c1 = Component::new("c".into(), Some("1".into()));
1955        c1.ecosystem = Some("cargo".into());
1956
1957        old.components.insert(n1.id.clone(), n1.clone());
1958        old.components.insert(n2.id.clone(), n2.clone());
1959        old.components.insert(c1.id.clone(), c1);
1960
1961        // new: same 2 npm (unchanged), no cargo
1962        new.components.insert(n1.id.clone(), n1);
1963        new.components.insert(n2.id.clone(), n2);
1964
1965        let mut diff = Differ::diff(&old, &new, None);
1966        assert_eq!(diff.old_total, 3);
1967        assert_eq!(diff.new_total, 2);
1968
1969        // filter to npm only
1970        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 2, 2, &BTreeMap::new());
1971
1972        assert_eq!(diff.old_total, 2);
1973        assert_eq!(diff.new_total, 2);
1974        assert_eq!(diff.unchanged, 2);
1975        assert_eq!(diff.added.len(), 0);
1976        assert_eq!(diff.removed.len(), 0);
1977        assert_eq!(diff.changed.len(), 0);
1978    }
1979
1980    #[test]
1981    fn test_filter_by_ecosystem_filters_edge_diffs() {
1982        let mut old = Sbom::default();
1983        let mut new = Sbom::default();
1984
1985        // npm parent with an edge change
1986        let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
1987        npm_parent.ecosystem = Some("npm".into());
1988        let npm_child_old = Component::new("npm-dep-old".into(), Some("1.0".into()));
1989        let npm_child_new = Component::new("npm-dep-new".into(), Some("1.0".into()));
1990
1991        // cargo parent with an edge change
1992        let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
1993        cargo_parent.ecosystem = Some("cargo".into());
1994        let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
1995
1996        // old: both parents, npm-dep-old as child of npm-app
1997        old.components
1998            .insert(npm_parent.id.clone(), npm_parent.clone());
1999        old.components
2000            .insert(npm_child_old.id.clone(), npm_child_old.clone());
2001        old.components
2002            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2003
2004        // new: both parents, npm-dep-new replaces npm-dep-old, cargo gets new dep
2005        new.components
2006            .insert(npm_parent.id.clone(), npm_parent.clone());
2007        new.components
2008            .insert(npm_child_new.id.clone(), npm_child_new.clone());
2009        new.components
2010            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2011        new.components
2012            .insert(cargo_child.id.clone(), cargo_child.clone());
2013
2014        old.dependencies.insert(
2015            npm_parent.id.clone(),
2016            BTreeMap::from([(npm_child_old.id.clone(), DependencyKind::Runtime)]),
2017        );
2018        new.dependencies.insert(
2019            npm_parent.id.clone(),
2020            BTreeMap::from([(npm_child_new.id.clone(), DependencyKind::Runtime)]),
2021        );
2022        new.dependencies.insert(
2023            cargo_parent.id.clone(),
2024            BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2025        );
2026
2027        // build ecosystem map
2028        let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2029        for (id, comp) in old.components.iter().chain(new.components.iter()) {
2030            eco_map.insert(id.clone(), comp.ecosystem.clone());
2031        }
2032
2033        let mut diff = Differ::diff(&old, &new, None);
2034        // before filtering: should have edge diffs for both ecosystems
2035        assert!(diff.edge_diffs.len() >= 2);
2036
2037        // filter to npm only
2038        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2039
2040        // should only have the npm parent's edge diff
2041        assert_eq!(diff.edge_diffs.len(), 1);
2042        assert_eq!(diff.edge_diffs[0].parent, npm_parent.id);
2043    }
2044
2045    #[test]
2046    fn test_filter_by_ecosystem_prunes_component_names() {
2047        let mut old = Sbom::default();
2048        let mut new = Sbom::default();
2049
2050        // npm parent (hash-based IDs → entries in component_names)
2051        let mut npm_parent = Component::new("npm-app".into(), Some("1.0".into()));
2052        npm_parent.ecosystem = Some("npm".into());
2053        let npm_child = Component::new("npm-dep".into(), Some("1.0".into()));
2054
2055        // cargo parent
2056        let mut cargo_parent = Component::new("cargo-app".into(), Some("1.0".into()));
2057        cargo_parent.ecosystem = Some("cargo".into());
2058        let cargo_child = Component::new("cargo-dep".into(), Some("1.0".into()));
2059
2060        old.components
2061            .insert(npm_parent.id.clone(), npm_parent.clone());
2062        old.components
2063            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2064
2065        new.components
2066            .insert(npm_parent.id.clone(), npm_parent.clone());
2067        new.components
2068            .insert(npm_child.id.clone(), npm_child.clone());
2069        new.components
2070            .insert(cargo_parent.id.clone(), cargo_parent.clone());
2071        new.components
2072            .insert(cargo_child.id.clone(), cargo_child.clone());
2073
2074        new.dependencies.insert(
2075            npm_parent.id.clone(),
2076            BTreeMap::from([(npm_child.id.clone(), DependencyKind::Runtime)]),
2077        );
2078        new.dependencies.insert(
2079            cargo_parent.id.clone(),
2080            BTreeMap::from([(cargo_child.id.clone(), DependencyKind::Runtime)]),
2081        );
2082
2083        let mut eco_map: BTreeMap<ComponentId, Option<String>> = BTreeMap::new();
2084        for (id, comp) in old.components.iter().chain(new.components.iter()) {
2085            eco_map.insert(id.clone(), comp.ecosystem.clone());
2086        }
2087
2088        let mut diff = Differ::diff(&old, &new, None);
2089        let names_before = diff.component_names.len();
2090        assert!(names_before > 0, "should have component names for hash IDs");
2091
2092        diff.filter_by_ecosystem(&|eco| eco == Some("npm"), 1, 1, &eco_map);
2093
2094        // component_names should not contain IDs only from the cargo edge diff
2095        assert!(diff.component_names.len() <= names_before);
2096        for id in diff.component_names.keys() {
2097            // every remaining name should be referenced by a remaining edge diff
2098            let referenced = diff.edge_diffs.iter().any(|e| {
2099                &e.parent == id
2100                    || e.added.contains_key(id)
2101                    || e.removed.contains_key(id)
2102                    || e.kind_changed.contains_key(id)
2103            });
2104            assert!(referenced, "stale component_name entry for {}", id);
2105        }
2106    }
2107
2108    #[test]
2109    fn test_diff_owned_identity() {
2110        let mut sbom = Sbom::default();
2111
2112        // build a non-trivial SBOM with varied component fields
2113        let mut parent = Component::new("my-app".to_string(), Some("2.0.0".to_string()));
2114        parent.purl = Some("pkg:cargo/my-app@2.0.0".to_string());
2115        parent.ecosystem = Some("cargo".to_string());
2116        parent.licenses.insert("MIT".into());
2117        parent.supplier = Some("Acme Corp".into());
2118        parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
2119
2120        let mut dep_a = Component::new("dep-a".to_string(), Some("1.0.0".to_string()));
2121        dep_a.purl = Some("pkg:cargo/dep-a@1.0.0".to_string());
2122        dep_a.ecosystem = Some("cargo".to_string());
2123        dep_a.licenses.insert("Apache-2.0".into());
2124        dep_a
2125            .hashes
2126            .insert("sha256".into(), "abcdef1234567890".into());
2127        dep_a.id = ComponentId::new(dep_a.purl.as_deref(), &[]);
2128
2129        let mut dep_b = Component::new("dep-b".to_string(), Some("0.5.0".to_string()));
2130        dep_b.ecosystem = Some("cargo".to_string());
2131        dep_b.description = Some("A helper library".into());
2132
2133        sbom.components.insert(parent.id.clone(), parent.clone());
2134        sbom.components.insert(dep_a.id.clone(), dep_a.clone());
2135        sbom.components.insert(dep_b.id.clone(), dep_b.clone());
2136
2137        // add dependency edges: parent -> dep-a (runtime), parent -> dep-b (dev)
2138        sbom.dependencies
2139            .entry(parent.id.clone())
2140            .or_default()
2141            .insert(dep_a.id.clone(), DependencyKind::Runtime);
2142        sbom.dependencies
2143            .entry(parent.id.clone())
2144            .or_default()
2145            .insert(dep_b.id.clone(), DependencyKind::Dev);
2146
2147        let copy = sbom.clone();
2148        let diff = Differ::diff_owned(sbom, copy, None);
2149
2150        assert_eq!(
2151            diff.added.len(),
2152            0,
2153            "identical SBOMs should have no added components"
2154        );
2155        assert_eq!(
2156            diff.removed.len(),
2157            0,
2158            "identical SBOMs should have no removed components"
2159        );
2160        assert_eq!(
2161            diff.changed.len(),
2162            0,
2163            "identical SBOMs should have no changed components"
2164        );
2165        assert_eq!(
2166            diff.edge_diffs.len(),
2167            0,
2168            "identical SBOMs should have no edge diffs"
2169        );
2170        assert_eq!(
2171            diff.metadata_changed, None,
2172            "identical SBOMs should have no metadata changes"
2173        );
2174        assert_eq!(diff.old_total, 3);
2175        assert_eq!(diff.new_total, 3);
2176        assert_eq!(diff.unchanged, 3);
2177    }
2178}