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