Skip to main content

sbom_diff/
lib.rs

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