Skip to main content

sbom_diff/
lib.rs

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