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/// The result of comparing two SBOMs.
10///
11/// Contains lists of added, removed, and changed components,
12/// as well as dependency edge changes.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Diff {
15    /// Components present in the new SBOM but not the old.
16    pub added: Vec<Component>,
17    /// Components present in the old SBOM but not the new.
18    pub removed: Vec<Component>,
19    /// Components present in both with field-level changes.
20    pub changed: Vec<ComponentChange>,
21    /// Dependency edge changes between components.
22    pub edge_diffs: Vec<EdgeDiff>,
23    /// Whether document metadata differs (usually ignored).
24    pub metadata_changed: bool,
25}
26
27/// A component that exists in both SBOMs with detected changes.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ComponentChange {
30    /// The component identifier (from the new SBOM).
31    pub id: ComponentId,
32    /// The component as it appeared in the old SBOM.
33    pub old: Component,
34    /// The component as it appears in the new SBOM.
35    pub new: Component,
36    /// List of specific field changes detected.
37    pub changes: Vec<FieldChange>,
38}
39
40/// A dependency edge change for a single parent component.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct EdgeDiff {
43    /// The parent component whose dependencies changed.
44    pub parent: ComponentId,
45    /// Dependencies added in the new SBOM.
46    pub added: BTreeSet<ComponentId>,
47    /// Dependencies removed from the old SBOM.
48    pub removed: BTreeSet<ComponentId>,
49}
50
51/// A specific field that changed between two versions of a component.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub enum FieldChange {
54    /// Version changed: (old, new).
55    Version(String, String),
56    /// Licenses changed: (old, new).
57    License(BTreeSet<String>, BTreeSet<String>),
58    /// Supplier changed: (old, new).
59    Supplier(Option<String>, Option<String>),
60    /// Package URL changed: (old, new).
61    Purl(Option<String>, Option<String>),
62    /// Hashes changed (details not tracked).
63    Hashes,
64}
65
66/// Fields that can be compared and filtered.
67///
68/// Use with [`Differ::diff`] to limit comparison to specific fields.
69#[derive(Debug, Copy, Clone, PartialEq, Eq)]
70pub enum Field {
71    /// Package version.
72    Version,
73    /// License identifiers.
74    License,
75    /// Supplier/publisher.
76    Supplier,
77    /// Package URL.
78    Purl,
79    /// Checksums.
80    Hashes,
81    /// Dependency edges.
82    Deps,
83}
84
85/// SBOM comparison engine.
86///
87/// Compares two SBOMs and produces a [`Diff`] describing the changes.
88/// Components are matched first by ID (purl), then by identity (name + ecosystem).
89pub struct Differ;
90
91impl Differ {
92    /// Compares two SBOMs and returns the differences.
93    ///
94    /// Both SBOMs are normalized before comparison to ignore irrelevant differences
95    /// like ordering or metadata timestamps.
96    ///
97    /// # Arguments
98    ///
99    /// * `old` - The baseline SBOM
100    /// * `new` - The SBOM to compare against the baseline
101    /// * `only` - Optional filter to limit comparison to specific fields
102    ///
103    /// # Example
104    ///
105    /// ```
106    /// use sbom_diff::{Differ, Field};
107    /// use sbom_model::Sbom;
108    ///
109    /// let old = Sbom::default();
110    /// let new = Sbom::default();
111    ///
112    /// // Compare all fields
113    /// let diff = Differ::diff(&old, &new, None);
114    ///
115    /// // Compare only version and license changes
116    /// let diff = Differ::diff(&old, &new, Some(&[Field::Version, Field::License]));
117    /// ```
118    pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
119        let mut old = old.clone();
120        let mut new = new.clone();
121
122        old.normalize();
123        new.normalize();
124
125        let mut added = Vec::new();
126        let mut removed = Vec::new();
127        let mut changed = Vec::new();
128
129        let mut processed_old = HashSet::new();
130        let mut processed_new = HashSet::new();
131
132        // Track old_id -> new_id mappings for edge reconciliation
133        let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
134
135        // 1. Match by ID
136        for (id, new_comp) in &new.components {
137            if let Some(old_comp) = old.components.get(id) {
138                processed_old.insert(id.clone());
139                processed_new.insert(id.clone());
140                id_mapping.insert(id.clone(), id.clone());
141
142                if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
143                    changed.push(change);
144                }
145            }
146        }
147
148        // 2. Reconciliation: Match by "Identity" (Name + Ecosystem)
149        // When purls are absent or change, we match by (ecosystem, name).
150        // If either ecosystem is None, we treat it as a wildcard and match by name alone.
151        let mut old_identity_map: BTreeMap<(Option<String>, String), Vec<ComponentId>> =
152            BTreeMap::new();
153        for (id, comp) in &old.components {
154            if !processed_old.contains(id) {
155                let identity = (comp.ecosystem.clone(), comp.name.clone());
156                old_identity_map
157                    .entry(identity)
158                    .or_default()
159                    .push(id.clone());
160            }
161        }
162
163        for (id, new_comp) in &new.components {
164            if processed_new.contains(id) {
165                continue;
166            }
167
168            let identity = (new_comp.ecosystem.clone(), new_comp.name.clone());
169
170            // Try to find a matching old component:
171            // 1. Exact match on (ecosystem, name)
172            // 2. If new has ecosystem but no exact match, try old with None ecosystem (same name)
173            // 3. If new has no ecosystem, try any old with same name
174            let matched_old_id = old_identity_map
175                .get_mut(&identity)
176                .and_then(|ids| ids.pop())
177                .or_else(|| {
178                    if new_comp.ecosystem.is_some() {
179                        // New has ecosystem, try matching old with None ecosystem
180                        old_identity_map
181                            .get_mut(&(None, new_comp.name.clone()))
182                            .and_then(|ids| ids.pop())
183                    } else {
184                        // New has no ecosystem, try matching any old with same name
185                        old_identity_map
186                            .iter_mut()
187                            .find(|((_, name), ids)| name == &new_comp.name && !ids.is_empty())
188                            .and_then(|(_, ids)| ids.pop())
189                    }
190                });
191
192            if let Some(old_id) = matched_old_id {
193                if let Some(old_comp) = old.components.get(&old_id) {
194                    processed_old.insert(old_id.clone());
195                    processed_new.insert(id.clone());
196                    id_mapping.insert(old_id.clone(), id.clone());
197
198                    if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
199                        changed.push(change);
200                    }
201                    continue;
202                }
203            }
204
205            added.push(new_comp.clone());
206            processed_new.insert(id.clone());
207        }
208
209        for (id, old_comp) in &old.components {
210            if !processed_old.contains(id) {
211                removed.push(old_comp.clone());
212            }
213        }
214
215        // 3. Compute edge diffs (dependency graph changes)
216        let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
217        let edge_diffs = if should_include_deps {
218            Self::compute_edge_diffs(&old, &new, &id_mapping)
219        } else {
220            Vec::new()
221        };
222
223        Diff {
224            added,
225            removed,
226            changed,
227            edge_diffs,
228            metadata_changed: old.metadata != new.metadata,
229        }
230    }
231
232    /// Computes dependency edge differences between two SBOMs.
233    ///
234    /// Uses the id_mapping to translate old component IDs to new IDs when
235    /// components were matched by identity rather than exact ID match.
236    fn compute_edge_diffs(
237        old: &Sbom,
238        new: &Sbom,
239        id_mapping: &BTreeMap<ComponentId, ComponentId>,
240    ) -> Vec<EdgeDiff> {
241        let mut edge_diffs = Vec::new();
242
243        // Helper to translate old ID to new ID (if mapped) or keep as-is
244        let translate_id = |old_id: &ComponentId| -> ComponentId {
245            id_mapping
246                .get(old_id)
247                .cloned()
248                .unwrap_or_else(|| old_id.clone())
249        };
250
251        // Collect all parent IDs from new SBOM's perspective
252        // We use new IDs as the canonical reference
253        let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
254
255        // Also include old parents (translated to new IDs)
256        for old_parent in old.dependencies.keys() {
257            all_parents.insert(translate_id(old_parent));
258        }
259
260        for parent_id in all_parents {
261            // Get new dependencies for this parent
262            let new_children: BTreeSet<ComponentId> = new
263                .dependencies
264                .get(&parent_id)
265                .cloned()
266                .unwrap_or_default();
267
268            // Get old dependencies, translating both parent and child IDs
269            // First find the old parent ID (reverse lookup or same ID)
270            let old_parent_id = id_mapping
271                .iter()
272                .find(|(_, new_id)| *new_id == &parent_id)
273                .map(|(old_id, _)| old_id.clone())
274                .unwrap_or_else(|| parent_id.clone());
275
276            let old_children: BTreeSet<ComponentId> = old
277                .dependencies
278                .get(&old_parent_id)
279                .map(|children| children.iter().map(&translate_id).collect())
280                .unwrap_or_default();
281
282            // Compute added and removed edges
283            let added: BTreeSet<ComponentId> =
284                new_children.difference(&old_children).cloned().collect();
285            let removed: BTreeSet<ComponentId> =
286                old_children.difference(&new_children).cloned().collect();
287
288            if !added.is_empty() || !removed.is_empty() {
289                edge_diffs.push(EdgeDiff {
290                    parent: parent_id,
291                    added,
292                    removed,
293                });
294            }
295        }
296
297        edge_diffs
298    }
299
300    fn compute_change(
301        old: &Component,
302        new: &Component,
303        only: Option<&[Field]>,
304    ) -> Option<ComponentChange> {
305        let mut changes = Vec::new();
306
307        let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
308
309        if should_include(Field::Version) && old.version != new.version {
310            changes.push(FieldChange::Version(
311                old.version.clone().unwrap_or_default(),
312                new.version.clone().unwrap_or_default(),
313            ));
314        }
315
316        if should_include(Field::License) && old.licenses != new.licenses {
317            changes.push(FieldChange::License(
318                old.licenses.clone(),
319                new.licenses.clone(),
320            ));
321        }
322
323        if should_include(Field::Supplier) && old.supplier != new.supplier {
324            changes.push(FieldChange::Supplier(
325                old.supplier.clone(),
326                new.supplier.clone(),
327            ));
328        }
329
330        if should_include(Field::Purl) && old.purl != new.purl {
331            changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
332        }
333
334        if should_include(Field::Hashes) && old.hashes != new.hashes {
335            changes.push(FieldChange::Hashes);
336        }
337
338        if changes.is_empty() {
339            None
340        } else {
341            Some(ComponentChange {
342                id: new.id.clone(),
343                old: old.clone(),
344                new: new.clone(),
345                changes,
346            })
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_diff_added_removed() {
357        let mut old = Sbom::default();
358        let mut new = Sbom::default();
359
360        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
361        let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
362
363        old.components.insert(c1.id.clone(), c1);
364        new.components.insert(c2.id.clone(), c2);
365
366        let diff = Differ::diff(&old, &new, None);
367        assert_eq!(diff.added.len(), 1);
368        assert_eq!(diff.removed.len(), 1);
369        assert_eq!(diff.changed.len(), 0);
370    }
371
372    #[test]
373    fn test_diff_changed() {
374        let mut old = Sbom::default();
375        let mut new = Sbom::default();
376
377        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
378        let mut c2 = c1.clone();
379        c2.version = Some("1.1".to_string());
380
381        old.components.insert(c1.id.clone(), c1);
382        new.components.insert(c2.id.clone(), c2);
383
384        let diff = Differ::diff(&old, &new, None);
385        assert_eq!(diff.added.len(), 0);
386        assert_eq!(diff.removed.len(), 0);
387        assert_eq!(diff.changed.len(), 1);
388        assert!(matches!(
389            diff.changed[0].changes[0],
390            FieldChange::Version(_, _)
391        ));
392    }
393
394    #[test]
395    fn test_diff_identity_reconciliation() {
396        let mut old = Sbom::default();
397        let mut new = Sbom::default();
398
399        let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
400        let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
401
402        old.components.insert(c1.id.clone(), c1);
403        new.components.insert(c2.id.clone(), c2);
404
405        let diff = Differ::diff(&old, &new, None);
406        assert_eq!(diff.changed.len(), 1);
407        assert_eq!(diff.added.len(), 0);
408    }
409
410    #[test]
411    fn test_diff_license_change() {
412        let mut old = Sbom::default();
413        let mut new = Sbom::default();
414
415        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
416        c1.licenses.insert("MIT".into());
417        let mut c2 = c1.clone();
418        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
419
420        old.components.insert(c1.id.clone(), c1);
421        new.components.insert(c2.id.clone(), c2);
422
423        let diff = Differ::diff(&old, &new, None);
424        assert_eq!(diff.changed.len(), 1);
425        assert!(diff.changed[0]
426            .changes
427            .iter()
428            .any(|c| matches!(c, FieldChange::License(_, _))));
429    }
430
431    #[test]
432    fn test_diff_supplier_change() {
433        let mut old = Sbom::default();
434        let mut new = Sbom::default();
435
436        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
437        c1.supplier = Some("Acme Corp".into());
438        let mut c2 = c1.clone();
439        c2.supplier = Some("New Corp".into());
440
441        old.components.insert(c1.id.clone(), c1);
442        new.components.insert(c2.id.clone(), c2);
443
444        let diff = Differ::diff(&old, &new, None);
445        assert_eq!(diff.changed.len(), 1);
446        assert!(diff.changed[0]
447            .changes
448            .iter()
449            .any(|c| matches!(c, FieldChange::Supplier(_, _))));
450    }
451
452    #[test]
453    fn test_diff_hashes_change() {
454        let mut old = Sbom::default();
455        let mut new = Sbom::default();
456
457        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
458        c1.hashes.insert("sha256".into(), "aaa".into());
459        let mut c2 = c1.clone();
460        c2.hashes.insert("sha256".into(), "bbb".into());
461
462        old.components.insert(c1.id.clone(), c1);
463        new.components.insert(c2.id.clone(), c2);
464
465        let diff = Differ::diff(&old, &new, None);
466        assert_eq!(diff.changed.len(), 1);
467        assert!(diff.changed[0]
468            .changes
469            .iter()
470            .any(|c| matches!(c, FieldChange::Hashes)));
471    }
472
473    #[test]
474    fn test_diff_multiple_field_changes() {
475        let mut old = Sbom::default();
476        let mut new = Sbom::default();
477
478        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
479        c1.licenses.insert("MIT".into());
480        c1.supplier = Some("Old Corp".into());
481        c1.hashes.insert("sha256".into(), "aaa".into());
482
483        let mut c2 = c1.clone();
484        c2.version = Some("2.0".into());
485        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
486        c2.supplier = Some("New Corp".into());
487        c2.hashes.insert("sha256".into(), "bbb".into());
488
489        old.components.insert(c1.id.clone(), c1);
490        new.components.insert(c2.id.clone(), c2);
491
492        let diff = Differ::diff(&old, &new, None);
493        assert_eq!(diff.changed.len(), 1);
494        assert_eq!(diff.changed[0].changes.len(), 4);
495    }
496
497    #[test]
498    fn test_diff_no_changes() {
499        let mut old = Sbom::default();
500        let mut new = Sbom::default();
501
502        let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
503        old.components.insert(c.id.clone(), c.clone());
504        new.components.insert(c.id.clone(), c);
505
506        let diff = Differ::diff(&old, &new, None);
507        assert!(diff.added.is_empty());
508        assert!(diff.removed.is_empty());
509        assert!(diff.changed.is_empty());
510        assert!(diff.edge_diffs.is_empty());
511    }
512
513    #[test]
514    fn test_diff_metadata_changed() {
515        let mut old = Sbom::default();
516        let mut new = Sbom::default();
517
518        // Metadata is stripped during normalization, so metadata_changed should
519        // always be false after normalize
520        old.metadata.timestamp = Some("2024-01-01".into());
521        new.metadata.timestamp = Some("2024-01-02".into());
522
523        let diff = Differ::diff(&old, &new, None);
524        // After normalization, both timestamps are cleared
525        assert!(!diff.metadata_changed);
526    }
527
528    #[test]
529    fn test_diff_filtering() {
530        let mut old = Sbom::default();
531        let mut new = Sbom::default();
532
533        let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
534        c1.licenses.insert("MIT".into());
535
536        let mut c2 = c1.clone();
537        c2.version = Some("1.1".to_string());
538        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
539
540        old.components.insert(c1.id.clone(), c1);
541        new.components.insert(c2.id.clone(), c2);
542
543        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
544        assert_eq!(diff.changed.len(), 1);
545        assert_eq!(diff.changed[0].changes.len(), 1);
546        assert!(matches!(
547            diff.changed[0].changes[0],
548            FieldChange::Version(_, _)
549        ));
550    }
551
552    #[test]
553    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
554        // Component with purl in old, different purl in new (same ecosystem+name)
555        // Should be treated as a CHANGE with Purl field change, not add/remove
556        let mut old = Sbom::default();
557        let mut new = Sbom::default();
558
559        // Old: lodash with one purl
560        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
561        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
562        c_old.ecosystem = Some("npm".to_string());
563        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
564
565        // New: lodash with updated purl (version bump)
566        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
567        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
568        c_new.ecosystem = Some("npm".to_string());
569        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
570
571        old.components.insert(c_old.id.clone(), c_old);
572        new.components.insert(c_new.id.clone(), c_new);
573
574        let diff = Differ::diff(&old, &new, None);
575
576        // Should NOT be add/remove
577        assert_eq!(diff.added.len(), 0, "Should not have added components");
578        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
579
580        // Should be a change
581        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
582
583        // Should include both Version and Purl changes
584        let changes = &diff.changed[0].changes;
585        assert!(changes
586            .iter()
587            .any(|c| matches!(c, FieldChange::Version(_, _))));
588        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
589    }
590
591    #[test]
592    fn test_purl_removed_is_change() {
593        // Component with purl in old, no purl in new (same name)
594        // This is realistic: old SBOM from tool that adds purls, new from tool that doesn't
595        let mut old = Sbom::default();
596        let mut new = Sbom::default();
597
598        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
599        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
600        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
601        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
602
603        // New component without purl - ecosystem is None (realistic!)
604        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
605        c_new.purl = None;
606        c_new.ecosystem = None; // No purl means no ecosystem extraction
607                                // ID will be hash-based since no purl
608        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
609
610        old.components.insert(c_old.id.clone(), c_old);
611        new.components.insert(c_new.id.clone(), c_new);
612
613        let diff = Differ::diff(&old, &new, None);
614
615        assert_eq!(diff.added.len(), 0, "Should not have added components");
616        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
617        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
618
619        // Should have Purl change
620        assert!(diff.changed[0]
621            .changes
622            .iter()
623            .any(|c| matches!(c, FieldChange::Purl(_, _))));
624    }
625
626    #[test]
627    fn test_purl_added_is_change() {
628        // Component with no purl in old, purl in new
629        // This is realistic: old SBOM without purls, new from better tooling
630        let mut old = Sbom::default();
631        let mut new = Sbom::default();
632
633        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
634        c_old.purl = None;
635        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
636        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
637
638        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
639        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
640        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
641        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
642
643        old.components.insert(c_old.id.clone(), c_old);
644        new.components.insert(c_new.id.clone(), c_new);
645
646        let diff = Differ::diff(&old, &new, None);
647
648        assert_eq!(diff.added.len(), 0, "Should not have added components");
649        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
650        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
651    }
652
653    #[test]
654    fn test_same_name_different_ecosystems_not_matched() {
655        // Two components with same name but different ecosystems should NOT match
656        let mut old = Sbom::default();
657        let mut new = Sbom::default();
658
659        // Old: "utils" from npm
660        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
661        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
662        c_old.ecosystem = Some("npm".to_string());
663        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
664
665        // New: "utils" from pypi (different ecosystem!)
666        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
667        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
668        c_new.ecosystem = Some("pypi".to_string());
669        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
670
671        old.components.insert(c_old.id.clone(), c_old);
672        new.components.insert(c_new.id.clone(), c_new);
673
674        let diff = Differ::diff(&old, &new, None);
675
676        // Should be separate add/remove, NOT a change
677        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
678        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
679        assert_eq!(
680            diff.changed.len(),
681            0,
682            "Should not match different ecosystems"
683        );
684    }
685
686    #[test]
687    fn test_same_name_both_no_ecosystem_matched() {
688        // Components with same name and both having None ecosystem should match
689        // (backwards compatibility for SBOMs without purls)
690        let mut old = Sbom::default();
691        let mut new = Sbom::default();
692
693        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
694        c_old.ecosystem = None;
695
696        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
697        c_new.ecosystem = None;
698
699        old.components.insert(c_old.id.clone(), c_old);
700        new.components.insert(c_new.id.clone(), c_new);
701
702        let diff = Differ::diff(&old, &new, None);
703
704        assert_eq!(diff.added.len(), 0);
705        assert_eq!(diff.removed.len(), 0);
706        assert_eq!(
707            diff.changed.len(),
708            1,
709            "Same name with None ecosystems should match"
710        );
711    }
712
713    #[test]
714    fn test_edge_diff_added_removed() {
715        let mut old = Sbom::default();
716        let mut new = Sbom::default();
717
718        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
719        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
720        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
721
722        let parent_id = c1.id.clone();
723        let child_a_id = c2.id.clone();
724        let child_b_id = c3.id.clone();
725
726        // Add all components to both SBOMs
727        old.components.insert(c1.id.clone(), c1.clone());
728        old.components.insert(c2.id.clone(), c2.clone());
729        old.components.insert(c3.id.clone(), c3.clone());
730
731        new.components.insert(c1.id.clone(), c1);
732        new.components.insert(c2.id.clone(), c2);
733        new.components.insert(c3.id.clone(), c3);
734
735        // Old: parent -> child-a
736        old.dependencies
737            .entry(parent_id.clone())
738            .or_default()
739            .insert(child_a_id.clone());
740
741        // New: parent -> child-b (removed child-a, added child-b)
742        new.dependencies
743            .entry(parent_id.clone())
744            .or_default()
745            .insert(child_b_id.clone());
746
747        let diff = Differ::diff(&old, &new, None);
748
749        assert_eq!(diff.edge_diffs.len(), 1);
750        assert_eq!(diff.edge_diffs[0].parent, parent_id);
751        assert!(diff.edge_diffs[0].added.contains(&child_b_id));
752        assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
753    }
754
755    #[test]
756    fn test_edge_diff_with_identity_reconciliation() {
757        // Test that edge diffs work when components are matched by identity
758        // (different IDs but same name/ecosystem)
759        let mut old = Sbom::default();
760        let mut new = Sbom::default();
761
762        // Parent with purl in old
763        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
764        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
765        parent_old.ecosystem = Some("npm".to_string());
766        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
767
768        // Parent with different purl in new (same name/ecosystem)
769        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
770        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
771        parent_new.ecosystem = Some("npm".to_string());
772        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
773
774        // Child component (same in both)
775        let child = Component::new("child".to_string(), Some("1.0".to_string()));
776
777        old.components
778            .insert(parent_old.id.clone(), parent_old.clone());
779        old.components.insert(child.id.clone(), child.clone());
780
781        new.components
782            .insert(parent_new.id.clone(), parent_new.clone());
783        new.components.insert(child.id.clone(), child.clone());
784
785        // Old: parent -> child
786        old.dependencies
787            .entry(parent_old.id.clone())
788            .or_default()
789            .insert(child.id.clone());
790
791        // New: parent -> child (same edge, but parent has different ID)
792        new.dependencies
793            .entry(parent_new.id.clone())
794            .or_default()
795            .insert(child.id.clone());
796
797        let diff = Differ::diff(&old, &new, None);
798
799        // Components should be matched by identity, so no spurious edge changes
800        // (the edge parent->child exists in both, just under different parent IDs)
801        assert_eq!(
802            diff.edge_diffs.len(),
803            0,
804            "No edge changes expected when parent is reconciled by identity"
805        );
806    }
807
808    #[test]
809    fn test_edge_diff_filtering() {
810        // Test that --only filtering excludes edge diffs when deps not included
811        let mut old = Sbom::default();
812        let mut new = Sbom::default();
813
814        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
815        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
816
817        let parent_id = c1.id.clone();
818        let child_id = c2.id.clone();
819
820        old.components.insert(c1.id.clone(), c1.clone());
821        old.components.insert(c2.id.clone(), c2.clone());
822
823        new.components.insert(c1.id.clone(), c1);
824        new.components.insert(c2.id.clone(), c2);
825
826        // New has an edge that old doesn't
827        new.dependencies
828            .entry(parent_id.clone())
829            .or_default()
830            .insert(child_id);
831
832        // Without filtering - should have edge diff
833        let diff = Differ::diff(&old, &new, None);
834        assert_eq!(diff.edge_diffs.len(), 1);
835
836        // With filtering to only Version - should NOT have edge diff
837        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
838        assert_eq!(diff_filtered.edge_diffs.len(), 0);
839
840        // With filtering to include Deps - should have edge diff
841        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
842        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
843    }
844}