1#![doc = include_str!("../readme.md")]
2
3use sbom_model::{Component, ComponentId, DependencyKind, Sbom};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7pub mod renderer;
8
9#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct Diff {
23 pub added: Vec<Component>,
25 pub removed: Vec<Component>,
27 pub changed: Vec<ComponentChange>,
29 pub edge_diffs: Vec<EdgeDiff>,
31 pub metadata_changed: bool,
33 pub old_total: usize,
35 pub new_total: usize,
37 pub unchanged: usize,
39 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
44 pub component_names: BTreeMap<ComponentId, String>,
45}
46
47impl Diff {
48 pub fn is_empty(&self) -> bool {
50 self.added.is_empty()
51 && self.removed.is_empty()
52 && self.changed.is_empty()
53 && self.edge_diffs.is_empty()
54 && !self.metadata_changed
55 }
56
57 pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
61 self.component_names
62 .get(id)
63 .map(String::as_str)
64 .unwrap_or_else(|| id.as_str())
65 }
66
67 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
71 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
72
73 for comp in &self.added {
74 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
75 breakdown.entry(eco).or_default().added += 1;
76 }
77
78 for comp in &self.removed {
79 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
80 breakdown.entry(eco).or_default().removed += 1;
81 }
82
83 for change in &self.changed {
84 let eco = change
85 .new
86 .ecosystem
87 .as_deref()
88 .unwrap_or("unknown")
89 .to_string();
90 breakdown.entry(eco).or_default().changed += 1;
91 }
92
93 breakdown
94 }
95
96 pub fn group_by_ecosystem(&self) -> GroupedDiff {
100 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
101
102 for c in &self.added {
103 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
104 ecosystems.entry(eco).or_default().added.push(c.clone());
105 }
106 for c in &self.removed {
107 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
108 ecosystems.entry(eco).or_default().removed.push(c.clone());
109 }
110 for c in &self.changed {
111 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
112 ecosystems.entry(eco).or_default().changed.push(c.clone());
113 }
114
115 GroupedDiff {
116 by_ecosystem: ecosystems,
117 edge_diffs: self.edge_diffs.clone(),
118 metadata_changed: self.metadata_changed,
119 }
120 }
121
122 pub fn into_group_by_ecosystem(self) -> GroupedDiff {
125 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
126
127 for c in self.added {
128 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
129 ecosystems.entry(eco).or_default().added.push(c);
130 }
131 for c in self.removed {
132 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
133 ecosystems.entry(eco).or_default().removed.push(c);
134 }
135 for c in self.changed {
136 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
137 ecosystems.entry(eco).or_default().changed.push(c);
138 }
139
140 GroupedDiff {
141 by_ecosystem: ecosystems,
142 edge_diffs: self.edge_diffs,
143 metadata_changed: self.metadata_changed,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct GroupedDiff {
151 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
152 pub edge_diffs: Vec<EdgeDiff>,
153 pub metadata_changed: bool,
154}
155
156impl GroupedDiff {
157 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
163 self.by_ecosystem
164 .iter()
165 .map(|(eco, eco_diff)| {
166 (
167 eco.clone(),
168 EcosystemCounts {
169 added: eco_diff.added.len(),
170 removed: eco_diff.removed.len(),
171 changed: eco_diff.changed.len(),
172 },
173 )
174 })
175 .collect()
176 }
177}
178
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct EcosystemDiff {
182 pub added: Vec<Component>,
183 pub removed: Vec<Component>,
184 pub changed: Vec<ComponentChange>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ComponentChange {
190 pub id: ComponentId,
192 pub old: Component,
194 pub new: Component,
196 pub changes: Vec<FieldChange>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct EdgeDiff {
203 pub parent: ComponentId,
205 pub added: BTreeMap<ComponentId, DependencyKind>,
207 pub removed: BTreeMap<ComponentId, DependencyKind>,
209 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211 pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216pub enum FieldChange {
217 Version(String, String),
219 License(BTreeSet<String>, BTreeSet<String>),
221 Supplier(Option<String>, Option<String>),
223 Purl(Option<String>, Option<String>),
225 Description(Option<String>, Option<String>),
227 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
229 Ecosystem(Option<String>, Option<String>),
231}
232
233#[derive(Debug, Copy, Clone, PartialEq, Eq)]
237pub enum Field {
238 Version,
240 License,
242 Supplier,
244 Purl,
246 Description,
248 Hashes,
250 Ecosystem,
252 Deps,
254}
255
256pub struct Differ;
261
262impl Differ {
263 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
290 let mut old = old.clone();
291 let mut new = new.clone();
292
293 let metadata_changed = old.metadata != new.metadata;
295
296 old.normalize();
297 new.normalize();
298
299 let mut added = Vec::new();
300 let mut removed = Vec::new();
301 let mut changed = Vec::new();
302
303 let mut processed_old = HashSet::new();
304 let mut processed_new = HashSet::new();
305
306 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
308
309 for (id, new_comp) in &new.components {
311 if let Some(old_comp) = old.components.get(id) {
312 processed_old.insert(id.clone());
313 processed_new.insert(id.clone());
314 id_mapping.insert(id.clone(), id.clone());
315
316 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
317 changed.push(change);
318 }
319 }
320 }
321
322 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
331 BTreeMap::new();
332 for (id, comp) in &old.components {
333 if !processed_old.contains(id) {
334 old_identity_map
335 .entry(comp.name.clone())
336 .or_default()
337 .entry(comp.ecosystem.clone())
338 .or_default()
339 .push(id.clone());
340 }
341 }
342
343 for (id, new_comp) in &new.components {
344 if processed_new.contains(id) {
345 continue;
346 }
347
348 let matched_old_id = old_identity_map
353 .get_mut(&new_comp.name)
354 .and_then(|eco_map| {
355 eco_map
357 .get_mut(&new_comp.ecosystem)
358 .and_then(|ids| ids.pop())
359 .or_else(|| {
360 if new_comp.ecosystem.is_some() {
361 eco_map.get_mut(&None).and_then(|ids| ids.pop())
363 } else {
364 eco_map.values_mut().find_map(|ids| ids.pop())
366 }
367 })
368 });
369
370 if let Some(old_id) = matched_old_id {
371 if let Some(old_comp) = old.components.get(&old_id) {
372 processed_old.insert(old_id.clone());
373 processed_new.insert(id.clone());
374 id_mapping.insert(old_id.clone(), id.clone());
375
376 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
377 changed.push(change);
378 }
379 continue;
380 }
381 }
382
383 added.push(new_comp.clone());
384 processed_new.insert(id.clone());
385 }
386
387 for (id, old_comp) in &old.components {
388 if !processed_old.contains(id) {
389 removed.push(old_comp.clone());
390 }
391 }
392
393 let old_total = old.components.len();
395 let new_total = new.components.len();
396 let matched = processed_old.len();
398 let unchanged = matched - changed.len();
399
400 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
402 let edge_diffs = if should_include_deps {
403 Self::compute_edge_diffs(&old, &new, &id_mapping)
404 } else {
405 Vec::new()
406 };
407
408 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
410
411 Diff {
412 added,
413 removed,
414 changed,
415 edge_diffs,
416 metadata_changed,
417 old_total,
418 new_total,
419 unchanged,
420 component_names,
421 }
422 }
423
424 fn compute_edge_diffs(
431 old: &Sbom,
432 new: &Sbom,
433 id_mapping: &BTreeMap<ComponentId, ComponentId>,
434 ) -> Vec<EdgeDiff> {
435 let mut edge_diffs = Vec::new();
436
437 let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
441 .iter()
442 .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
443 .collect();
444
445 let translate_id = |old_id: &ComponentId| -> ComponentId {
447 id_mapping
448 .get(old_id)
449 .cloned()
450 .unwrap_or_else(|| old_id.clone())
451 };
452
453 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
456
457 for old_parent in old.dependencies.keys() {
459 all_parents.insert(translate_id(old_parent));
460 }
461
462 for parent_id in all_parents {
463 let new_children: BTreeMap<ComponentId, DependencyKind> = new
465 .dependencies
466 .get(&parent_id)
467 .cloned()
468 .unwrap_or_default();
469
470 let old_parent_id = reverse_mapping
473 .get(&parent_id)
474 .cloned()
475 .unwrap_or_else(|| parent_id.clone());
476
477 let old_children: BTreeMap<ComponentId, DependencyKind> = old
478 .dependencies
479 .get(&old_parent_id)
480 .map(|children| {
481 children
482 .iter()
483 .map(|(id, kind)| (translate_id(id), *kind))
484 .collect()
485 })
486 .unwrap_or_default();
487
488 let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
489 let old_keys: BTreeSet<&ComponentId> = old_children.keys().collect();
490
491 let added: BTreeMap<ComponentId, DependencyKind> = new_keys
493 .difference(&old_keys)
494 .map(|&id| (id.clone(), new_children[id]))
495 .collect();
496 let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
497 .difference(&new_keys)
498 .map(|&id| (id.clone(), old_children[id]))
499 .collect();
500
501 let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
503 .intersection(&old_keys)
504 .filter_map(|&id| {
505 let old_kind = old_children[id];
506 let new_kind = new_children[id];
507 if old_kind != new_kind {
508 Some((id.clone(), (old_kind, new_kind)))
509 } else {
510 None
511 }
512 })
513 .collect();
514
515 if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
516 edge_diffs.push(EdgeDiff {
517 parent: parent_id,
518 added,
519 removed,
520 kind_changed,
521 });
522 }
523 }
524
525 edge_diffs
526 }
527
528 fn build_component_names(
533 old: &Sbom,
534 new: &Sbom,
535 edge_diffs: &[EdgeDiff],
536 ) -> BTreeMap<ComponentId, String> {
537 let mut names = BTreeMap::new();
538
539 let mut ids = BTreeSet::new();
541 for edge in edge_diffs {
542 ids.insert(&edge.parent);
543 ids.extend(edge.added.keys());
544 ids.extend(edge.removed.keys());
545 ids.extend(edge.kind_changed.keys());
546 }
547
548 for id in ids {
550 if !id.as_str().starts_with("h:") {
551 continue;
552 }
553
554 let comp = new.components.get(id).or_else(|| old.components.get(id));
556 if let Some(comp) = comp {
557 let display = match &comp.version {
558 Some(v) => format!("{}@{}", comp.name, v),
559 None => comp.name.clone(),
560 };
561 names.insert(id.clone(), display);
562 }
563 }
564
565 names
566 }
567
568 fn compute_change(
569 old: &Component,
570 new: &Component,
571 only: Option<&[Field]>,
572 ) -> Option<ComponentChange> {
573 let mut changes = Vec::new();
574
575 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
576
577 if should_include(Field::Version) && old.version != new.version {
578 changes.push(FieldChange::Version(
579 old.version.clone().unwrap_or_default(),
580 new.version.clone().unwrap_or_default(),
581 ));
582 }
583
584 if should_include(Field::License) && old.licenses != new.licenses {
585 changes.push(FieldChange::License(
586 old.licenses.clone(),
587 new.licenses.clone(),
588 ));
589 }
590
591 if should_include(Field::Supplier) && old.supplier != new.supplier {
592 changes.push(FieldChange::Supplier(
593 old.supplier.clone(),
594 new.supplier.clone(),
595 ));
596 }
597
598 if should_include(Field::Purl) && old.purl != new.purl {
599 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
600 }
601
602 if should_include(Field::Description) && old.description != new.description {
603 changes.push(FieldChange::Description(
604 old.description.clone(),
605 new.description.clone(),
606 ));
607 }
608
609 if should_include(Field::Hashes) && old.hashes != new.hashes {
610 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
611 }
612
613 if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
614 changes.push(FieldChange::Ecosystem(
615 old.ecosystem.clone(),
616 new.ecosystem.clone(),
617 ));
618 }
619
620 if changes.is_empty() {
621 None
622 } else {
623 Some(ComponentChange {
624 id: new.id.clone(),
625 old: old.clone(),
626 new: new.clone(),
627 changes,
628 })
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn test_diff_added_removed() {
639 let mut old = Sbom::default();
640 let mut new = Sbom::default();
641
642 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
643 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
644
645 old.components.insert(c1.id.clone(), c1);
646 new.components.insert(c2.id.clone(), c2);
647
648 let diff = Differ::diff(&old, &new, None);
649 assert_eq!(diff.added.len(), 1);
650 assert_eq!(diff.removed.len(), 1);
651 assert_eq!(diff.changed.len(), 0);
652 }
653
654 #[test]
655 fn test_diff_changed() {
656 let mut old = Sbom::default();
657 let mut new = Sbom::default();
658
659 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
660 let mut c2 = c1.clone();
661 c2.version = Some("1.1".to_string());
662
663 old.components.insert(c1.id.clone(), c1);
664 new.components.insert(c2.id.clone(), c2);
665
666 let diff = Differ::diff(&old, &new, None);
667 assert_eq!(diff.added.len(), 0);
668 assert_eq!(diff.removed.len(), 0);
669 assert_eq!(diff.changed.len(), 1);
670 assert!(matches!(
671 diff.changed[0].changes[0],
672 FieldChange::Version(_, _)
673 ));
674 }
675
676 #[test]
677 fn test_diff_identity_reconciliation() {
678 let mut old = Sbom::default();
679 let mut new = Sbom::default();
680
681 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
682 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
683
684 old.components.insert(c1.id.clone(), c1);
685 new.components.insert(c2.id.clone(), c2);
686
687 let diff = Differ::diff(&old, &new, None);
688 assert_eq!(diff.changed.len(), 1);
689 assert_eq!(diff.added.len(), 0);
690 }
691
692 #[test]
693 fn test_diff_license_change() {
694 let mut old = Sbom::default();
695 let mut new = Sbom::default();
696
697 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
698 c1.licenses.insert("MIT".into());
699 let mut c2 = c1.clone();
700 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
701
702 old.components.insert(c1.id.clone(), c1);
703 new.components.insert(c2.id.clone(), c2);
704
705 let diff = Differ::diff(&old, &new, None);
706 assert_eq!(diff.changed.len(), 1);
707 assert!(diff.changed[0]
708 .changes
709 .iter()
710 .any(|c| matches!(c, FieldChange::License(_, _))));
711 }
712
713 #[test]
714 fn test_diff_supplier_change() {
715 let mut old = Sbom::default();
716 let mut new = Sbom::default();
717
718 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
719 c1.supplier = Some("Acme Corp".into());
720 let mut c2 = c1.clone();
721 c2.supplier = Some("New Corp".into());
722
723 old.components.insert(c1.id.clone(), c1);
724 new.components.insert(c2.id.clone(), c2);
725
726 let diff = Differ::diff(&old, &new, None);
727 assert_eq!(diff.changed.len(), 1);
728 assert!(diff.changed[0]
729 .changes
730 .iter()
731 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
732 }
733
734 #[test]
735 fn test_diff_hashes_change() {
736 let mut old = Sbom::default();
737 let mut new = Sbom::default();
738
739 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
740 c1.hashes.insert("sha256".into(), "aaa".into());
741 let mut c2 = c1.clone();
742 c2.hashes.insert("sha256".into(), "bbb".into());
743
744 old.components.insert(c1.id.clone(), c1);
745 new.components.insert(c2.id.clone(), c2);
746
747 let diff = Differ::diff(&old, &new, None);
748 assert_eq!(diff.changed.len(), 1);
749 assert!(diff.changed[0]
750 .changes
751 .iter()
752 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
753 }
754
755 #[test]
756 fn test_diff_description_change() {
757 let mut old = Sbom::default();
758 let mut new = Sbom::default();
759
760 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
761 c1.description = Some("Old description".into());
762 let mut c2 = c1.clone();
763 c2.description = Some("New description".into());
764
765 old.components.insert(c1.id.clone(), c1);
766 new.components.insert(c2.id.clone(), c2);
767
768 let diff = Differ::diff(&old, &new, None);
769 assert_eq!(diff.changed.len(), 1);
770 assert!(diff.changed[0]
771 .changes
772 .iter()
773 .any(|c| matches!(c, FieldChange::Description(_, _))));
774 }
775
776 #[test]
777 fn test_diff_description_added() {
778 let mut old = Sbom::default();
779 let mut new = Sbom::default();
780
781 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
782 let mut c2 = c1.clone();
783 c2.description = Some("A new description".into());
784
785 old.components.insert(c1.id.clone(), c1);
786 new.components.insert(c2.id.clone(), c2);
787
788 let diff = Differ::diff(&old, &new, None);
789 assert_eq!(diff.changed.len(), 1);
790 assert!(diff.changed[0]
791 .changes
792 .iter()
793 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
794 }
795
796 #[test]
797 fn test_diff_description_removed() {
798 let mut old = Sbom::default();
799 let mut new = Sbom::default();
800
801 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
802 c1.description = Some("Had a description".into());
803 let mut c2 = c1.clone();
804 c2.description = None;
805
806 old.components.insert(c1.id.clone(), c1);
807 new.components.insert(c2.id.clone(), c2);
808
809 let diff = Differ::diff(&old, &new, None);
810 assert_eq!(diff.changed.len(), 1);
811 assert!(diff.changed[0]
812 .changes
813 .iter()
814 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
815 }
816
817 #[test]
818 fn test_diff_description_unchanged() {
819 let mut old = Sbom::default();
820 let mut new = Sbom::default();
821
822 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
823 c1.description = Some("Same description".into());
824 let c2 = c1.clone();
825
826 old.components.insert(c1.id.clone(), c1);
827 new.components.insert(c2.id.clone(), c2);
828
829 let diff = Differ::diff(&old, &new, None);
830 assert!(diff.changed.is_empty());
831 }
832
833 #[test]
834 fn test_diff_description_filtering() {
835 let mut old = Sbom::default();
836 let mut new = Sbom::default();
837
838 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
839 c1.description = Some("Old".into());
840 let mut c2 = c1.clone();
841 c2.version = Some("2.0".into());
842 c2.description = Some("New".into());
843
844 old.components.insert(c1.id.clone(), c1);
845 new.components.insert(c2.id.clone(), c2);
846
847 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
849 assert_eq!(diff.changed.len(), 1);
850 assert_eq!(diff.changed[0].changes.len(), 1);
851 assert!(matches!(
852 diff.changed[0].changes[0],
853 FieldChange::Description(_, _)
854 ));
855
856 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
858 assert_eq!(diff.changed.len(), 1);
859 assert_eq!(diff.changed[0].changes.len(), 1);
860 assert!(matches!(
861 diff.changed[0].changes[0],
862 FieldChange::Version(_, _)
863 ));
864 }
865
866 #[test]
867 fn test_diff_ecosystem_change() {
868 let mut old = Sbom::default();
869 let mut new = Sbom::default();
870
871 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
872 c1.ecosystem = Some("npm".to_string());
873 let mut c2 = c1.clone();
874 c2.ecosystem = Some("cargo".to_string());
875
876 old.components.insert(c1.id.clone(), c1);
877 new.components.insert(c2.id.clone(), c2);
878
879 let diff = Differ::diff(&old, &new, None);
880 assert_eq!(diff.changed.len(), 1);
881 assert_eq!(diff.changed[0].changes.len(), 1);
882 assert!(matches!(
883 diff.changed[0].changes[0],
884 FieldChange::Ecosystem(_, _)
885 ));
886
887 if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
888 assert_eq!(o.as_deref(), Some("npm"));
889 assert_eq!(n.as_deref(), Some("cargo"));
890 }
891 }
892
893 #[test]
894 fn test_diff_ecosystem_change_from_none() {
895 let mut old = Sbom::default();
896 let mut new = Sbom::default();
897
898 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
899 let mut c2 = c1.clone();
900 c2.ecosystem = Some("npm".to_string());
901
902 old.components.insert(c1.id.clone(), c1);
903 new.components.insert(c2.id.clone(), c2);
904
905 let diff = Differ::diff(&old, &new, None);
906 assert_eq!(diff.changed.len(), 1);
907 assert_eq!(diff.changed[0].changes.len(), 1);
908 assert!(matches!(
909 diff.changed[0].changes[0],
910 FieldChange::Ecosystem(None, Some(_))
911 ));
912 }
913
914 #[test]
915 fn test_diff_ecosystem_filtering() {
916 let mut old = Sbom::default();
917 let mut new = Sbom::default();
918
919 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
920 c1.ecosystem = Some("npm".to_string());
921 let mut c2 = c1.clone();
922 c2.version = Some("2.0".into());
923 c2.ecosystem = Some("cargo".to_string());
924
925 old.components.insert(c1.id.clone(), c1);
926 new.components.insert(c2.id.clone(), c2);
927
928 let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
930 assert_eq!(diff.changed.len(), 1);
931 assert_eq!(diff.changed[0].changes.len(), 1);
932 assert!(matches!(
933 diff.changed[0].changes[0],
934 FieldChange::Ecosystem(_, _)
935 ));
936
937 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
939 assert_eq!(diff.changed.len(), 1);
940 assert_eq!(diff.changed[0].changes.len(), 1);
941 assert!(matches!(
942 diff.changed[0].changes[0],
943 FieldChange::Version(_, _)
944 ));
945 }
946
947 #[test]
948 fn test_diff_ecosystem_no_change() {
949 let mut old = Sbom::default();
950 let mut new = Sbom::default();
951
952 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
953 c1.ecosystem = Some("npm".to_string());
954 let c2 = c1.clone();
955
956 old.components.insert(c1.id.clone(), c1);
957 new.components.insert(c2.id.clone(), c2);
958
959 let diff = Differ::diff(&old, &new, None);
960 assert!(diff.changed.is_empty());
961 }
962
963 #[test]
964 fn test_diff_multiple_field_changes() {
965 let mut old = Sbom::default();
966 let mut new = Sbom::default();
967
968 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
969 c1.licenses.insert("MIT".into());
970 c1.supplier = Some("Old Corp".into());
971 c1.hashes.insert("sha256".into(), "aaa".into());
972
973 let mut c2 = c1.clone();
974 c2.version = Some("2.0".into());
975 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
976 c2.supplier = Some("New Corp".into());
977 c2.hashes.insert("sha256".into(), "bbb".into());
978
979 old.components.insert(c1.id.clone(), c1);
980 new.components.insert(c2.id.clone(), c2);
981
982 let diff = Differ::diff(&old, &new, None);
983 assert_eq!(diff.changed.len(), 1);
984 assert_eq!(diff.changed[0].changes.len(), 4);
985 }
986
987 #[test]
988 fn test_diff_no_changes() {
989 let mut old = Sbom::default();
990 let mut new = Sbom::default();
991
992 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
993 old.components.insert(c.id.clone(), c.clone());
994 new.components.insert(c.id.clone(), c);
995
996 let diff = Differ::diff(&old, &new, None);
997 assert!(diff.added.is_empty());
998 assert!(diff.removed.is_empty());
999 assert!(diff.changed.is_empty());
1000 assert!(diff.edge_diffs.is_empty());
1001 }
1002
1003 #[test]
1004 fn test_diff_metadata_changed_timestamp() {
1005 let mut old = Sbom::default();
1006 let mut new = Sbom::default();
1007
1008 old.metadata.timestamp = Some("2024-01-01".into());
1009 new.metadata.timestamp = Some("2024-01-02".into());
1010
1011 let diff = Differ::diff(&old, &new, None);
1012 assert!(diff.metadata_changed);
1013 assert!(!diff.is_empty());
1014 }
1015
1016 #[test]
1017 fn test_diff_metadata_changed_tools() {
1018 let mut old = Sbom::default();
1019 let mut new = Sbom::default();
1020
1021 old.metadata.tools = vec!["syft".into()];
1022 new.metadata.tools = vec!["trivy".into()];
1023
1024 let diff = Differ::diff(&old, &new, None);
1025 assert!(diff.metadata_changed);
1026 }
1027
1028 #[test]
1029 fn test_diff_metadata_changed_authors() {
1030 let mut old = Sbom::default();
1031 let mut new = Sbom::default();
1032
1033 old.metadata.authors = vec!["alice".into()];
1034 new.metadata.authors = vec!["bob".into()];
1035
1036 let diff = Differ::diff(&old, &new, None);
1037 assert!(diff.metadata_changed);
1038 }
1039
1040 #[test]
1041 fn test_diff_metadata_unchanged() {
1042 let mut old = Sbom::default();
1043 let mut new = Sbom::default();
1044
1045 old.metadata.timestamp = Some("2024-01-01".into());
1046 new.metadata.timestamp = Some("2024-01-01".into());
1047 old.metadata.tools = vec!["syft".into()];
1048 new.metadata.tools = vec!["syft".into()];
1049
1050 let diff = Differ::diff(&old, &new, None);
1051 assert!(!diff.metadata_changed);
1052 }
1053
1054 #[test]
1055 fn test_diff_filtering() {
1056 let mut old = Sbom::default();
1057 let mut new = Sbom::default();
1058
1059 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1060 c1.licenses.insert("MIT".into());
1061
1062 let mut c2 = c1.clone();
1063 c2.version = Some("1.1".to_string());
1064 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1065
1066 old.components.insert(c1.id.clone(), c1);
1067 new.components.insert(c2.id.clone(), c2);
1068
1069 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1070 assert_eq!(diff.changed.len(), 1);
1071 assert_eq!(diff.changed[0].changes.len(), 1);
1072 assert!(matches!(
1073 diff.changed[0].changes[0],
1074 FieldChange::Version(_, _)
1075 ));
1076 }
1077
1078 #[test]
1079 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1080 let mut old = Sbom::default();
1083 let mut new = Sbom::default();
1084
1085 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1087 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1088 c_old.ecosystem = Some("npm".to_string());
1089 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1090
1091 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1093 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1094 c_new.ecosystem = Some("npm".to_string());
1095 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1096
1097 old.components.insert(c_old.id.clone(), c_old);
1098 new.components.insert(c_new.id.clone(), c_new);
1099
1100 let diff = Differ::diff(&old, &new, None);
1101
1102 assert_eq!(diff.added.len(), 0, "Should not have added components");
1104 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1105
1106 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1108
1109 let changes = &diff.changed[0].changes;
1111 assert!(changes
1112 .iter()
1113 .any(|c| matches!(c, FieldChange::Version(_, _))));
1114 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1115 }
1116
1117 #[test]
1118 fn test_purl_removed_is_change() {
1119 let mut old = Sbom::default();
1122 let mut new = Sbom::default();
1123
1124 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1125 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1126 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1128
1129 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1131 c_new.purl = None;
1132 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1135
1136 old.components.insert(c_old.id.clone(), c_old);
1137 new.components.insert(c_new.id.clone(), c_new);
1138
1139 let diff = Differ::diff(&old, &new, None);
1140
1141 assert_eq!(diff.added.len(), 0, "Should not have added components");
1142 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1143 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1144
1145 assert!(diff.changed[0]
1147 .changes
1148 .iter()
1149 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1150 }
1151
1152 #[test]
1153 fn test_purl_added_is_change() {
1154 let mut old = Sbom::default();
1157 let mut new = Sbom::default();
1158
1159 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1160 c_old.purl = None;
1161 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1163
1164 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1165 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1166 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1168
1169 old.components.insert(c_old.id.clone(), c_old);
1170 new.components.insert(c_new.id.clone(), c_new);
1171
1172 let diff = Differ::diff(&old, &new, None);
1173
1174 assert_eq!(diff.added.len(), 0, "Should not have added components");
1175 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1176 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1177 }
1178
1179 #[test]
1180 fn test_same_name_different_ecosystems_not_matched() {
1181 let mut old = Sbom::default();
1183 let mut new = Sbom::default();
1184
1185 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1187 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1188 c_old.ecosystem = Some("npm".to_string());
1189 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1190
1191 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1193 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1194 c_new.ecosystem = Some("pypi".to_string());
1195 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1196
1197 old.components.insert(c_old.id.clone(), c_old);
1198 new.components.insert(c_new.id.clone(), c_new);
1199
1200 let diff = Differ::diff(&old, &new, None);
1201
1202 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1204 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1205 assert_eq!(
1206 diff.changed.len(),
1207 0,
1208 "Should not match different ecosystems"
1209 );
1210 }
1211
1212 #[test]
1213 fn test_same_name_both_no_ecosystem_matched() {
1214 let mut old = Sbom::default();
1217 let mut new = Sbom::default();
1218
1219 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1220 c_old.ecosystem = None;
1221
1222 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1223 c_new.ecosystem = None;
1224
1225 old.components.insert(c_old.id.clone(), c_old);
1226 new.components.insert(c_new.id.clone(), c_new);
1227
1228 let diff = Differ::diff(&old, &new, None);
1229
1230 assert_eq!(diff.added.len(), 0);
1231 assert_eq!(diff.removed.len(), 0);
1232 assert_eq!(
1233 diff.changed.len(),
1234 1,
1235 "Same name with None ecosystems should match"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_edge_diff_added_removed() {
1241 let mut old = Sbom::default();
1242 let mut new = Sbom::default();
1243
1244 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1245 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1246 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1247
1248 let parent_id = c1.id.clone();
1249 let child_a_id = c2.id.clone();
1250 let child_b_id = c3.id.clone();
1251
1252 old.components.insert(c1.id.clone(), c1.clone());
1254 old.components.insert(c2.id.clone(), c2.clone());
1255 old.components.insert(c3.id.clone(), c3.clone());
1256
1257 new.components.insert(c1.id.clone(), c1);
1258 new.components.insert(c2.id.clone(), c2);
1259 new.components.insert(c3.id.clone(), c3);
1260
1261 old.dependencies
1263 .entry(parent_id.clone())
1264 .or_default()
1265 .insert(child_a_id.clone(), DependencyKind::Runtime);
1266
1267 new.dependencies
1269 .entry(parent_id.clone())
1270 .or_default()
1271 .insert(child_b_id.clone(), DependencyKind::Runtime);
1272
1273 let diff = Differ::diff(&old, &new, None);
1274
1275 assert_eq!(diff.edge_diffs.len(), 1);
1276 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1277 assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1278 assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1279 }
1280
1281 #[test]
1282 fn test_edge_diff_with_identity_reconciliation() {
1283 let mut old = Sbom::default();
1286 let mut new = Sbom::default();
1287
1288 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1290 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1291 parent_old.ecosystem = Some("npm".to_string());
1292 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1293
1294 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1296 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1297 parent_new.ecosystem = Some("npm".to_string());
1298 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1299
1300 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1302
1303 old.components
1304 .insert(parent_old.id.clone(), parent_old.clone());
1305 old.components.insert(child.id.clone(), child.clone());
1306
1307 new.components
1308 .insert(parent_new.id.clone(), parent_new.clone());
1309 new.components.insert(child.id.clone(), child.clone());
1310
1311 old.dependencies
1313 .entry(parent_old.id.clone())
1314 .or_default()
1315 .insert(child.id.clone(), DependencyKind::Runtime);
1316
1317 new.dependencies
1319 .entry(parent_new.id.clone())
1320 .or_default()
1321 .insert(child.id.clone(), DependencyKind::Runtime);
1322
1323 let diff = Differ::diff(&old, &new, None);
1324
1325 assert_eq!(
1328 diff.edge_diffs.len(),
1329 0,
1330 "No edge changes expected when parent is reconciled by identity"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_edge_diff_filtering() {
1336 let mut old = Sbom::default();
1338 let mut new = Sbom::default();
1339
1340 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1341 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1342
1343 let parent_id = c1.id.clone();
1344 let child_id = c2.id.clone();
1345
1346 old.components.insert(c1.id.clone(), c1.clone());
1347 old.components.insert(c2.id.clone(), c2.clone());
1348
1349 new.components.insert(c1.id.clone(), c1);
1350 new.components.insert(c2.id.clone(), c2);
1351
1352 new.dependencies
1354 .entry(parent_id.clone())
1355 .or_default()
1356 .insert(child_id, DependencyKind::Runtime);
1357
1358 let diff = Differ::diff(&old, &new, None);
1360 assert_eq!(diff.edge_diffs.len(), 1);
1361
1362 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1364 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1365
1366 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1368 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1369 }
1370
1371 #[test]
1372 fn test_ecosystem_breakdown() {
1373 let mut old = Sbom::default();
1374 let mut new = Sbom::default();
1375
1376 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1378 c1.ecosystem = Some("npm".into());
1379 old.components.insert(c1.id.clone(), c1);
1380
1381 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1383 c2.ecosystem = Some("npm".into());
1384 new.components.insert(c2.id.clone(), c2);
1385
1386 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1388 c3.ecosystem = Some("cargo".into());
1389 new.components.insert(c3.id.clone(), c3);
1390
1391 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1393 c4_old.ecosystem = Some("npm".into());
1394 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1395 c4_new.ecosystem = Some("npm".into());
1396 old.components.insert(c4_old.id.clone(), c4_old);
1397 new.components.insert(c4_new.id.clone(), c4_new);
1398
1399 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1401 new.components.insert(c5.id.clone(), c5);
1402
1403 let diff = Differ::diff(&old, &new, None);
1404 let breakdown = diff.ecosystem_breakdown();
1405
1406 let npm = breakdown.get("npm").unwrap();
1407 assert_eq!(npm.added, 1);
1408 assert_eq!(npm.removed, 1);
1409 assert_eq!(npm.changed, 1);
1410
1411 let cargo = breakdown.get("cargo").unwrap();
1412 assert_eq!(cargo.added, 1);
1413 assert_eq!(cargo.removed, 0);
1414 assert_eq!(cargo.changed, 0);
1415
1416 let unknown = breakdown.get("unknown").unwrap();
1417 assert_eq!(unknown.added, 1);
1418 assert_eq!(unknown.removed, 0);
1419 assert_eq!(unknown.changed, 0);
1420 }
1421
1422 #[test]
1423 fn test_ecosystem_breakdown_empty_diff() {
1424 let old = Sbom::default();
1425 let new = Sbom::default();
1426
1427 let diff = Differ::diff(&old, &new, None);
1428 assert!(diff.is_empty());
1429 assert!(diff.ecosystem_breakdown().is_empty());
1430 }
1431
1432 #[test]
1433 fn test_group_by_ecosystem_empty_diff() {
1434 let old = Sbom::default();
1435 let new = Sbom::default();
1436
1437 let diff = Differ::diff(&old, &new, None);
1438 let grouped = diff.group_by_ecosystem();
1439 assert!(grouped.by_ecosystem.is_empty());
1440 assert!(grouped.edge_diffs.is_empty());
1441 assert!(!grouped.metadata_changed);
1442 assert!(grouped.ecosystem_breakdown().is_empty());
1443 }
1444
1445 #[test]
1446 fn test_group_by_ecosystem_groups_correctly() {
1447 let mut old = Sbom::default();
1448 let mut new = Sbom::default();
1449
1450 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1452 c1.ecosystem = Some("npm".into());
1453 old.components.insert(c1.id.clone(), c1);
1454
1455 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1457 c2.ecosystem = Some("npm".into());
1458 new.components.insert(c2.id.clone(), c2);
1459
1460 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1462 c3.ecosystem = Some("cargo".into());
1463 new.components.insert(c3.id.clone(), c3);
1464
1465 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1467 c4_old.ecosystem = Some("npm".into());
1468 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1469 c4_new.ecosystem = Some("npm".into());
1470 old.components.insert(c4_old.id.clone(), c4_old);
1471 new.components.insert(c4_new.id.clone(), c4_new);
1472
1473 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1475 new.components.insert(c5.id.clone(), c5);
1476
1477 let diff = Differ::diff(&old, &new, None);
1478 let grouped = diff.group_by_ecosystem();
1479
1480 let npm = grouped.by_ecosystem.get("npm").unwrap();
1481 assert_eq!(npm.added.len(), 1);
1482 assert_eq!(npm.removed.len(), 1);
1483 assert_eq!(npm.changed.len(), 1);
1484
1485 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1486 assert_eq!(cargo.added.len(), 1);
1487 assert_eq!(cargo.removed.len(), 0);
1488 assert_eq!(cargo.changed.len(), 0);
1489
1490 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1491 assert_eq!(unknown.added.len(), 1);
1492 assert_eq!(unknown.removed.len(), 0);
1493 assert_eq!(unknown.changed.len(), 0);
1494
1495 let grouped_counts = grouped.ecosystem_breakdown();
1497 let direct_counts = diff.ecosystem_breakdown();
1498 assert_eq!(grouped_counts, direct_counts);
1499 }
1500
1501 #[test]
1502 fn test_totals_no_changes() {
1503 let mut old = Sbom::default();
1504 let mut new = Sbom::default();
1505
1506 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1507 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1508
1509 old.components.insert(c1.id.clone(), c1.clone());
1510 old.components.insert(c2.id.clone(), c2.clone());
1511 new.components.insert(c1.id.clone(), c1);
1512 new.components.insert(c2.id.clone(), c2);
1513
1514 let diff = Differ::diff(&old, &new, None);
1515 assert_eq!(diff.old_total, 2);
1516 assert_eq!(diff.new_total, 2);
1517 assert_eq!(diff.unchanged, 2);
1518 }
1519
1520 #[test]
1521 fn test_totals_with_changes() {
1522 let mut old = Sbom::default();
1523 let mut new = Sbom::default();
1524
1525 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1526 let mut c1_updated = c1.clone();
1527 c1_updated.version = Some("1.1".to_string());
1528 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1529 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1530 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1531
1532 old.components.insert(c1.id.clone(), c1);
1533 old.components.insert(c2.id.clone(), c2.clone());
1534 old.components.insert(c3.id.clone(), c3);
1535 new.components.insert(c1_updated.id.clone(), c1_updated);
1536 new.components.insert(c2.id.clone(), c2);
1537 new.components.insert(c4.id.clone(), c4);
1538
1539 let diff = Differ::diff(&old, &new, None);
1540 assert_eq!(diff.old_total, 3);
1541 assert_eq!(diff.new_total, 3);
1542 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1547
1548 #[test]
1549 fn test_component_names_for_hash_ids_in_edge_diffs() {
1550 let mut old = Sbom::default();
1551 let mut new = Sbom::default();
1552
1553 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1555 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1556 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1557
1558 old.components.insert(parent.id.clone(), parent.clone());
1559 old.components.insert(child_a.id.clone(), child_a.clone());
1560 new.components.insert(parent.id.clone(), parent.clone());
1561 new.components.insert(child_b.id.clone(), child_b.clone());
1562
1563 old.dependencies.insert(
1565 parent.id.clone(),
1566 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1567 );
1568 new.dependencies.insert(
1569 parent.id.clone(),
1570 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1571 );
1572
1573 let diff = Differ::diff(&old, &new, None);
1574
1575 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1577
1578 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1580 for added in diff.edge_diffs[0].added.keys() {
1581 assert!(!diff.display_name(added).starts_with("h:"));
1582 }
1583 for removed in diff.edge_diffs[0].removed.keys() {
1584 assert!(!diff.display_name(removed).starts_with("h:"));
1585 }
1586 }
1587
1588 #[test]
1589 fn test_component_names_skips_purl_ids() {
1590 let mut old = Sbom::default();
1591 let mut new = Sbom::default();
1592
1593 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1594 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1595 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1596
1597 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1598 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1599 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1600
1601 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1602 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1603 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1604
1605 old.components.insert(parent.id.clone(), parent.clone());
1606 old.components.insert(child_a.id.clone(), child_a.clone());
1607 new.components.insert(parent.id.clone(), parent.clone());
1608 new.components.insert(child_b.id.clone(), child_b.clone());
1609
1610 old.dependencies.insert(
1611 parent.id.clone(),
1612 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1613 );
1614 new.dependencies.insert(
1615 parent.id.clone(),
1616 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1617 );
1618
1619 let diff = Differ::diff(&old, &new, None);
1620
1621 assert!(diff.component_names.is_empty());
1623
1624 assert!(diff
1626 .display_name(&diff.edge_diffs[0].parent)
1627 .starts_with("pkg:npm/parent@"));
1628 }
1629
1630 #[test]
1631 fn test_display_name_fallback() {
1632 let diff = Diff::default();
1633 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1634 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1636 }
1637}