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
151/// Shared implementation for [`Diff::group_by_ecosystem`] and
152/// [`Diff::into_group_by_ecosystem`]. Accepts owned iterators so both the
153/// cloning and consuming callers can share the same loop logic.
154fn group_components_by_ecosystem(
155    added: impl IntoIterator<Item = Component>,
156    removed: impl IntoIterator<Item = Component>,
157    changed: impl IntoIterator<Item = ComponentChange>,
158    edge_diffs: Vec<EdgeDiff>,
159    metadata_changed: Option<MetadataChange>,
160) -> GroupedDiff {
161    let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
162
163    for c in added {
164        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
165        ecosystems.entry(eco).or_default().added.push(c);
166    }
167    for c in removed {
168        let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
169        ecosystems.entry(eco).or_default().removed.push(c);
170    }
171    for c in changed {
172        let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
173        ecosystems.entry(eco).or_default().changed.push(c);
174    }
175
176    GroupedDiff {
177        by_ecosystem: ecosystems,
178        edge_diffs,
179        metadata_changed,
180    }
181}
182
183/// Diff grouped by package ecosystem.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct GroupedDiff {
186    pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
187    pub edge_diffs: Vec<EdgeDiff>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub metadata_changed: Option<MetadataChange>,
190}
191
192impl GroupedDiff {
193    /// Derives per-ecosystem counts from the already-grouped data.
194    ///
195    /// This avoids a redundant traversal when both grouped components and
196    /// counts are needed — call [`Diff::group_by_ecosystem`] once, then
197    /// derive counts from the result.
198    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
199        self.by_ecosystem
200            .iter()
201            .map(|(eco, eco_diff)| {
202                (
203                    eco.clone(),
204                    EcosystemCounts {
205                        added: eco_diff.added.len(),
206                        removed: eco_diff.removed.len(),
207                        changed: eco_diff.changed.len(),
208                    },
209                )
210            })
211            .collect()
212    }
213}
214
215/// Per-ecosystem slice of added, removed, and changed components.
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct EcosystemDiff {
218    pub added: Vec<Component>,
219    pub removed: Vec<Component>,
220    pub changed: Vec<ComponentChange>,
221}
222
223/// A component that exists in both SBOMs with detected changes.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ComponentChange {
226    /// The component identifier (from the new SBOM).
227    pub id: ComponentId,
228    /// The component as it appeared in the old SBOM.
229    pub old: Component,
230    /// The component as it appears in the new SBOM.
231    pub new: Component,
232    /// List of specific field changes detected.
233    pub changes: Vec<FieldChange>,
234}
235
236/// A dependency edge change for a single parent component.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct EdgeDiff {
239    /// The parent component whose dependencies changed.
240    pub parent: ComponentId,
241    /// Dependencies added in the new SBOM, with their dependency kind.
242    pub added: BTreeMap<ComponentId, DependencyKind>,
243    /// Dependencies removed from the old SBOM, with their dependency kind.
244    pub removed: BTreeMap<ComponentId, DependencyKind>,
245    /// Dependencies whose kind changed between old and new (old_kind, new_kind).
246    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247    pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
248}
249
250/// A specific field that changed between two versions of a component.
251#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252pub enum FieldChange {
253    /// Version changed: (old, new).
254    Version(Option<String>, Option<String>),
255    /// Licenses changed: (old, new).
256    License(BTreeSet<String>, BTreeSet<String>),
257    /// Supplier changed: (old, new).
258    Supplier(Option<String>, Option<String>),
259    /// Package URL changed: (old, new).
260    Purl(Option<String>, Option<String>),
261    /// Description changed: (old, new).
262    Description(Option<String>, Option<String>),
263    /// Hashes changed: (old, new).
264    Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
265    /// Ecosystem changed: (old, new).
266    Ecosystem(Option<String>, Option<String>),
267}
268
269/// Fields that can be compared and filtered.
270///
271/// Use with [`Differ::diff`] to limit comparison to specific fields.
272#[derive(Debug, Copy, Clone, PartialEq, Eq)]
273pub enum Field {
274    /// Package version.
275    Version,
276    /// License identifiers.
277    License,
278    /// Supplier/publisher.
279    Supplier,
280    /// Package URL.
281    Purl,
282    /// Human-readable description.
283    Description,
284    /// Checksums.
285    Hashes,
286    /// Package ecosystem.
287    Ecosystem,
288    /// Dependency edges.
289    Deps,
290}
291
292/// SBOM comparison engine.
293///
294/// Compares two SBOMs and produces a [`Diff`] describing the changes.
295/// Components are matched first by ID (purl), then by identity (name + ecosystem).
296pub struct Differ;
297
298impl Differ {
299    /// Compares two SBOMs and returns the differences.
300    ///
301    /// Both SBOMs are normalized before comparison to ignore irrelevant differences
302    /// like ordering or metadata timestamps. This method clones both SBOMs
303    /// internally; use [`diff_owned`](Self::diff_owned) to avoid cloning when
304    /// you already own the SBOMs.
305    ///
306    /// # Arguments
307    ///
308    /// * `old` - The baseline SBOM
309    /// * `new` - The SBOM to compare against the baseline
310    /// * `only` - Optional filter to limit comparison to specific fields
311    ///
312    /// # Example
313    ///
314    /// ```
315    /// use sbom_diff::{Differ, Field};
316    /// use sbom_model::Sbom;
317    ///
318    /// let old = Sbom::default();
319    /// let new = Sbom::default();
320    ///
321    /// // Compare all fields
322    /// let diff = Differ::diff(&old, &new, None);
323    ///
324    /// // Compare only version and license changes
325    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
326    /// ```
327    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
328        Self::diff_owned(old.clone(), new.clone(), only)
329    }
330
331    /// Consuming variant of [`diff`](Self::diff) that normalizes in place,
332    /// avoiding two full SBOM clones.
333    pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
334        // Compare metadata before normalize() strips volatile fields
335        let metadata_changed = {
336            let mut mc = MetadataChange {
337                timestamp: None,
338                tools: None,
339                authors: None,
340            };
341            if old.metadata.timestamp != new.metadata.timestamp {
342                mc.timestamp = Some((
343                    old.metadata.timestamp.clone(),
344                    new.metadata.timestamp.clone(),
345                ));
346            }
347            if old.metadata.tools != new.metadata.tools {
348                mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
349            }
350            if old.metadata.authors != new.metadata.authors {
351                mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
352            }
353            if mc.is_empty() {
354                None
355            } else {
356                Some(mc)
357            }
358        };
359
360        old.normalize();
361        new.normalize();
362
363        let mut added = Vec::new();
364        let mut removed = Vec::new();
365        let mut changed = Vec::new();
366
367        let mut processed_old = HashSet::new();
368        let mut processed_new = HashSet::new();
369
370        // Track old_id -> new_id mappings for edge reconciliation
371        let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
372
373        // 1. Match by ID
374        for (id, new_comp) in &new.components {
375            if let Some(old_comp) = old.components.get(id) {
376                processed_old.insert(id.clone());
377                processed_new.insert(id.clone());
378                id_mapping.insert(id.clone(), id.clone());
379
380                if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
381                    changed.push(change);
382                }
383            }
384        }
385
386        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
387        // When purls are absent or change, we match by (ecosystem, name).
388        // If either ecosystem is None, we treat it as a wildcard and match by name alone.
389        //
390        // The map is keyed by name, then by ecosystem, so the wildcard lookup
391        // (case 3: new has no ecosystem → match any old with same name) is
392        // O(k) where k is the number of distinct ecosystems sharing that name,
393        // rather than a linear scan of the entire map.
394        let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
395            BTreeMap::new();
396        for (id, comp) in &old.components {
397            if !processed_old.contains(id) {
398                old_identity_map
399                    .entry(comp.name.clone())
400                    .or_default()
401                    .entry(comp.ecosystem.clone())
402                    .or_default()
403                    .push(id.clone());
404            }
405        }
406
407        for (id, new_comp) in &new.components {
408            if processed_new.contains(id) {
409                continue;
410            }
411
412            // Try to find a matching old component:
413            // 1. Exact match on (ecosystem, name)
414            // 2. If new has ecosystem but no exact match, try old with None ecosystem (same name)
415            // 3. If new has no ecosystem, try any old with same name
416            let matched_old_id = old_identity_map
417                .get_mut(&new_comp.name)
418                .and_then(|eco_map| {
419                    // Case 1: exact match on (ecosystem, name)
420                    eco_map
421                        .get_mut(&new_comp.ecosystem)
422                        .and_then(|ids| ids.pop())
423                        .or_else(|| {
424                            if new_comp.ecosystem.is_some() {
425                                // Case 2: new has ecosystem, try old with None ecosystem
426                                eco_map.get_mut(&None).and_then(|ids| ids.pop())
427                            } else {
428                                // Case 3: new has no ecosystem, try any old with same name
429                                eco_map.values_mut().find_map(|ids| ids.pop())
430                            }
431                        })
432                });
433
434            if let Some(old_id) = matched_old_id {
435                if let Some(old_comp) = old.components.get(&old_id) {
436                    processed_old.insert(old_id.clone());
437                    processed_new.insert(id.clone());
438                    id_mapping.insert(old_id.clone(), id.clone());
439
440                    if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
441                        changed.push(change);
442                    }
443                    continue;
444                }
445            }
446
447            added.push(new_comp.clone());
448            processed_new.insert(id.clone());
449        }
450
451        for (id, old_comp) in &old.components {
452            if !processed_old.contains(id) {
453                removed.push(old_comp.clone());
454            }
455        }
456
457        // 3. Compute totals
458        let old_total = old.components.len();
459        let new_total = new.components.len();
460        // matched = all components in the old SBOM that were paired with a new one
461        let matched = processed_old.len();
462        let unchanged = matched - changed.len();
463
464        // 4. Compute edge diffs (dependency graph changes)
465        let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
466        let edge_diffs = if should_include_deps {
467            Self::compute_edge_diffs(&old, &new, &id_mapping)
468        } else {
469            Vec::new()
470        };
471
472        // 5. Build human-readable name map for hash-based IDs in edge diffs
473        let component_names = Self::build_component_names(&old, &new, &edge_diffs);
474
475        Diff {
476            added,
477            removed,
478            changed,
479            edge_diffs,
480            metadata_changed,
481            old_total,
482            new_total,
483            unchanged,
484            component_names,
485        }
486    }
487
488    /// Computes dependency edge differences between two SBOMs.
489    ///
490    /// Uses the id_mapping to translate old component IDs to new IDs when
491    /// components were matched by identity rather than exact ID match.
492    /// Tracks dependency kind for added/removed edges and detects kind changes
493    /// (e.g. a dependency moving from dev to runtime).
494    fn compute_edge_diffs(
495        old: &Sbom,
496        new: &Sbom,
497        id_mapping: &BTreeMap<ComponentId, ComponentId>,
498    ) -> Vec<EdgeDiff> {
499        let mut edge_diffs = Vec::new();
500
501        // Build reverse mapping (new_id -> old_id) once upfront for O(1) lookups.
502        // The forward id_mapping is old_id -> new_id; we need the inverse for
503        // translating new parent IDs back to old parent IDs.
504        let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
505            .iter()
506            .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
507            .collect();
508
509        // Helper to translate old ID to new ID (if mapped) or keep as-is
510        let translate_id = |old_id: &ComponentId| -> ComponentId {
511            id_mapping
512                .get(old_id)
513                .cloned()
514                .unwrap_or_else(|| old_id.clone())
515        };
516
517        // Collect all parent IDs from new SBOM's perspective
518        // We use new IDs as the canonical reference
519        let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
520
521        // Also include old parents (translated to new IDs)
522        for old_parent in old.dependencies.keys() {
523            all_parents.insert(translate_id(old_parent));
524        }
525
526        for parent_id in all_parents {
527            // Get new dependencies for this parent (child -> kind)
528            let new_children: BTreeMap<ComponentId, DependencyKind> = new
529                .dependencies
530                .get(&parent_id)
531                .cloned()
532                .unwrap_or_default();
533
534            // Get old dependencies, translating both parent and child IDs
535            // Look up the old parent ID via the reverse map
536            let old_parent_id = reverse_mapping
537                .get(&parent_id)
538                .cloned()
539                .unwrap_or_else(|| parent_id.clone());
540
541            let old_children: BTreeMap<ComponentId, DependencyKind> = old
542                .dependencies
543                .get(&old_parent_id)
544                .map(|children| {
545                    children
546                        .iter()
547                        .map(|(id, kind)| (translate_id(id), *kind))
548                        .collect()
549                })
550                .unwrap_or_default();
551
552            let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
553            let old_keys: BTreeSet<&ComponentId> = old_children.keys().collect();
554
555            // Compute added and removed edges with their kinds
556            let added: BTreeMap<ComponentId, DependencyKind> = new_keys
557                .difference(&old_keys)
558                .map(|&id| (id.clone(), new_children[id]))
559                .collect();
560            let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
561                .difference(&new_keys)
562                .map(|&id| (id.clone(), old_children[id]))
563                .collect();
564
565            // Detect kind changes for edges that exist in both
566            let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
567                .intersection(&old_keys)
568                .filter_map(|&id| {
569                    let old_kind = old_children[id];
570                    let new_kind = new_children[id];
571                    if old_kind != new_kind {
572                        Some((id.clone(), (old_kind, new_kind)))
573                    } else {
574                        None
575                    }
576                })
577                .collect();
578
579            if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
580                edge_diffs.push(EdgeDiff {
581                    parent: parent_id,
582                    added,
583                    removed,
584                    kind_changed,
585                });
586            }
587        }
588
589        edge_diffs
590    }
591
592    /// Builds a human-readable display name map for component IDs in edge diffs.
593    ///
594    /// Only includes entries for hash-based IDs (`h:...`) since purl-based IDs
595    /// are already human-readable. Looks up component names from both SBOMs.
596    fn build_component_names(
597        old: &Sbom,
598        new: &Sbom,
599        edge_diffs: &[EdgeDiff],
600    ) -> BTreeMap<ComponentId, String> {
601        let mut names = BTreeMap::new();
602
603        // Collect all IDs that appear in edge diffs
604        let mut ids = BTreeSet::new();
605        for edge in edge_diffs {
606            ids.insert(&edge.parent);
607            ids.extend(edge.added.keys());
608            ids.extend(edge.removed.keys());
609            ids.extend(edge.kind_changed.keys());
610        }
611
612        // Only resolve hash-based IDs — purls are already readable
613        for id in ids {
614            if !id.as_str().starts_with("h:") {
615                continue;
616            }
617
618            // Try new SBOM first (edge diffs use new-SBOM IDs), then old
619            let comp = new.components.get(id).or_else(|| old.components.get(id));
620            if let Some(comp) = comp {
621                let display = match &comp.version {
622                    Some(v) => format!("{}@{}", comp.name, v),
623                    None => comp.name.clone(),
624                };
625                names.insert(id.clone(), display);
626            }
627        }
628
629        names
630    }
631
632    fn compute_change(
633        old: &Component,
634        new: &Component,
635        only: Option<&[Field]>,
636    ) -> Option<ComponentChange> {
637        let mut changes = Vec::new();
638
639        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
640
641        if should_include(Field::Version) && old.version != new.version {
642            changes.push(FieldChange::Version(
643                old.version.clone(),
644                new.version.clone(),
645            ));
646        }
647
648        if should_include(Field::License) && old.licenses != new.licenses {
649            changes.push(FieldChange::License(
650                old.licenses.clone(),
651                new.licenses.clone(),
652            ));
653        }
654
655        if should_include(Field::Supplier) && old.supplier != new.supplier {
656            changes.push(FieldChange::Supplier(
657                old.supplier.clone(),
658                new.supplier.clone(),
659            ));
660        }
661
662        if should_include(Field::Purl) && old.purl != new.purl {
663            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
664        }
665
666        if should_include(Field::Description) && old.description != new.description {
667            changes.push(FieldChange::Description(
668                old.description.clone(),
669                new.description.clone(),
670            ));
671        }
672
673        if should_include(Field::Hashes) && old.hashes != new.hashes {
674            changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
675        }
676
677        if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
678            changes.push(FieldChange::Ecosystem(
679                old.ecosystem.clone(),
680                new.ecosystem.clone(),
681            ));
682        }
683
684        if changes.is_empty() {
685            None
686        } else {
687            Some(ComponentChange {
688                id: new.id.clone(),
689                old: old.clone(),
690                new: new.clone(),
691                changes,
692            })
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_diff_added_removed() {
703        let mut old = Sbom::default();
704        let mut new = Sbom::default();
705
706        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
707        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
708
709        old.components.insert(c1.id.clone(), c1);
710        new.components.insert(c2.id.clone(), c2);
711
712        let diff = Differ::diff(&old, &new, None);
713        assert_eq!(diff.added.len(), 1);
714        assert_eq!(diff.removed.len(), 1);
715        assert_eq!(diff.changed.len(), 0);
716    }
717
718    #[test]
719    fn test_diff_changed() {
720        let mut old = Sbom::default();
721        let mut new = Sbom::default();
722
723        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
724        let mut c2 = c1.clone();
725        c2.version = Some("1.1".to_string());
726
727        old.components.insert(c1.id.clone(), c1);
728        new.components.insert(c2.id.clone(), c2);
729
730        let diff = Differ::diff(&old, &new, None);
731        assert_eq!(diff.added.len(), 0);
732        assert_eq!(diff.removed.len(), 0);
733        assert_eq!(diff.changed.len(), 1);
734        assert!(matches!(
735            diff.changed[0].changes[0],
736            FieldChange::Version(_, _)
737        ));
738    }
739
740    #[test]
741    fn test_diff_identity_reconciliation() {
742        let mut old = Sbom::default();
743        let mut new = Sbom::default();
744
745        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
746        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
747
748        old.components.insert(c1.id.clone(), c1);
749        new.components.insert(c2.id.clone(), c2);
750
751        let diff = Differ::diff(&old, &new, None);
752        assert_eq!(diff.changed.len(), 1);
753        assert_eq!(diff.added.len(), 0);
754    }
755
756    #[test]
757    fn test_diff_license_change() {
758        let mut old = Sbom::default();
759        let mut new = Sbom::default();
760
761        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
762        c1.licenses.insert("MIT".into());
763        let mut c2 = c1.clone();
764        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
765
766        old.components.insert(c1.id.clone(), c1);
767        new.components.insert(c2.id.clone(), c2);
768
769        let diff = Differ::diff(&old, &new, None);
770        assert_eq!(diff.changed.len(), 1);
771        assert!(diff.changed[0]
772            .changes
773            .iter()
774            .any(|c| matches!(c, FieldChange::License(_, _))));
775    }
776
777    #[test]
778    fn test_diff_supplier_change() {
779        let mut old = Sbom::default();
780        let mut new = Sbom::default();
781
782        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
783        c1.supplier = Some("Acme Corp".into());
784        let mut c2 = c1.clone();
785        c2.supplier = Some("New Corp".into());
786
787        old.components.insert(c1.id.clone(), c1);
788        new.components.insert(c2.id.clone(), c2);
789
790        let diff = Differ::diff(&old, &new, None);
791        assert_eq!(diff.changed.len(), 1);
792        assert!(diff.changed[0]
793            .changes
794            .iter()
795            .any(|c| matches!(c, FieldChange::Supplier(_, _))));
796    }
797
798    #[test]
799    fn test_diff_hashes_change() {
800        let mut old = Sbom::default();
801        let mut new = Sbom::default();
802
803        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
804        c1.hashes.insert("sha256".into(), "aaa".into());
805        let mut c2 = c1.clone();
806        c2.hashes.insert("sha256".into(), "bbb".into());
807
808        old.components.insert(c1.id.clone(), c1);
809        new.components.insert(c2.id.clone(), c2);
810
811        let diff = Differ::diff(&old, &new, None);
812        assert_eq!(diff.changed.len(), 1);
813        assert!(diff.changed[0]
814            .changes
815            .iter()
816            .any(|c| matches!(c, FieldChange::Hashes(_, _))));
817    }
818
819    #[test]
820    fn test_diff_description_change() {
821        let mut old = Sbom::default();
822        let mut new = Sbom::default();
823
824        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
825        c1.description = Some("Old description".into());
826        let mut c2 = c1.clone();
827        c2.description = Some("New description".into());
828
829        old.components.insert(c1.id.clone(), c1);
830        new.components.insert(c2.id.clone(), c2);
831
832        let diff = Differ::diff(&old, &new, None);
833        assert_eq!(diff.changed.len(), 1);
834        assert!(diff.changed[0]
835            .changes
836            .iter()
837            .any(|c| matches!(c, FieldChange::Description(_, _))));
838    }
839
840    #[test]
841    fn test_diff_description_added() {
842        let mut old = Sbom::default();
843        let mut new = Sbom::default();
844
845        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
846        let mut c2 = c1.clone();
847        c2.description = Some("A new description".into());
848
849        old.components.insert(c1.id.clone(), c1);
850        new.components.insert(c2.id.clone(), c2);
851
852        let diff = Differ::diff(&old, &new, None);
853        assert_eq!(diff.changed.len(), 1);
854        assert!(diff.changed[0]
855            .changes
856            .iter()
857            .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
858    }
859
860    #[test]
861    fn test_diff_description_removed() {
862        let mut old = Sbom::default();
863        let mut new = Sbom::default();
864
865        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
866        c1.description = Some("Had a description".into());
867        let mut c2 = c1.clone();
868        c2.description = None;
869
870        old.components.insert(c1.id.clone(), c1);
871        new.components.insert(c2.id.clone(), c2);
872
873        let diff = Differ::diff(&old, &new, None);
874        assert_eq!(diff.changed.len(), 1);
875        assert!(diff.changed[0]
876            .changes
877            .iter()
878            .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
879    }
880
881    #[test]
882    fn test_diff_description_unchanged() {
883        let mut old = Sbom::default();
884        let mut new = Sbom::default();
885
886        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
887        c1.description = Some("Same description".into());
888        let c2 = c1.clone();
889
890        old.components.insert(c1.id.clone(), c1);
891        new.components.insert(c2.id.clone(), c2);
892
893        let diff = Differ::diff(&old, &new, None);
894        assert!(diff.changed.is_empty());
895    }
896
897    #[test]
898    fn test_diff_description_filtering() {
899        let mut old = Sbom::default();
900        let mut new = Sbom::default();
901
902        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
903        c1.description = Some("Old".into());
904        let mut c2 = c1.clone();
905        c2.version = Some("2.0".into());
906        c2.description = Some("New".into());
907
908        old.components.insert(c1.id.clone(), c1);
909        new.components.insert(c2.id.clone(), c2);
910
911        // Only description: should see description change but not version
912        let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
913        assert_eq!(diff.changed.len(), 1);
914        assert_eq!(diff.changed[0].changes.len(), 1);
915        assert!(matches!(
916            diff.changed[0].changes[0],
917            FieldChange::Description(_, _)
918        ));
919
920        // Only version: should see version change but not description
921        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
922        assert_eq!(diff.changed.len(), 1);
923        assert_eq!(diff.changed[0].changes.len(), 1);
924        assert!(matches!(
925            diff.changed[0].changes[0],
926            FieldChange::Version(_, _)
927        ));
928    }
929
930    #[test]
931    fn test_diff_ecosystem_change() {
932        let mut old = Sbom::default();
933        let mut new = Sbom::default();
934
935        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
936        c1.ecosystem = Some("npm".to_string());
937        let mut c2 = c1.clone();
938        c2.ecosystem = Some("cargo".to_string());
939
940        old.components.insert(c1.id.clone(), c1);
941        new.components.insert(c2.id.clone(), c2);
942
943        let diff = Differ::diff(&old, &new, None);
944        assert_eq!(diff.changed.len(), 1);
945        assert_eq!(diff.changed[0].changes.len(), 1);
946        assert!(matches!(
947            diff.changed[0].changes[0],
948            FieldChange::Ecosystem(_, _)
949        ));
950
951        if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
952            assert_eq!(o.as_deref(), Some("npm"));
953            assert_eq!(n.as_deref(), Some("cargo"));
954        }
955    }
956
957    #[test]
958    fn test_diff_ecosystem_change_from_none() {
959        let mut old = Sbom::default();
960        let mut new = Sbom::default();
961
962        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
963        let mut c2 = c1.clone();
964        c2.ecosystem = Some("npm".to_string());
965
966        old.components.insert(c1.id.clone(), c1);
967        new.components.insert(c2.id.clone(), c2);
968
969        let diff = Differ::diff(&old, &new, None);
970        assert_eq!(diff.changed.len(), 1);
971        assert_eq!(diff.changed[0].changes.len(), 1);
972        assert!(matches!(
973            diff.changed[0].changes[0],
974            FieldChange::Ecosystem(None, Some(_))
975        ));
976    }
977
978    #[test]
979    fn test_diff_ecosystem_filtering() {
980        let mut old = Sbom::default();
981        let mut new = Sbom::default();
982
983        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
984        c1.ecosystem = Some("npm".to_string());
985        let mut c2 = c1.clone();
986        c2.version = Some("2.0".into());
987        c2.ecosystem = Some("cargo".to_string());
988
989        old.components.insert(c1.id.clone(), c1);
990        new.components.insert(c2.id.clone(), c2);
991
992        // Only ecosystem: should see ecosystem change but not version
993        let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
994        assert_eq!(diff.changed.len(), 1);
995        assert_eq!(diff.changed[0].changes.len(), 1);
996        assert!(matches!(
997            diff.changed[0].changes[0],
998            FieldChange::Ecosystem(_, _)
999        ));
1000
1001        // Only version: should see version change but not ecosystem
1002        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1003        assert_eq!(diff.changed.len(), 1);
1004        assert_eq!(diff.changed[0].changes.len(), 1);
1005        assert!(matches!(
1006            diff.changed[0].changes[0],
1007            FieldChange::Version(_, _)
1008        ));
1009    }
1010
1011    #[test]
1012    fn test_diff_ecosystem_no_change() {
1013        let mut old = Sbom::default();
1014        let mut new = Sbom::default();
1015
1016        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1017        c1.ecosystem = Some("npm".to_string());
1018        let c2 = c1.clone();
1019
1020        old.components.insert(c1.id.clone(), c1);
1021        new.components.insert(c2.id.clone(), c2);
1022
1023        let diff = Differ::diff(&old, &new, None);
1024        assert!(diff.changed.is_empty());
1025    }
1026
1027    #[test]
1028    fn test_diff_multiple_field_changes() {
1029        let mut old = Sbom::default();
1030        let mut new = Sbom::default();
1031
1032        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1033        c1.licenses.insert("MIT".into());
1034        c1.supplier = Some("Old Corp".into());
1035        c1.hashes.insert("sha256".into(), "aaa".into());
1036
1037        let mut c2 = c1.clone();
1038        c2.version = Some("2.0".into());
1039        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1040        c2.supplier = Some("New Corp".into());
1041        c2.hashes.insert("sha256".into(), "bbb".into());
1042
1043        old.components.insert(c1.id.clone(), c1);
1044        new.components.insert(c2.id.clone(), c2);
1045
1046        let diff = Differ::diff(&old, &new, None);
1047        assert_eq!(diff.changed.len(), 1);
1048        assert_eq!(diff.changed[0].changes.len(), 4);
1049    }
1050
1051    #[test]
1052    fn test_diff_no_changes() {
1053        let mut old = Sbom::default();
1054        let mut new = Sbom::default();
1055
1056        let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1057        old.components.insert(c.id.clone(), c.clone());
1058        new.components.insert(c.id.clone(), c);
1059
1060        let diff = Differ::diff(&old, &new, None);
1061        assert!(diff.added.is_empty());
1062        assert!(diff.removed.is_empty());
1063        assert!(diff.changed.is_empty());
1064        assert!(diff.edge_diffs.is_empty());
1065    }
1066
1067    #[test]
1068    fn test_diff_metadata_changed_timestamp() {
1069        let mut old = Sbom::default();
1070        let mut new = Sbom::default();
1071
1072        old.metadata.timestamp = Some("2024-01-01".into());
1073        new.metadata.timestamp = Some("2024-01-02".into());
1074
1075        let diff = Differ::diff(&old, &new, None);
1076        let mc = diff.metadata_changed.as_ref().unwrap();
1077        assert_eq!(
1078            mc.timestamp,
1079            Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1080        );
1081        assert!(mc.tools.is_none());
1082        assert!(mc.authors.is_none());
1083        assert!(!diff.is_empty());
1084    }
1085
1086    #[test]
1087    fn test_diff_metadata_changed_tools() {
1088        let mut old = Sbom::default();
1089        let mut new = Sbom::default();
1090
1091        old.metadata.tools = vec!["syft".into()];
1092        new.metadata.tools = vec!["trivy".into()];
1093
1094        let diff = Differ::diff(&old, &new, None);
1095        let mc = diff.metadata_changed.as_ref().unwrap();
1096        assert!(mc.timestamp.is_none());
1097        assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1098        assert!(mc.authors.is_none());
1099    }
1100
1101    #[test]
1102    fn test_diff_metadata_changed_authors() {
1103        let mut old = Sbom::default();
1104        let mut new = Sbom::default();
1105
1106        old.metadata.authors = vec!["alice".into()];
1107        new.metadata.authors = vec!["bob".into()];
1108
1109        let diff = Differ::diff(&old, &new, None);
1110        let mc = diff.metadata_changed.as_ref().unwrap();
1111        assert!(mc.timestamp.is_none());
1112        assert!(mc.tools.is_none());
1113        assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1114    }
1115
1116    #[test]
1117    fn test_diff_metadata_unchanged() {
1118        let mut old = Sbom::default();
1119        let mut new = Sbom::default();
1120
1121        old.metadata.timestamp = Some("2024-01-01".into());
1122        new.metadata.timestamp = Some("2024-01-01".into());
1123        old.metadata.tools = vec!["syft".into()];
1124        new.metadata.tools = vec!["syft".into()];
1125
1126        let diff = Differ::diff(&old, &new, None);
1127        assert!(diff.metadata_changed.is_none());
1128    }
1129
1130    #[test]
1131    fn test_diff_metadata_changed_multiple_fields() {
1132        let mut old = Sbom::default();
1133        let mut new = Sbom::default();
1134
1135        old.metadata.timestamp = Some("2024-01-01".into());
1136        new.metadata.timestamp = Some("2024-01-02".into());
1137        old.metadata.tools = vec!["syft".into()];
1138        new.metadata.tools = vec!["trivy".into()];
1139        old.metadata.authors = vec!["alice".into()];
1140        new.metadata.authors = vec!["bob".into()];
1141
1142        let diff = Differ::diff(&old, &new, None);
1143        let mc = diff.metadata_changed.as_ref().unwrap();
1144        assert!(mc.timestamp.is_some());
1145        assert!(mc.tools.is_some());
1146        assert!(mc.authors.is_some());
1147    }
1148
1149    #[test]
1150    fn test_diff_filtering() {
1151        let mut old = Sbom::default();
1152        let mut new = Sbom::default();
1153
1154        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1155        c1.licenses.insert("MIT".into());
1156
1157        let mut c2 = c1.clone();
1158        c2.version = Some("1.1".to_string());
1159        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1160
1161        old.components.insert(c1.id.clone(), c1);
1162        new.components.insert(c2.id.clone(), c2);
1163
1164        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1165        assert_eq!(diff.changed.len(), 1);
1166        assert_eq!(diff.changed[0].changes.len(), 1);
1167        assert!(matches!(
1168            diff.changed[0].changes[0],
1169            FieldChange::Version(_, _)
1170        ));
1171    }
1172
1173    #[test]
1174    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1175        // Component with purl in old, different purl in new (same ecosystem+name)
1176        // Should be treated as a CHANGE with Purl field change, not add/remove
1177        let mut old = Sbom::default();
1178        let mut new = Sbom::default();
1179
1180        // Old: lodash with one purl
1181        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1182        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1183        c_old.ecosystem = Some("npm".to_string());
1184        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1185
1186        // New: lodash with updated purl (version bump)
1187        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1188        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1189        c_new.ecosystem = Some("npm".to_string());
1190        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1191
1192        old.components.insert(c_old.id.clone(), c_old);
1193        new.components.insert(c_new.id.clone(), c_new);
1194
1195        let diff = Differ::diff(&old, &new, None);
1196
1197        // Should NOT be add/remove
1198        assert_eq!(diff.added.len(), 0, "Should not have added components");
1199        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1200
1201        // Should be a change
1202        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1203
1204        // Should include both Version and Purl changes
1205        let changes = &diff.changed[0].changes;
1206        assert!(changes
1207            .iter()
1208            .any(|c| matches!(c, FieldChange::Version(_, _))));
1209        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1210    }
1211
1212    #[test]
1213    fn test_purl_removed_is_change() {
1214        // Component with purl in old, no purl in new (same name)
1215        // This is realistic: old SBOM from tool that adds purls, new from tool that doesn't
1216        let mut old = Sbom::default();
1217        let mut new = Sbom::default();
1218
1219        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1220        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1221        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
1222        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1223
1224        // New component without purl - ecosystem is None (realistic!)
1225        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1226        c_new.purl = None;
1227        c_new.ecosystem = None; // No purl means no ecosystem extraction
1228                                // ID will be hash-based since no purl
1229        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1230
1231        old.components.insert(c_old.id.clone(), c_old);
1232        new.components.insert(c_new.id.clone(), c_new);
1233
1234        let diff = Differ::diff(&old, &new, None);
1235
1236        assert_eq!(diff.added.len(), 0, "Should not have added components");
1237        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1238        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1239
1240        // Should have Purl change
1241        assert!(diff.changed[0]
1242            .changes
1243            .iter()
1244            .any(|c| matches!(c, FieldChange::Purl(_, _))));
1245    }
1246
1247    #[test]
1248    fn test_purl_added_is_change() {
1249        // Component with no purl in old, purl in new
1250        // This is realistic: old SBOM without purls, new from better tooling
1251        let mut old = Sbom::default();
1252        let mut new = Sbom::default();
1253
1254        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1255        c_old.purl = None;
1256        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
1257        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1258
1259        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1260        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1261        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
1262        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1263
1264        old.components.insert(c_old.id.clone(), c_old);
1265        new.components.insert(c_new.id.clone(), c_new);
1266
1267        let diff = Differ::diff(&old, &new, None);
1268
1269        assert_eq!(diff.added.len(), 0, "Should not have added components");
1270        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1271        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1272    }
1273
1274    #[test]
1275    fn test_same_name_different_ecosystems_not_matched() {
1276        // Two components with same name but different ecosystems should NOT match
1277        let mut old = Sbom::default();
1278        let mut new = Sbom::default();
1279
1280        // Old: "utils" from npm
1281        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1282        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1283        c_old.ecosystem = Some("npm".to_string());
1284        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1285
1286        // New: "utils" from pypi (different ecosystem!)
1287        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1288        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1289        c_new.ecosystem = Some("pypi".to_string());
1290        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1291
1292        old.components.insert(c_old.id.clone(), c_old);
1293        new.components.insert(c_new.id.clone(), c_new);
1294
1295        let diff = Differ::diff(&old, &new, None);
1296
1297        // Should be separate add/remove, NOT a change
1298        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1299        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1300        assert_eq!(
1301            diff.changed.len(),
1302            0,
1303            "Should not match different ecosystems"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_same_name_both_no_ecosystem_matched() {
1309        // Components with same name and both having None ecosystem should match
1310        // (backwards compatibility for SBOMs without purls)
1311        let mut old = Sbom::default();
1312        let mut new = Sbom::default();
1313
1314        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1315        c_old.ecosystem = None;
1316
1317        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1318        c_new.ecosystem = None;
1319
1320        old.components.insert(c_old.id.clone(), c_old);
1321        new.components.insert(c_new.id.clone(), c_new);
1322
1323        let diff = Differ::diff(&old, &new, None);
1324
1325        assert_eq!(diff.added.len(), 0);
1326        assert_eq!(diff.removed.len(), 0);
1327        assert_eq!(
1328            diff.changed.len(),
1329            1,
1330            "Same name with None ecosystems should match"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_edge_diff_added_removed() {
1336        let mut old = Sbom::default();
1337        let mut new = Sbom::default();
1338
1339        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1340        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1341        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1342
1343        let parent_id = c1.id.clone();
1344        let child_a_id = c2.id.clone();
1345        let child_b_id = c3.id.clone();
1346
1347        // Add all components to both SBOMs
1348        old.components.insert(c1.id.clone(), c1.clone());
1349        old.components.insert(c2.id.clone(), c2.clone());
1350        old.components.insert(c3.id.clone(), c3.clone());
1351
1352        new.components.insert(c1.id.clone(), c1);
1353        new.components.insert(c2.id.clone(), c2);
1354        new.components.insert(c3.id.clone(), c3);
1355
1356        // Old: parent -> child-a
1357        old.dependencies
1358            .entry(parent_id.clone())
1359            .or_default()
1360            .insert(child_a_id.clone(), DependencyKind::Runtime);
1361
1362        // New: parent -> child-b (removed child-a, added child-b)
1363        new.dependencies
1364            .entry(parent_id.clone())
1365            .or_default()
1366            .insert(child_b_id.clone(), DependencyKind::Runtime);
1367
1368        let diff = Differ::diff(&old, &new, None);
1369
1370        assert_eq!(diff.edge_diffs.len(), 1);
1371        assert_eq!(diff.edge_diffs[0].parent, parent_id);
1372        assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1373        assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1374    }
1375
1376    #[test]
1377    fn test_edge_diff_with_identity_reconciliation() {
1378        // Test that edge diffs work when components are matched by identity
1379        // (different IDs but same name/ecosystem)
1380        let mut old = Sbom::default();
1381        let mut new = Sbom::default();
1382
1383        // Parent with purl in old
1384        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1385        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1386        parent_old.ecosystem = Some("npm".to_string());
1387        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1388
1389        // Parent with different purl in new (same name/ecosystem)
1390        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1391        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1392        parent_new.ecosystem = Some("npm".to_string());
1393        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1394
1395        // Child component (same in both)
1396        let child = Component::new("child".to_string(), Some("1.0".to_string()));
1397
1398        old.components
1399            .insert(parent_old.id.clone(), parent_old.clone());
1400        old.components.insert(child.id.clone(), child.clone());
1401
1402        new.components
1403            .insert(parent_new.id.clone(), parent_new.clone());
1404        new.components.insert(child.id.clone(), child.clone());
1405
1406        // Old: parent -> child
1407        old.dependencies
1408            .entry(parent_old.id.clone())
1409            .or_default()
1410            .insert(child.id.clone(), DependencyKind::Runtime);
1411
1412        // New: parent -> child (same edge, but parent has different ID)
1413        new.dependencies
1414            .entry(parent_new.id.clone())
1415            .or_default()
1416            .insert(child.id.clone(), DependencyKind::Runtime);
1417
1418        let diff = Differ::diff(&old, &new, None);
1419
1420        // Components should be matched by identity, so no spurious edge changes
1421        // (the edge parent->child exists in both, just under different parent IDs)
1422        assert_eq!(
1423            diff.edge_diffs.len(),
1424            0,
1425            "No edge changes expected when parent is reconciled by identity"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_edge_diff_filtering() {
1431        // Test that --only filtering excludes edge diffs when deps not included
1432        let mut old = Sbom::default();
1433        let mut new = Sbom::default();
1434
1435        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1436        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1437
1438        let parent_id = c1.id.clone();
1439        let child_id = c2.id.clone();
1440
1441        old.components.insert(c1.id.clone(), c1.clone());
1442        old.components.insert(c2.id.clone(), c2.clone());
1443
1444        new.components.insert(c1.id.clone(), c1);
1445        new.components.insert(c2.id.clone(), c2);
1446
1447        // New has an edge that old doesn't
1448        new.dependencies
1449            .entry(parent_id.clone())
1450            .or_default()
1451            .insert(child_id, DependencyKind::Runtime);
1452
1453        // Without filtering - should have edge diff
1454        let diff = Differ::diff(&old, &new, None);
1455        assert_eq!(diff.edge_diffs.len(), 1);
1456
1457        // With filtering to only Version - should NOT have edge diff
1458        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1459        assert_eq!(diff_filtered.edge_diffs.len(), 0);
1460
1461        // With filtering to include Deps - should have edge diff
1462        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1463        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1464    }
1465
1466    #[test]
1467    fn test_ecosystem_breakdown() {
1468        let mut old = Sbom::default();
1469        let mut new = Sbom::default();
1470
1471        // npm component in old only (removed)
1472        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1473        c1.ecosystem = Some("npm".into());
1474        old.components.insert(c1.id.clone(), c1);
1475
1476        // npm component in new only (added)
1477        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1478        c2.ecosystem = Some("npm".into());
1479        new.components.insert(c2.id.clone(), c2);
1480
1481        // cargo component in new only (added)
1482        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1483        c3.ecosystem = Some("cargo".into());
1484        new.components.insert(c3.id.clone(), c3);
1485
1486        // npm component changed (present in both, different version)
1487        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1488        c4_old.ecosystem = Some("npm".into());
1489        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1490        c4_new.ecosystem = Some("npm".into());
1491        old.components.insert(c4_old.id.clone(), c4_old);
1492        new.components.insert(c4_new.id.clone(), c4_new);
1493
1494        // component with no ecosystem (added)
1495        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1496        new.components.insert(c5.id.clone(), c5);
1497
1498        let diff = Differ::diff(&old, &new, None);
1499        let breakdown = diff.ecosystem_breakdown();
1500
1501        let npm = breakdown.get("npm").unwrap();
1502        assert_eq!(npm.added, 1);
1503        assert_eq!(npm.removed, 1);
1504        assert_eq!(npm.changed, 1);
1505
1506        let cargo = breakdown.get("cargo").unwrap();
1507        assert_eq!(cargo.added, 1);
1508        assert_eq!(cargo.removed, 0);
1509        assert_eq!(cargo.changed, 0);
1510
1511        let unknown = breakdown.get("unknown").unwrap();
1512        assert_eq!(unknown.added, 1);
1513        assert_eq!(unknown.removed, 0);
1514        assert_eq!(unknown.changed, 0);
1515    }
1516
1517    #[test]
1518    fn test_ecosystem_breakdown_empty_diff() {
1519        let old = Sbom::default();
1520        let new = Sbom::default();
1521
1522        let diff = Differ::diff(&old, &new, None);
1523        assert!(diff.is_empty());
1524        assert!(diff.ecosystem_breakdown().is_empty());
1525    }
1526
1527    #[test]
1528    fn test_group_by_ecosystem_empty_diff() {
1529        let old = Sbom::default();
1530        let new = Sbom::default();
1531
1532        let diff = Differ::diff(&old, &new, None);
1533        let grouped = diff.group_by_ecosystem();
1534        assert!(grouped.by_ecosystem.is_empty());
1535        assert!(grouped.edge_diffs.is_empty());
1536        assert!(grouped.metadata_changed.is_none());
1537        assert!(grouped.ecosystem_breakdown().is_empty());
1538    }
1539
1540    #[test]
1541    fn test_group_by_ecosystem_groups_correctly() {
1542        let mut old = Sbom::default();
1543        let mut new = Sbom::default();
1544
1545        // npm removed
1546        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1547        c1.ecosystem = Some("npm".into());
1548        old.components.insert(c1.id.clone(), c1);
1549
1550        // npm added
1551        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1552        c2.ecosystem = Some("npm".into());
1553        new.components.insert(c2.id.clone(), c2);
1554
1555        // cargo added
1556        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1557        c3.ecosystem = Some("cargo".into());
1558        new.components.insert(c3.id.clone(), c3);
1559
1560        // npm changed
1561        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1562        c4_old.ecosystem = Some("npm".into());
1563        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1564        c4_new.ecosystem = Some("npm".into());
1565        old.components.insert(c4_old.id.clone(), c4_old);
1566        new.components.insert(c4_new.id.clone(), c4_new);
1567
1568        // unknown added
1569        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1570        new.components.insert(c5.id.clone(), c5);
1571
1572        let diff = Differ::diff(&old, &new, None);
1573        let grouped = diff.group_by_ecosystem();
1574
1575        let npm = grouped.by_ecosystem.get("npm").unwrap();
1576        assert_eq!(npm.added.len(), 1);
1577        assert_eq!(npm.removed.len(), 1);
1578        assert_eq!(npm.changed.len(), 1);
1579
1580        let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1581        assert_eq!(cargo.added.len(), 1);
1582        assert_eq!(cargo.removed.len(), 0);
1583        assert_eq!(cargo.changed.len(), 0);
1584
1585        let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1586        assert_eq!(unknown.added.len(), 1);
1587        assert_eq!(unknown.removed.len(), 0);
1588        assert_eq!(unknown.changed.len(), 0);
1589
1590        // Derived breakdown should match direct breakdown
1591        let grouped_counts = grouped.ecosystem_breakdown();
1592        let direct_counts = diff.ecosystem_breakdown();
1593        assert_eq!(grouped_counts, direct_counts);
1594    }
1595
1596    #[test]
1597    fn test_totals_no_changes() {
1598        let mut old = Sbom::default();
1599        let mut new = Sbom::default();
1600
1601        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1602        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1603
1604        old.components.insert(c1.id.clone(), c1.clone());
1605        old.components.insert(c2.id.clone(), c2.clone());
1606        new.components.insert(c1.id.clone(), c1);
1607        new.components.insert(c2.id.clone(), c2);
1608
1609        let diff = Differ::diff(&old, &new, None);
1610        assert_eq!(diff.old_total, 2);
1611        assert_eq!(diff.new_total, 2);
1612        assert_eq!(diff.unchanged, 2);
1613    }
1614
1615    #[test]
1616    fn test_totals_with_changes() {
1617        let mut old = Sbom::default();
1618        let mut new = Sbom::default();
1619
1620        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1621        let mut c1_updated = c1.clone();
1622        c1_updated.version = Some("1.1".to_string());
1623        let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1624        let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1625        let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1626
1627        old.components.insert(c1.id.clone(), c1);
1628        old.components.insert(c2.id.clone(), c2.clone());
1629        old.components.insert(c3.id.clone(), c3);
1630        new.components.insert(c1_updated.id.clone(), c1_updated);
1631        new.components.insert(c2.id.clone(), c2);
1632        new.components.insert(c4.id.clone(), c4);
1633
1634        let diff = Differ::diff(&old, &new, None);
1635        assert_eq!(diff.old_total, 3);
1636        assert_eq!(diff.new_total, 3);
1637        assert_eq!(diff.added.len(), 1); // c4
1638        assert_eq!(diff.removed.len(), 1); // c3
1639        assert_eq!(diff.changed.len(), 1); // c1
1640        assert_eq!(diff.unchanged, 1); // c2
1641    }
1642
1643    #[test]
1644    fn test_component_names_for_hash_ids_in_edge_diffs() {
1645        let mut old = Sbom::default();
1646        let mut new = Sbom::default();
1647
1648        // Components without purls → hash-based IDs
1649        let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1650        let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1651        let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1652
1653        old.components.insert(parent.id.clone(), parent.clone());
1654        old.components.insert(child_a.id.clone(), child_a.clone());
1655        new.components.insert(parent.id.clone(), parent.clone());
1656        new.components.insert(child_b.id.clone(), child_b.clone());
1657
1658        // Set up edges: old parent -> child_a, new parent -> child_b
1659        old.dependencies.insert(
1660            parent.id.clone(),
1661            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1662        );
1663        new.dependencies.insert(
1664            parent.id.clone(),
1665            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1666        );
1667
1668        let diff = Differ::diff(&old, &new, None);
1669
1670        // All IDs in edge diffs should be hash-based (no purls)
1671        assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1672
1673        // component_names should resolve all hash IDs to readable names
1674        assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1675        for added in diff.edge_diffs[0].added.keys() {
1676            assert!(!diff.display_name(added).starts_with("h:"));
1677        }
1678        for removed in diff.edge_diffs[0].removed.keys() {
1679            assert!(!diff.display_name(removed).starts_with("h:"));
1680        }
1681    }
1682
1683    #[test]
1684    fn test_component_names_skips_purl_ids() {
1685        let mut old = Sbom::default();
1686        let mut new = Sbom::default();
1687
1688        let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1689        parent.purl = Some("pkg:npm/parent@1.0".to_string());
1690        parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1691
1692        let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1693        child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1694        child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1695
1696        let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1697        child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1698        child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1699
1700        old.components.insert(parent.id.clone(), parent.clone());
1701        old.components.insert(child_a.id.clone(), child_a.clone());
1702        new.components.insert(parent.id.clone(), parent.clone());
1703        new.components.insert(child_b.id.clone(), child_b.clone());
1704
1705        old.dependencies.insert(
1706            parent.id.clone(),
1707            BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1708        );
1709        new.dependencies.insert(
1710            parent.id.clone(),
1711            BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1712        );
1713
1714        let diff = Differ::diff(&old, &new, None);
1715
1716        // component_names should be empty — all IDs are purl-based
1717        assert!(diff.component_names.is_empty());
1718
1719        // display_name should fall back to the purl-based ID string
1720        assert!(diff
1721            .display_name(&diff.edge_diffs[0].parent)
1722            .starts_with("pkg:npm/parent@"));
1723    }
1724
1725    #[test]
1726    fn test_display_name_fallback() {
1727        let diff = Diff::default();
1728        let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1729        // No entry in component_names → falls back to raw ID
1730        assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1731    }
1732}