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, 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}
34
35impl Diff {
36    /// Returns `true` if the diff contains no changes of any kind.
37    pub fn is_empty(&self) -> bool {
38        self.added.is_empty()
39            && self.removed.is_empty()
40            && self.changed.is_empty()
41            && self.edge_diffs.is_empty()
42            && !self.metadata_changed
43    }
44
45    /// Groups added/removed/changed counts by package ecosystem.
46    ///
47    /// Components without an ecosystem are grouped under `"unknown"`.
48    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
49        let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
50
51        for comp in &self.added {
52            let eco = comp.ecosystem.clone().unwrap_or_else(|| "unknown".into());
53            breakdown.entry(eco).or_default().added += 1;
54        }
55
56        for comp in &self.removed {
57            let eco = comp.ecosystem.clone().unwrap_or_else(|| "unknown".into());
58            breakdown.entry(eco).or_default().removed += 1;
59        }
60
61        for change in &self.changed {
62            let eco = change
63                .new
64                .ecosystem
65                .clone()
66                .unwrap_or_else(|| "unknown".into());
67            breakdown.entry(eco).or_default().changed += 1;
68        }
69
70        breakdown
71    }
72
73    /// Groups the full diff by ecosystem, returning per-ecosystem slices.
74    ///
75    /// Components without an ecosystem are grouped under `"unknown"`.
76    pub fn group_by_ecosystem(&self) -> GroupedDiff {
77        let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
78
79        for c in &self.added {
80            let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
81            ecosystems.entry(eco).or_default().added.push(c.clone());
82        }
83        for c in &self.removed {
84            let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
85            ecosystems.entry(eco).or_default().removed.push(c.clone());
86        }
87        for c in &self.changed {
88            let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
89            ecosystems.entry(eco).or_default().changed.push(c.clone());
90        }
91
92        GroupedDiff {
93            by_ecosystem: ecosystems,
94            edge_diffs: self.edge_diffs.clone(),
95            metadata_changed: self.metadata_changed,
96        }
97    }
98}
99
100/// Diff grouped by package ecosystem.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct GroupedDiff {
103    pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
104    pub edge_diffs: Vec<EdgeDiff>,
105    pub metadata_changed: bool,
106}
107
108impl GroupedDiff {
109    /// Derives per-ecosystem counts from the already-grouped data.
110    ///
111    /// This avoids a redundant traversal when both grouped components and
112    /// counts are needed — call [`Diff::group_by_ecosystem`] once, then
113    /// derive counts from the result.
114    pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
115        self.by_ecosystem
116            .iter()
117            .map(|(eco, eco_diff)| {
118                (
119                    eco.clone(),
120                    EcosystemCounts {
121                        added: eco_diff.added.len(),
122                        removed: eco_diff.removed.len(),
123                        changed: eco_diff.changed.len(),
124                    },
125                )
126            })
127            .collect()
128    }
129}
130
131/// Per-ecosystem slice of added, removed, and changed components.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct EcosystemDiff {
134    pub added: Vec<Component>,
135    pub removed: Vec<Component>,
136    pub changed: Vec<ComponentChange>,
137}
138
139/// A component that exists in both SBOMs with detected changes.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ComponentChange {
142    /// The component identifier (from the new SBOM).
143    pub id: ComponentId,
144    /// The component as it appeared in the old SBOM.
145    pub old: Component,
146    /// The component as it appears in the new SBOM.
147    pub new: Component,
148    /// List of specific field changes detected.
149    pub changes: Vec<FieldChange>,
150}
151
152/// A dependency edge change for a single parent component.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct EdgeDiff {
155    /// The parent component whose dependencies changed.
156    pub parent: ComponentId,
157    /// Dependencies added in the new SBOM.
158    pub added: BTreeSet<ComponentId>,
159    /// Dependencies removed from the old SBOM.
160    pub removed: BTreeSet<ComponentId>,
161}
162
163/// A specific field that changed between two versions of a component.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub enum FieldChange {
166    /// Version changed: (old, new).
167    Version(String, String),
168    /// Licenses changed: (old, new).
169    License(BTreeSet<String>, BTreeSet<String>),
170    /// Supplier changed: (old, new).
171    Supplier(Option<String>, Option<String>),
172    /// Package URL changed: (old, new).
173    Purl(Option<String>, Option<String>),
174    /// Description changed: (old, new).
175    Description(Option<String>, Option<String>),
176    /// Hashes changed: (old, new).
177    Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
178}
179
180/// Fields that can be compared and filtered.
181///
182/// Use with [`Differ::diff`] to limit comparison to specific fields.
183#[derive(Debug, Copy, Clone, PartialEq, Eq)]
184pub enum Field {
185    /// Package version.
186    Version,
187    /// License identifiers.
188    License,
189    /// Supplier/publisher.
190    Supplier,
191    /// Package URL.
192    Purl,
193    /// Human-readable description.
194    Description,
195    /// Checksums.
196    Hashes,
197    /// Dependency edges.
198    Deps,
199}
200
201/// SBOM comparison engine.
202///
203/// Compares two SBOMs and produces a [`Diff`] describing the changes.
204/// Components are matched first by ID (purl), then by identity (name + ecosystem).
205pub struct Differ;
206
207impl Differ {
208    /// Compares two SBOMs and returns the differences.
209    ///
210    /// Both SBOMs are normalized before comparison to ignore irrelevant differences
211    /// like ordering or metadata timestamps.
212    ///
213    /// # Arguments
214    ///
215    /// * `old` - The baseline SBOM
216    /// * `new` - The SBOM to compare against the baseline
217    /// * `only` - Optional filter to limit comparison to specific fields
218    ///
219    /// # Example
220    ///
221    /// ```
222    /// use sbom_diff::{Differ, Field};
223    /// use sbom_model::Sbom;
224    ///
225    /// let old = Sbom::default();
226    /// let new = Sbom::default();
227    ///
228    /// // Compare all fields
229    /// let diff = Differ::diff(&old, &new, None);
230    ///
231    /// // Compare only version and license changes
232    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
233    /// ```
234    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
235        let mut old = old.clone();
236        let mut new = new.clone();
237
238        old.normalize();
239        new.normalize();
240
241        let mut added = Vec::new();
242        let mut removed = Vec::new();
243        let mut changed = Vec::new();
244
245        let mut processed_old = HashSet::new();
246        let mut processed_new = HashSet::new();
247
248        // Track old_id -> new_id mappings for edge reconciliation
249        let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
250
251        // 1. Match by ID
252        for (id, new_comp) in &new.components {
253            if let Some(old_comp) = old.components.get(id) {
254                processed_old.insert(id.clone());
255                processed_new.insert(id.clone());
256                id_mapping.insert(id.clone(), id.clone());
257
258                if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
259                    changed.push(change);
260                }
261            }
262        }
263
264        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
265        // When purls are absent or change, we match by (ecosystem, name).
266        // If either ecosystem is None, we treat it as a wildcard and match by name alone.
267        let mut old_identity_map: BTreeMap<(Option<String>, String), Vec<ComponentId>> =
268            BTreeMap::new();
269        for (id, comp) in &old.components {
270            if !processed_old.contains(id) {
271                let identity = (comp.ecosystem.clone(), comp.name.clone());
272                old_identity_map
273                    .entry(identity)
274                    .or_default()
275                    .push(id.clone());
276            }
277        }
278
279        for (id, new_comp) in &new.components {
280            if processed_new.contains(id) {
281                continue;
282            }
283
284            let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
285
286            // Try to find a matching old component:
287            // 1. Exact match on (ecosystem, name)
288            // 2. If new has ecosystem but no exact match, try old with None ecosystem (same name)
289            // 3. If new has no ecosystem, try any old with same name
290            let matched_old_id = old_identity_map
291                .get_mut(&identity)
292                .and_then(|ids| ids.pop())
293                .or_else(|| {
294                    if new_comp.ecosystem.is_some() {
295                        // New has ecosystem, try matching old with None ecosystem
296                        old_identity_map
297                            .get_mut(&(None, new_comp.name.clone()))
298                            .and_then(|ids| ids.pop())
299                    } else {
300                        // New has no ecosystem, try matching any old with same name
301                        old_identity_map
302                            .iter_mut()
303                            .find(|((_, name), ids)| name == &new_comp.name && !ids.is_empty())
304                            .and_then(|(_, ids)| ids.pop())
305                    }
306                });
307
308            if let Some(old_id) = matched_old_id {
309                if let Some(old_comp) = old.components.get(&old_id) {
310                    processed_old.insert(old_id.clone());
311                    processed_new.insert(id.clone());
312                    id_mapping.insert(old_id.clone(), id.clone());
313
314                    if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
315                        changed.push(change);
316                    }
317                    continue;
318                }
319            }
320
321            added.push(new_comp.clone());
322            processed_new.insert(id.clone());
323        }
324
325        for (id, old_comp) in &old.components {
326            if !processed_old.contains(id) {
327                removed.push(old_comp.clone());
328            }
329        }
330
331        // 3. Compute edge diffs (dependency graph changes)
332        let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
333        let edge_diffs = if should_include_deps {
334            Self::compute_edge_diffs(&old, &new, &id_mapping)
335        } else {
336            Vec::new()
337        };
338
339        Diff {
340            added,
341            removed,
342            changed,
343            edge_diffs,
344            metadata_changed: old.metadata != new.metadata,
345        }
346    }
347
348    /// Computes dependency edge differences between two SBOMs.
349    ///
350    /// Uses the id_mapping to translate old component IDs to new IDs when
351    /// components were matched by identity rather than exact ID match.
352    fn compute_edge_diffs(
353        old: &Sbom,
354        new: &Sbom,
355        id_mapping: &BTreeMap<ComponentId, ComponentId>,
356    ) -> Vec<EdgeDiff> {
357        let mut edge_diffs = Vec::new();
358
359        // Build reverse mapping (new_id -> old_id) once upfront for O(1) lookups.
360        // The forward id_mapping is old_id -> new_id; we need the inverse for
361        // translating new parent IDs back to old parent IDs.
362        let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
363            .iter()
364            .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
365            .collect();
366
367        // Helper to translate old ID to new ID (if mapped) or keep as-is
368        let translate_id = |old_id: &ComponentId| -> ComponentId {
369            id_mapping
370                .get(old_id)
371                .cloned()
372                .unwrap_or_else(|| old_id.clone())
373        };
374
375        // Collect all parent IDs from new SBOM's perspective
376        // We use new IDs as the canonical reference
377        let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
378
379        // Also include old parents (translated to new IDs)
380        for old_parent in old.dependencies.keys() {
381            all_parents.insert(translate_id(old_parent));
382        }
383
384        for parent_id in all_parents {
385            // Get new dependencies for this parent
386            let new_children: BTreeSet<ComponentId> = new
387                .dependencies
388                .get(&parent_id)
389                .cloned()
390                .unwrap_or_default();
391
392            // Get old dependencies, translating both parent and child IDs
393            // Look up the old parent ID via the reverse map
394            let old_parent_id = reverse_mapping
395                .get(&parent_id)
396                .cloned()
397                .unwrap_or_else(|| parent_id.clone());
398
399            let old_children: BTreeSet<ComponentId> = old
400                .dependencies
401                .get(&old_parent_id)
402                .map(|children| children.iter().map(&translate_id).collect())
403                .unwrap_or_default();
404
405            // Compute added and removed edges
406            let added: BTreeSet<ComponentId> =
407                new_children.difference(&old_children).cloned().collect();
408            let removed: BTreeSet<ComponentId> =
409                old_children.difference(&new_children).cloned().collect();
410
411            if !added.is_empty() || !removed.is_empty() {
412                edge_diffs.push(EdgeDiff {
413                    parent: parent_id,
414                    added,
415                    removed,
416                });
417            }
418        }
419
420        edge_diffs
421    }
422
423    fn compute_change(
424        old: &Component,
425        new: &Component,
426        only: Option<&[Field]>,
427    ) -> Option<ComponentChange> {
428        let mut changes = Vec::new();
429
430        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
431
432        if should_include(Field::Version) && old.version != new.version {
433            changes.push(FieldChange::Version(
434                old.version.clone().unwrap_or_default(),
435                new.version.clone().unwrap_or_default(),
436            ));
437        }
438
439        if should_include(Field::License) && old.licenses != new.licenses {
440            changes.push(FieldChange::License(
441                old.licenses.clone(),
442                new.licenses.clone(),
443            ));
444        }
445
446        if should_include(Field::Supplier) && old.supplier != new.supplier {
447            changes.push(FieldChange::Supplier(
448                old.supplier.clone(),
449                new.supplier.clone(),
450            ));
451        }
452
453        if should_include(Field::Purl) && old.purl != new.purl {
454            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
455        }
456
457        if should_include(Field::Description) && old.description != new.description {
458            changes.push(FieldChange::Description(
459                old.description.clone(),
460                new.description.clone(),
461            ));
462        }
463
464        if should_include(Field::Hashes) && old.hashes != new.hashes {
465            changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
466        }
467
468        if changes.is_empty() {
469            None
470        } else {
471            Some(ComponentChange {
472                id: new.id.clone(),
473                old: old.clone(),
474                new: new.clone(),
475                changes,
476            })
477        }
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_diff_added_removed() {
487        let mut old = Sbom::default();
488        let mut new = Sbom::default();
489
490        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
491        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
492
493        old.components.insert(c1.id.clone(), c1);
494        new.components.insert(c2.id.clone(), c2);
495
496        let diff = Differ::diff(&old, &new, None);
497        assert_eq!(diff.added.len(), 1);
498        assert_eq!(diff.removed.len(), 1);
499        assert_eq!(diff.changed.len(), 0);
500    }
501
502    #[test]
503    fn test_diff_changed() {
504        let mut old = Sbom::default();
505        let mut new = Sbom::default();
506
507        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
508        let mut c2 = c1.clone();
509        c2.version = Some("1.1".to_string());
510
511        old.components.insert(c1.id.clone(), c1);
512        new.components.insert(c2.id.clone(), c2);
513
514        let diff = Differ::diff(&old, &new, None);
515        assert_eq!(diff.added.len(), 0);
516        assert_eq!(diff.removed.len(), 0);
517        assert_eq!(diff.changed.len(), 1);
518        assert!(matches!(
519            diff.changed[0].changes[0],
520            FieldChange::Version(_, _)
521        ));
522    }
523
524    #[test]
525    fn test_diff_identity_reconciliation() {
526        let mut old = Sbom::default();
527        let mut new = Sbom::default();
528
529        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
530        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
531
532        old.components.insert(c1.id.clone(), c1);
533        new.components.insert(c2.id.clone(), c2);
534
535        let diff = Differ::diff(&old, &new, None);
536        assert_eq!(diff.changed.len(), 1);
537        assert_eq!(diff.added.len(), 0);
538    }
539
540    #[test]
541    fn test_diff_license_change() {
542        let mut old = Sbom::default();
543        let mut new = Sbom::default();
544
545        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
546        c1.licenses.insert("MIT".into());
547        let mut c2 = c1.clone();
548        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
549
550        old.components.insert(c1.id.clone(), c1);
551        new.components.insert(c2.id.clone(), c2);
552
553        let diff = Differ::diff(&old, &new, None);
554        assert_eq!(diff.changed.len(), 1);
555        assert!(diff.changed[0]
556            .changes
557            .iter()
558            .any(|c| matches!(c, FieldChange::License(_, _))));
559    }
560
561    #[test]
562    fn test_diff_supplier_change() {
563        let mut old = Sbom::default();
564        let mut new = Sbom::default();
565
566        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
567        c1.supplier = Some("Acme Corp".into());
568        let mut c2 = c1.clone();
569        c2.supplier = Some("New Corp".into());
570
571        old.components.insert(c1.id.clone(), c1);
572        new.components.insert(c2.id.clone(), c2);
573
574        let diff = Differ::diff(&old, &new, None);
575        assert_eq!(diff.changed.len(), 1);
576        assert!(diff.changed[0]
577            .changes
578            .iter()
579            .any(|c| matches!(c, FieldChange::Supplier(_, _))));
580    }
581
582    #[test]
583    fn test_diff_hashes_change() {
584        let mut old = Sbom::default();
585        let mut new = Sbom::default();
586
587        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
588        c1.hashes.insert("sha256".into(), "aaa".into());
589        let mut c2 = c1.clone();
590        c2.hashes.insert("sha256".into(), "bbb".into());
591
592        old.components.insert(c1.id.clone(), c1);
593        new.components.insert(c2.id.clone(), c2);
594
595        let diff = Differ::diff(&old, &new, None);
596        assert_eq!(diff.changed.len(), 1);
597        assert!(diff.changed[0]
598            .changes
599            .iter()
600            .any(|c| matches!(c, FieldChange::Hashes(_, _))));
601    }
602
603    #[test]
604    fn test_diff_description_change() {
605        let mut old = Sbom::default();
606        let mut new = Sbom::default();
607
608        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
609        c1.description = Some("Old description".into());
610        let mut c2 = c1.clone();
611        c2.description = Some("New description".into());
612
613        old.components.insert(c1.id.clone(), c1);
614        new.components.insert(c2.id.clone(), c2);
615
616        let diff = Differ::diff(&old, &new, None);
617        assert_eq!(diff.changed.len(), 1);
618        assert!(diff.changed[0]
619            .changes
620            .iter()
621            .any(|c| matches!(c, FieldChange::Description(_, _))));
622    }
623
624    #[test]
625    fn test_diff_description_added() {
626        let mut old = Sbom::default();
627        let mut new = Sbom::default();
628
629        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
630        let mut c2 = c1.clone();
631        c2.description = Some("A new description".into());
632
633        old.components.insert(c1.id.clone(), c1);
634        new.components.insert(c2.id.clone(), c2);
635
636        let diff = Differ::diff(&old, &new, None);
637        assert_eq!(diff.changed.len(), 1);
638        assert!(diff.changed[0]
639            .changes
640            .iter()
641            .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
642    }
643
644    #[test]
645    fn test_diff_description_removed() {
646        let mut old = Sbom::default();
647        let mut new = Sbom::default();
648
649        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
650        c1.description = Some("Had a description".into());
651        let mut c2 = c1.clone();
652        c2.description = None;
653
654        old.components.insert(c1.id.clone(), c1);
655        new.components.insert(c2.id.clone(), c2);
656
657        let diff = Differ::diff(&old, &new, None);
658        assert_eq!(diff.changed.len(), 1);
659        assert!(diff.changed[0]
660            .changes
661            .iter()
662            .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
663    }
664
665    #[test]
666    fn test_diff_description_unchanged() {
667        let mut old = Sbom::default();
668        let mut new = Sbom::default();
669
670        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
671        c1.description = Some("Same description".into());
672        let c2 = c1.clone();
673
674        old.components.insert(c1.id.clone(), c1);
675        new.components.insert(c2.id.clone(), c2);
676
677        let diff = Differ::diff(&old, &new, None);
678        assert!(diff.changed.is_empty());
679    }
680
681    #[test]
682    fn test_diff_description_filtering() {
683        let mut old = Sbom::default();
684        let mut new = Sbom::default();
685
686        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
687        c1.description = Some("Old".into());
688        let mut c2 = c1.clone();
689        c2.version = Some("2.0".into());
690        c2.description = Some("New".into());
691
692        old.components.insert(c1.id.clone(), c1);
693        new.components.insert(c2.id.clone(), c2);
694
695        // Only description: should see description change but not version
696        let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
697        assert_eq!(diff.changed.len(), 1);
698        assert_eq!(diff.changed[0].changes.len(), 1);
699        assert!(matches!(
700            diff.changed[0].changes[0],
701            FieldChange::Description(_, _)
702        ));
703
704        // Only version: should see version change but not description
705        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
706        assert_eq!(diff.changed.len(), 1);
707        assert_eq!(diff.changed[0].changes.len(), 1);
708        assert!(matches!(
709            diff.changed[0].changes[0],
710            FieldChange::Version(_, _)
711        ));
712    }
713
714    #[test]
715    fn test_diff_multiple_field_changes() {
716        let mut old = Sbom::default();
717        let mut new = Sbom::default();
718
719        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
720        c1.licenses.insert("MIT".into());
721        c1.supplier = Some("Old Corp".into());
722        c1.hashes.insert("sha256".into(), "aaa".into());
723
724        let mut c2 = c1.clone();
725        c2.version = Some("2.0".into());
726        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
727        c2.supplier = Some("New Corp".into());
728        c2.hashes.insert("sha256".into(), "bbb".into());
729
730        old.components.insert(c1.id.clone(), c1);
731        new.components.insert(c2.id.clone(), c2);
732
733        let diff = Differ::diff(&old, &new, None);
734        assert_eq!(diff.changed.len(), 1);
735        assert_eq!(diff.changed[0].changes.len(), 4);
736    }
737
738    #[test]
739    fn test_diff_no_changes() {
740        let mut old = Sbom::default();
741        let mut new = Sbom::default();
742
743        let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
744        old.components.insert(c.id.clone(), c.clone());
745        new.components.insert(c.id.clone(), c);
746
747        let diff = Differ::diff(&old, &new, None);
748        assert!(diff.added.is_empty());
749        assert!(diff.removed.is_empty());
750        assert!(diff.changed.is_empty());
751        assert!(diff.edge_diffs.is_empty());
752    }
753
754    #[test]
755    fn test_diff_metadata_changed() {
756        let mut old = Sbom::default();
757        let mut new = Sbom::default();
758
759        // Metadata is stripped during normalization, so metadata_changed should
760        // always be false after normalize
761        old.metadata.timestamp = Some("2024-01-01".into());
762        new.metadata.timestamp = Some("2024-01-02".into());
763
764        let diff = Differ::diff(&old, &new, None);
765        // After normalization, both timestamps are cleared
766        assert!(!diff.metadata_changed);
767    }
768
769    #[test]
770    fn test_diff_filtering() {
771        let mut old = Sbom::default();
772        let mut new = Sbom::default();
773
774        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
775        c1.licenses.insert("MIT".into());
776
777        let mut c2 = c1.clone();
778        c2.version = Some("1.1".to_string());
779        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
780
781        old.components.insert(c1.id.clone(), c1);
782        new.components.insert(c2.id.clone(), c2);
783
784        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
785        assert_eq!(diff.changed.len(), 1);
786        assert_eq!(diff.changed[0].changes.len(), 1);
787        assert!(matches!(
788            diff.changed[0].changes[0],
789            FieldChange::Version(_, _)
790        ));
791    }
792
793    #[test]
794    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
795        // Component with purl in old, different purl in new (same ecosystem+name)
796        // Should be treated as a CHANGE with Purl field change, not add/remove
797        let mut old = Sbom::default();
798        let mut new = Sbom::default();
799
800        // Old: lodash with one purl
801        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
802        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
803        c_old.ecosystem = Some("npm".to_string());
804        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
805
806        // New: lodash with updated purl (version bump)
807        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
808        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
809        c_new.ecosystem = Some("npm".to_string());
810        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
811
812        old.components.insert(c_old.id.clone(), c_old);
813        new.components.insert(c_new.id.clone(), c_new);
814
815        let diff = Differ::diff(&old, &new, None);
816
817        // Should NOT be add/remove
818        assert_eq!(diff.added.len(), 0, "Should not have added components");
819        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
820
821        // Should be a change
822        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
823
824        // Should include both Version and Purl changes
825        let changes = &diff.changed[0].changes;
826        assert!(changes
827            .iter()
828            .any(|c| matches!(c, FieldChange::Version(_, _))));
829        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
830    }
831
832    #[test]
833    fn test_purl_removed_is_change() {
834        // Component with purl in old, no purl in new (same name)
835        // This is realistic: old SBOM from tool that adds purls, new from tool that doesn't
836        let mut old = Sbom::default();
837        let mut new = Sbom::default();
838
839        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
840        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
841        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
842        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
843
844        // New component without purl - ecosystem is None (realistic!)
845        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
846        c_new.purl = None;
847        c_new.ecosystem = None; // No purl means no ecosystem extraction
848                                // ID will be hash-based since no purl
849        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
850
851        old.components.insert(c_old.id.clone(), c_old);
852        new.components.insert(c_new.id.clone(), c_new);
853
854        let diff = Differ::diff(&old, &new, None);
855
856        assert_eq!(diff.added.len(), 0, "Should not have added components");
857        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
858        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
859
860        // Should have Purl change
861        assert!(diff.changed[0]
862            .changes
863            .iter()
864            .any(|c| matches!(c, FieldChange::Purl(_, _))));
865    }
866
867    #[test]
868    fn test_purl_added_is_change() {
869        // Component with no purl in old, purl in new
870        // This is realistic: old SBOM without purls, new from better tooling
871        let mut old = Sbom::default();
872        let mut new = Sbom::default();
873
874        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
875        c_old.purl = None;
876        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
877        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
878
879        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
880        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
881        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
882        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
883
884        old.components.insert(c_old.id.clone(), c_old);
885        new.components.insert(c_new.id.clone(), c_new);
886
887        let diff = Differ::diff(&old, &new, None);
888
889        assert_eq!(diff.added.len(), 0, "Should not have added components");
890        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
891        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
892    }
893
894    #[test]
895    fn test_same_name_different_ecosystems_not_matched() {
896        // Two components with same name but different ecosystems should NOT match
897        let mut old = Sbom::default();
898        let mut new = Sbom::default();
899
900        // Old: "utils" from npm
901        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
902        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
903        c_old.ecosystem = Some("npm".to_string());
904        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
905
906        // New: "utils" from pypi (different ecosystem!)
907        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
908        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
909        c_new.ecosystem = Some("pypi".to_string());
910        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
911
912        old.components.insert(c_old.id.clone(), c_old);
913        new.components.insert(c_new.id.clone(), c_new);
914
915        let diff = Differ::diff(&old, &new, None);
916
917        // Should be separate add/remove, NOT a change
918        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
919        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
920        assert_eq!(
921            diff.changed.len(),
922            0,
923            "Should not match different ecosystems"
924        );
925    }
926
927    #[test]
928    fn test_same_name_both_no_ecosystem_matched() {
929        // Components with same name and both having None ecosystem should match
930        // (backwards compatibility for SBOMs without purls)
931        let mut old = Sbom::default();
932        let mut new = Sbom::default();
933
934        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
935        c_old.ecosystem = None;
936
937        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
938        c_new.ecosystem = None;
939
940        old.components.insert(c_old.id.clone(), c_old);
941        new.components.insert(c_new.id.clone(), c_new);
942
943        let diff = Differ::diff(&old, &new, None);
944
945        assert_eq!(diff.added.len(), 0);
946        assert_eq!(diff.removed.len(), 0);
947        assert_eq!(
948            diff.changed.len(),
949            1,
950            "Same name with None ecosystems should match"
951        );
952    }
953
954    #[test]
955    fn test_edge_diff_added_removed() {
956        let mut old = Sbom::default();
957        let mut new = Sbom::default();
958
959        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
960        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
961        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
962
963        let parent_id = c1.id.clone();
964        let child_a_id = c2.id.clone();
965        let child_b_id = c3.id.clone();
966
967        // Add all components to both SBOMs
968        old.components.insert(c1.id.clone(), c1.clone());
969        old.components.insert(c2.id.clone(), c2.clone());
970        old.components.insert(c3.id.clone(), c3.clone());
971
972        new.components.insert(c1.id.clone(), c1);
973        new.components.insert(c2.id.clone(), c2);
974        new.components.insert(c3.id.clone(), c3);
975
976        // Old: parent -> child-a
977        old.dependencies
978            .entry(parent_id.clone())
979            .or_default()
980            .insert(child_a_id.clone());
981
982        // New: parent -> child-b (removed child-a, added child-b)
983        new.dependencies
984            .entry(parent_id.clone())
985            .or_default()
986            .insert(child_b_id.clone());
987
988        let diff = Differ::diff(&old, &new, None);
989
990        assert_eq!(diff.edge_diffs.len(), 1);
991        assert_eq!(diff.edge_diffs[0].parent, parent_id);
992        assert!(diff.edge_diffs[0].added.contains(&child_b_id));
993        assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
994    }
995
996    #[test]
997    fn test_edge_diff_with_identity_reconciliation() {
998        // Test that edge diffs work when components are matched by identity
999        // (different IDs but same name/ecosystem)
1000        let mut old = Sbom::default();
1001        let mut new = Sbom::default();
1002
1003        // Parent with purl in old
1004        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1005        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1006        parent_old.ecosystem = Some("npm".to_string());
1007        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1008
1009        // Parent with different purl in new (same name/ecosystem)
1010        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1011        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1012        parent_new.ecosystem = Some("npm".to_string());
1013        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1014
1015        // Child component (same in both)
1016        let child = Component::new("child".to_string(), Some("1.0".to_string()));
1017
1018        old.components
1019            .insert(parent_old.id.clone(), parent_old.clone());
1020        old.components.insert(child.id.clone(), child.clone());
1021
1022        new.components
1023            .insert(parent_new.id.clone(), parent_new.clone());
1024        new.components.insert(child.id.clone(), child.clone());
1025
1026        // Old: parent -> child
1027        old.dependencies
1028            .entry(parent_old.id.clone())
1029            .or_default()
1030            .insert(child.id.clone());
1031
1032        // New: parent -> child (same edge, but parent has different ID)
1033        new.dependencies
1034            .entry(parent_new.id.clone())
1035            .or_default()
1036            .insert(child.id.clone());
1037
1038        let diff = Differ::diff(&old, &new, None);
1039
1040        // Components should be matched by identity, so no spurious edge changes
1041        // (the edge parent->child exists in both, just under different parent IDs)
1042        assert_eq!(
1043            diff.edge_diffs.len(),
1044            0,
1045            "No edge changes expected when parent is reconciled by identity"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_edge_diff_filtering() {
1051        // Test that --only filtering excludes edge diffs when deps not included
1052        let mut old = Sbom::default();
1053        let mut new = Sbom::default();
1054
1055        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1056        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1057
1058        let parent_id = c1.id.clone();
1059        let child_id = c2.id.clone();
1060
1061        old.components.insert(c1.id.clone(), c1.clone());
1062        old.components.insert(c2.id.clone(), c2.clone());
1063
1064        new.components.insert(c1.id.clone(), c1);
1065        new.components.insert(c2.id.clone(), c2);
1066
1067        // New has an edge that old doesn't
1068        new.dependencies
1069            .entry(parent_id.clone())
1070            .or_default()
1071            .insert(child_id);
1072
1073        // Without filtering - should have edge diff
1074        let diff = Differ::diff(&old, &new, None);
1075        assert_eq!(diff.edge_diffs.len(), 1);
1076
1077        // With filtering to only Version - should NOT have edge diff
1078        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1079        assert_eq!(diff_filtered.edge_diffs.len(), 0);
1080
1081        // With filtering to include Deps - should have edge diff
1082        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1083        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1084    }
1085
1086    #[test]
1087    fn test_ecosystem_breakdown() {
1088        let mut old = Sbom::default();
1089        let mut new = Sbom::default();
1090
1091        // npm component in old only (removed)
1092        let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1093        c1.ecosystem = Some("npm".into());
1094        old.components.insert(c1.id.clone(), c1);
1095
1096        // npm component in new only (added)
1097        let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1098        c2.ecosystem = Some("npm".into());
1099        new.components.insert(c2.id.clone(), c2);
1100
1101        // cargo component in new only (added)
1102        let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1103        c3.ecosystem = Some("cargo".into());
1104        new.components.insert(c3.id.clone(), c3);
1105
1106        // npm component changed (present in both, different version)
1107        let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1108        c4_old.ecosystem = Some("npm".into());
1109        let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1110        c4_new.ecosystem = Some("npm".into());
1111        old.components.insert(c4_old.id.clone(), c4_old);
1112        new.components.insert(c4_new.id.clone(), c4_new);
1113
1114        // component with no ecosystem (added)
1115        let c5 = Component::new("mystery".into(), Some("1.0".into()));
1116        new.components.insert(c5.id.clone(), c5);
1117
1118        let diff = Differ::diff(&old, &new, None);
1119        let breakdown = diff.ecosystem_breakdown();
1120
1121        let npm = breakdown.get("npm").unwrap();
1122        assert_eq!(npm.added, 1);
1123        assert_eq!(npm.removed, 1);
1124        assert_eq!(npm.changed, 1);
1125
1126        let cargo = breakdown.get("cargo").unwrap();
1127        assert_eq!(cargo.added, 1);
1128        assert_eq!(cargo.removed, 0);
1129        assert_eq!(cargo.changed, 0);
1130
1131        let unknown = breakdown.get("unknown").unwrap();
1132        assert_eq!(unknown.added, 1);
1133        assert_eq!(unknown.removed, 0);
1134        assert_eq!(unknown.changed, 0);
1135    }
1136}