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_filtering() {
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
418        let mut c2 = c1.clone();
419        c2.version = Some("1.1".to_string());
420        c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
421
422        old.components.insert(c1.id.clone(), c1);
423        new.components.insert(c2.id.clone(), c2);
424
425        let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
426        assert_eq!(diff.changed.len(), 1);
427        assert_eq!(diff.changed[0].changes.len(), 1);
428        assert!(matches!(
429            diff.changed[0].changes[0],
430            FieldChange::Version(_, _)
431        ));
432    }
433
434    #[test]
435    fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
436        // Component with purl in old, different purl in new (same ecosystem+name)
437        // Should be treated as a CHANGE with Purl field change, not add/remove
438        let mut old = Sbom::default();
439        let mut new = Sbom::default();
440
441        // Old: lodash with one purl
442        let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
443        c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
444        c_old.ecosystem = Some("npm".to_string());
445        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
446
447        // New: lodash with updated purl (version bump)
448        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
449        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
450        c_new.ecosystem = Some("npm".to_string());
451        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
452
453        old.components.insert(c_old.id.clone(), c_old);
454        new.components.insert(c_new.id.clone(), c_new);
455
456        let diff = Differ::diff(&old, &new, None);
457
458        // Should NOT be add/remove
459        assert_eq!(diff.added.len(), 0, "Should not have added components");
460        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
461
462        // Should be a change
463        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
464
465        // Should include both Version and Purl changes
466        let changes = &diff.changed[0].changes;
467        assert!(changes
468            .iter()
469            .any(|c| matches!(c, FieldChange::Version(_, _))));
470        assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
471    }
472
473    #[test]
474    fn test_purl_removed_is_change() {
475        // Component with purl in old, no purl in new (same name)
476        // This is realistic: old SBOM from tool that adds purls, new from tool that doesn't
477        let mut old = Sbom::default();
478        let mut new = Sbom::default();
479
480        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
481        c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
482        c_old.ecosystem = Some("npm".to_string()); // Extracted from purl
483        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
484
485        // New component without purl - ecosystem is None (realistic!)
486        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
487        c_new.purl = None;
488        c_new.ecosystem = None; // No purl means no ecosystem extraction
489                                // ID will be hash-based since no purl
490        c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
491
492        old.components.insert(c_old.id.clone(), c_old);
493        new.components.insert(c_new.id.clone(), c_new);
494
495        let diff = Differ::diff(&old, &new, None);
496
497        assert_eq!(diff.added.len(), 0, "Should not have added components");
498        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
499        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
500
501        // Should have Purl change
502        assert!(diff.changed[0]
503            .changes
504            .iter()
505            .any(|c| matches!(c, FieldChange::Purl(_, _))));
506    }
507
508    #[test]
509    fn test_purl_added_is_change() {
510        // Component with no purl in old, purl in new
511        // This is realistic: old SBOM without purls, new from better tooling
512        let mut old = Sbom::default();
513        let mut new = Sbom::default();
514
515        let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
516        c_old.purl = None;
517        c_old.ecosystem = None; // No purl means no ecosystem (realistic!)
518        c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
519
520        let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
521        c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
522        c_new.ecosystem = Some("npm".to_string()); // Extracted from purl
523        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
524
525        old.components.insert(c_old.id.clone(), c_old);
526        new.components.insert(c_new.id.clone(), c_new);
527
528        let diff = Differ::diff(&old, &new, None);
529
530        assert_eq!(diff.added.len(), 0, "Should not have added components");
531        assert_eq!(diff.removed.len(), 0, "Should not have removed components");
532        assert_eq!(diff.changed.len(), 1, "Should have one changed component");
533    }
534
535    #[test]
536    fn test_same_name_different_ecosystems_not_matched() {
537        // Two components with same name but different ecosystems should NOT match
538        let mut old = Sbom::default();
539        let mut new = Sbom::default();
540
541        // Old: "utils" from npm
542        let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
543        c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
544        c_old.ecosystem = Some("npm".to_string());
545        c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
546
547        // New: "utils" from pypi (different ecosystem!)
548        let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
549        c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
550        c_new.ecosystem = Some("pypi".to_string());
551        c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
552
553        old.components.insert(c_old.id.clone(), c_old);
554        new.components.insert(c_new.id.clone(), c_new);
555
556        let diff = Differ::diff(&old, &new, None);
557
558        // Should be separate add/remove, NOT a change
559        assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
560        assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
561        assert_eq!(
562            diff.changed.len(),
563            0,
564            "Should not match different ecosystems"
565        );
566    }
567
568    #[test]
569    fn test_same_name_both_no_ecosystem_matched() {
570        // Components with same name and both having None ecosystem should match
571        // (backwards compatibility for SBOMs without purls)
572        let mut old = Sbom::default();
573        let mut new = Sbom::default();
574
575        let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
576        c_old.ecosystem = None;
577
578        let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
579        c_new.ecosystem = None;
580
581        old.components.insert(c_old.id.clone(), c_old);
582        new.components.insert(c_new.id.clone(), c_new);
583
584        let diff = Differ::diff(&old, &new, None);
585
586        assert_eq!(diff.added.len(), 0);
587        assert_eq!(diff.removed.len(), 0);
588        assert_eq!(
589            diff.changed.len(),
590            1,
591            "Same name with None ecosystems should match"
592        );
593    }
594
595    #[test]
596    fn test_edge_diff_added_removed() {
597        let mut old = Sbom::default();
598        let mut new = Sbom::default();
599
600        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
601        let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
602        let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
603
604        let parent_id = c1.id.clone();
605        let child_a_id = c2.id.clone();
606        let child_b_id = c3.id.clone();
607
608        // Add all components to both SBOMs
609        old.components.insert(c1.id.clone(), c1.clone());
610        old.components.insert(c2.id.clone(), c2.clone());
611        old.components.insert(c3.id.clone(), c3.clone());
612
613        new.components.insert(c1.id.clone(), c1);
614        new.components.insert(c2.id.clone(), c2);
615        new.components.insert(c3.id.clone(), c3);
616
617        // Old: parent -> child-a
618        old.dependencies
619            .entry(parent_id.clone())
620            .or_default()
621            .insert(child_a_id.clone());
622
623        // New: parent -> child-b (removed child-a, added child-b)
624        new.dependencies
625            .entry(parent_id.clone())
626            .or_default()
627            .insert(child_b_id.clone());
628
629        let diff = Differ::diff(&old, &new, None);
630
631        assert_eq!(diff.edge_diffs.len(), 1);
632        assert_eq!(diff.edge_diffs[0].parent, parent_id);
633        assert!(diff.edge_diffs[0].added.contains(&child_b_id));
634        assert!(diff.edge_diffs[0].removed.contains(&child_a_id));
635    }
636
637    #[test]
638    fn test_edge_diff_with_identity_reconciliation() {
639        // Test that edge diffs work when components are matched by identity
640        // (different IDs but same name/ecosystem)
641        let mut old = Sbom::default();
642        let mut new = Sbom::default();
643
644        // Parent with purl in old
645        let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
646        parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
647        parent_old.ecosystem = Some("npm".to_string());
648        parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
649
650        // Parent with different purl in new (same name/ecosystem)
651        let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
652        parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
653        parent_new.ecosystem = Some("npm".to_string());
654        parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
655
656        // Child component (same in both)
657        let child = Component::new("child".to_string(), Some("1.0".to_string()));
658
659        old.components
660            .insert(parent_old.id.clone(), parent_old.clone());
661        old.components.insert(child.id.clone(), child.clone());
662
663        new.components
664            .insert(parent_new.id.clone(), parent_new.clone());
665        new.components.insert(child.id.clone(), child.clone());
666
667        // Old: parent -> child
668        old.dependencies
669            .entry(parent_old.id.clone())
670            .or_default()
671            .insert(child.id.clone());
672
673        // New: parent -> child (same edge, but parent has different ID)
674        new.dependencies
675            .entry(parent_new.id.clone())
676            .or_default()
677            .insert(child.id.clone());
678
679        let diff = Differ::diff(&old, &new, None);
680
681        // Components should be matched by identity, so no spurious edge changes
682        // (the edge parent->child exists in both, just under different parent IDs)
683        assert_eq!(
684            diff.edge_diffs.len(),
685            0,
686            "No edge changes expected when parent is reconciled by identity"
687        );
688    }
689
690    #[test]
691    fn test_edge_diff_filtering() {
692        // Test that --only filtering excludes edge diffs when deps not included
693        let mut old = Sbom::default();
694        let mut new = Sbom::default();
695
696        let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
697        let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
698
699        let parent_id = c1.id.clone();
700        let child_id = c2.id.clone();
701
702        old.components.insert(c1.id.clone(), c1.clone());
703        old.components.insert(c2.id.clone(), c2.clone());
704
705        new.components.insert(c1.id.clone(), c1);
706        new.components.insert(c2.id.clone(), c2);
707
708        // New has an edge that old doesn't
709        new.dependencies
710            .entry(parent_id.clone())
711            .or_default()
712            .insert(child_id);
713
714        // Without filtering - should have edge diff
715        let diff = Differ::diff(&old, &new, None);
716        assert_eq!(diff.edge_diffs.len(), 1);
717
718        // With filtering to only Version - should NOT have edge diff
719        let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
720        assert_eq!(diff_filtered.edge_diffs.len(), 0);
721
722        // With filtering to include Deps - should have edge diff
723        let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
724        assert_eq!(diff_with_deps.edge_diffs.len(), 1);
725    }
726}