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, Serialize, Deserialize, PartialEq, Eq)]
15pub struct MetadataChange {
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub timestamp: Option<(Option<String>, Option<String>)>,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub tools: Option<(Vec<String>, Vec<String>)>,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub authors: Option<(Vec<String>, Vec<String>)>,
25}
26
27impl MetadataChange {
28 pub fn is_empty(&self) -> bool {
30 self.timestamp.is_none() && self.tools.is_none() && self.authors.is_none()
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36pub struct EcosystemCounts {
37 pub added: usize,
38 pub removed: usize,
39 pub changed: usize,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct Diff {
48 pub added: Vec<Component>,
50 pub removed: Vec<Component>,
52 pub changed: Vec<ComponentChange>,
54 pub edge_diffs: Vec<EdgeDiff>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub metadata_changed: Option<MetadataChange>,
59 pub old_total: usize,
61 pub new_total: usize,
63 pub unchanged: usize,
65 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70 pub component_names: BTreeMap<ComponentId, String>,
71}
72
73impl Diff {
74 pub fn is_empty(&self) -> bool {
76 self.added.is_empty()
77 && self.removed.is_empty()
78 && self.changed.is_empty()
79 && self.edge_diffs.is_empty()
80 && self.metadata_changed.is_none()
81 }
82
83 pub fn display_name<'a>(&'a self, id: &'a ComponentId) -> &'a str {
87 self.component_names
88 .get(id)
89 .map(String::as_str)
90 .unwrap_or_else(|| id.as_str())
91 }
92
93 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
97 let mut breakdown: BTreeMap<String, EcosystemCounts> = BTreeMap::new();
98
99 for comp in &self.added {
100 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
101 breakdown.entry(eco).or_default().added += 1;
102 }
103
104 for comp in &self.removed {
105 let eco = comp.ecosystem.as_deref().unwrap_or("unknown").to_string();
106 breakdown.entry(eco).or_default().removed += 1;
107 }
108
109 for change in &self.changed {
110 let eco = change
111 .new
112 .ecosystem
113 .as_deref()
114 .unwrap_or("unknown")
115 .to_string();
116 breakdown.entry(eco).or_default().changed += 1;
117 }
118
119 breakdown
120 }
121
122 pub fn group_by_ecosystem(&self) -> GroupedDiff {
129 group_components_by_ecosystem(
130 self.added.iter().cloned(),
131 self.removed.iter().cloned(),
132 self.changed.iter().cloned(),
133 self.edge_diffs.clone(),
134 self.metadata_changed.clone(),
135 )
136 }
137
138 pub fn into_group_by_ecosystem(self) -> GroupedDiff {
141 group_components_by_ecosystem(
142 self.added,
143 self.removed,
144 self.changed,
145 self.edge_diffs,
146 self.metadata_changed,
147 )
148 }
149}
150
151fn group_components_by_ecosystem(
155 added: impl IntoIterator<Item = Component>,
156 removed: impl IntoIterator<Item = Component>,
157 changed: impl IntoIterator<Item = ComponentChange>,
158 edge_diffs: Vec<EdgeDiff>,
159 metadata_changed: Option<MetadataChange>,
160) -> GroupedDiff {
161 let mut ecosystems: BTreeMap<String, EcosystemDiff> = BTreeMap::new();
162
163 for c in added {
164 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
165 ecosystems.entry(eco).or_default().added.push(c);
166 }
167 for c in removed {
168 let eco = c.ecosystem.as_deref().unwrap_or("unknown").to_string();
169 ecosystems.entry(eco).or_default().removed.push(c);
170 }
171 for c in changed {
172 let eco = c.new.ecosystem.as_deref().unwrap_or("unknown").to_string();
173 ecosystems.entry(eco).or_default().changed.push(c);
174 }
175
176 GroupedDiff {
177 by_ecosystem: ecosystems,
178 edge_diffs,
179 metadata_changed,
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct GroupedDiff {
186 pub by_ecosystem: BTreeMap<String, EcosystemDiff>,
187 pub edge_diffs: Vec<EdgeDiff>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub metadata_changed: Option<MetadataChange>,
190}
191
192impl GroupedDiff {
193 pub fn ecosystem_breakdown(&self) -> BTreeMap<String, EcosystemCounts> {
199 self.by_ecosystem
200 .iter()
201 .map(|(eco, eco_diff)| {
202 (
203 eco.clone(),
204 EcosystemCounts {
205 added: eco_diff.added.len(),
206 removed: eco_diff.removed.len(),
207 changed: eco_diff.changed.len(),
208 },
209 )
210 })
211 .collect()
212 }
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct EcosystemDiff {
218 pub added: Vec<Component>,
219 pub removed: Vec<Component>,
220 pub changed: Vec<ComponentChange>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ComponentChange {
226 pub id: ComponentId,
228 pub old: Component,
230 pub new: Component,
232 pub changes: Vec<FieldChange>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct EdgeDiff {
239 pub parent: ComponentId,
241 pub added: BTreeMap<ComponentId, DependencyKind>,
243 pub removed: BTreeMap<ComponentId, DependencyKind>,
245 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247 pub kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
252pub enum FieldChange {
253 Version(Option<String>, Option<String>),
255 License(BTreeSet<String>, BTreeSet<String>),
257 Supplier(Option<String>, Option<String>),
259 Purl(Option<String>, Option<String>),
261 Description(Option<String>, Option<String>),
263 Hashes(BTreeMap<String, String>, BTreeMap<String, String>),
265 Ecosystem(Option<String>, Option<String>),
267}
268
269#[derive(Debug, Copy, Clone, PartialEq, Eq)]
273pub enum Field {
274 Version,
276 License,
278 Supplier,
280 Purl,
282 Description,
284 Hashes,
286 Ecosystem,
288 Deps,
290}
291
292pub struct Differ;
297
298impl Differ {
299 pub fn diff(old: &Sbom, new: &Sbom, only: Option<&[Field]>) -> Diff {
328 Self::diff_owned(old.clone(), new.clone(), only)
329 }
330
331 pub fn diff_owned(mut old: Sbom, mut new: Sbom, only: Option<&[Field]>) -> Diff {
334 let metadata_changed = {
336 let mut mc = MetadataChange {
337 timestamp: None,
338 tools: None,
339 authors: None,
340 };
341 if old.metadata.timestamp != new.metadata.timestamp {
342 mc.timestamp = Some((
343 old.metadata.timestamp.clone(),
344 new.metadata.timestamp.clone(),
345 ));
346 }
347 if old.metadata.tools != new.metadata.tools {
348 mc.tools = Some((old.metadata.tools.clone(), new.metadata.tools.clone()));
349 }
350 if old.metadata.authors != new.metadata.authors {
351 mc.authors = Some((old.metadata.authors.clone(), new.metadata.authors.clone()));
352 }
353 if mc.is_empty() {
354 None
355 } else {
356 Some(mc)
357 }
358 };
359
360 old.normalize();
361 new.normalize();
362
363 let mut added = Vec::new();
364 let mut removed = Vec::new();
365 let mut changed = Vec::new();
366
367 let mut processed_old = HashSet::new();
368 let mut processed_new = HashSet::new();
369
370 let mut id_mapping: BTreeMap<ComponentId, ComponentId> = BTreeMap::new();
372
373 for (id, new_comp) in &new.components {
375 if let Some(old_comp) = old.components.get(id) {
376 processed_old.insert(id.clone());
377 processed_new.insert(id.clone());
378 id_mapping.insert(id.clone(), id.clone());
379
380 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
381 changed.push(change);
382 }
383 }
384 }
385
386 let mut old_identity_map: BTreeMap<String, BTreeMap<Option<String>, Vec<ComponentId>>> =
395 BTreeMap::new();
396 for (id, comp) in &old.components {
397 if !processed_old.contains(id) {
398 old_identity_map
399 .entry(comp.name.clone())
400 .or_default()
401 .entry(comp.ecosystem.clone())
402 .or_default()
403 .push(id.clone());
404 }
405 }
406
407 for (id, new_comp) in &new.components {
408 if processed_new.contains(id) {
409 continue;
410 }
411
412 let matched_old_id = old_identity_map
417 .get_mut(&new_comp.name)
418 .and_then(|eco_map| {
419 eco_map
421 .get_mut(&new_comp.ecosystem)
422 .and_then(|ids| ids.pop())
423 .or_else(|| {
424 if new_comp.ecosystem.is_some() {
425 eco_map.get_mut(&None).and_then(|ids| ids.pop())
427 } else {
428 eco_map.values_mut().find_map(|ids| ids.pop())
430 }
431 })
432 });
433
434 if let Some(old_id) = matched_old_id {
435 if let Some(old_comp) = old.components.get(&old_id) {
436 processed_old.insert(old_id.clone());
437 processed_new.insert(id.clone());
438 id_mapping.insert(old_id.clone(), id.clone());
439
440 if let Some(change) = Self::compute_change(old_comp, new_comp, only) {
441 changed.push(change);
442 }
443 continue;
444 }
445 }
446
447 added.push(new_comp.clone());
448 processed_new.insert(id.clone());
449 }
450
451 for (id, old_comp) in &old.components {
452 if !processed_old.contains(id) {
453 removed.push(old_comp.clone());
454 }
455 }
456
457 let old_total = old.components.len();
459 let new_total = new.components.len();
460 let matched = processed_old.len();
462 let unchanged = matched - changed.len();
463
464 let should_include_deps = only.is_none_or(|fields| fields.contains(&Field::Deps));
466 let edge_diffs = if should_include_deps {
467 Self::compute_edge_diffs(&old, &new, &id_mapping)
468 } else {
469 Vec::new()
470 };
471
472 let component_names = Self::build_component_names(&old, &new, &edge_diffs);
474
475 Diff {
476 added,
477 removed,
478 changed,
479 edge_diffs,
480 metadata_changed,
481 old_total,
482 new_total,
483 unchanged,
484 component_names,
485 }
486 }
487
488 fn compute_edge_diffs(
495 old: &Sbom,
496 new: &Sbom,
497 id_mapping: &BTreeMap<ComponentId, ComponentId>,
498 ) -> Vec<EdgeDiff> {
499 let mut edge_diffs = Vec::new();
500
501 let reverse_mapping: BTreeMap<ComponentId, ComponentId> = id_mapping
505 .iter()
506 .map(|(old_id, new_id)| (new_id.clone(), old_id.clone()))
507 .collect();
508
509 let translate_id = |old_id: &ComponentId| -> ComponentId {
511 id_mapping
512 .get(old_id)
513 .cloned()
514 .unwrap_or_else(|| old_id.clone())
515 };
516
517 let mut all_parents: BTreeSet<ComponentId> = new.dependencies.keys().cloned().collect();
520
521 for old_parent in old.dependencies.keys() {
523 all_parents.insert(translate_id(old_parent));
524 }
525
526 for parent_id in all_parents {
527 let new_children: BTreeMap<ComponentId, DependencyKind> = new
529 .dependencies
530 .get(&parent_id)
531 .cloned()
532 .unwrap_or_default();
533
534 let old_parent_id = reverse_mapping
537 .get(&parent_id)
538 .cloned()
539 .unwrap_or_else(|| parent_id.clone());
540
541 let old_children: BTreeMap<ComponentId, DependencyKind> = old
542 .dependencies
543 .get(&old_parent_id)
544 .map(|children| {
545 children
546 .iter()
547 .map(|(id, kind)| (translate_id(id), *kind))
548 .collect()
549 })
550 .unwrap_or_default();
551
552 let new_keys: BTreeSet<&ComponentId> = new_children.keys().collect();
553 let old_keys: BTreeSet<&ComponentId> = old_children.keys().collect();
554
555 let added: BTreeMap<ComponentId, DependencyKind> = new_keys
557 .difference(&old_keys)
558 .map(|&id| (id.clone(), new_children[id]))
559 .collect();
560 let removed: BTreeMap<ComponentId, DependencyKind> = old_keys
561 .difference(&new_keys)
562 .map(|&id| (id.clone(), old_children[id]))
563 .collect();
564
565 let kind_changed: BTreeMap<ComponentId, (DependencyKind, DependencyKind)> = new_keys
567 .intersection(&old_keys)
568 .filter_map(|&id| {
569 let old_kind = old_children[id];
570 let new_kind = new_children[id];
571 if old_kind != new_kind {
572 Some((id.clone(), (old_kind, new_kind)))
573 } else {
574 None
575 }
576 })
577 .collect();
578
579 if !added.is_empty() || !removed.is_empty() || !kind_changed.is_empty() {
580 edge_diffs.push(EdgeDiff {
581 parent: parent_id,
582 added,
583 removed,
584 kind_changed,
585 });
586 }
587 }
588
589 edge_diffs
590 }
591
592 fn build_component_names(
597 old: &Sbom,
598 new: &Sbom,
599 edge_diffs: &[EdgeDiff],
600 ) -> BTreeMap<ComponentId, String> {
601 let mut names = BTreeMap::new();
602
603 let mut ids = BTreeSet::new();
605 for edge in edge_diffs {
606 ids.insert(&edge.parent);
607 ids.extend(edge.added.keys());
608 ids.extend(edge.removed.keys());
609 ids.extend(edge.kind_changed.keys());
610 }
611
612 for id in ids {
614 if !id.as_str().starts_with("h:") {
615 continue;
616 }
617
618 let comp = new.components.get(id).or_else(|| old.components.get(id));
620 if let Some(comp) = comp {
621 let display = match &comp.version {
622 Some(v) => format!("{}@{}", comp.name, v),
623 None => comp.name.clone(),
624 };
625 names.insert(id.clone(), display);
626 }
627 }
628
629 names
630 }
631
632 fn compute_change(
633 old: &Component,
634 new: &Component,
635 only: Option<&[Field]>,
636 ) -> Option<ComponentChange> {
637 let mut changes = Vec::new();
638
639 let should_include = |f: Field| only.is_none_or(|fields| fields.contains(&f));
640
641 if should_include(Field::Version) && old.version != new.version {
642 changes.push(FieldChange::Version(
643 old.version.clone(),
644 new.version.clone(),
645 ));
646 }
647
648 if should_include(Field::License) && old.licenses != new.licenses {
649 changes.push(FieldChange::License(
650 old.licenses.clone(),
651 new.licenses.clone(),
652 ));
653 }
654
655 if should_include(Field::Supplier) && old.supplier != new.supplier {
656 changes.push(FieldChange::Supplier(
657 old.supplier.clone(),
658 new.supplier.clone(),
659 ));
660 }
661
662 if should_include(Field::Purl) && old.purl != new.purl {
663 changes.push(FieldChange::Purl(old.purl.clone(), new.purl.clone()));
664 }
665
666 if should_include(Field::Description) && old.description != new.description {
667 changes.push(FieldChange::Description(
668 old.description.clone(),
669 new.description.clone(),
670 ));
671 }
672
673 if should_include(Field::Hashes) && old.hashes != new.hashes {
674 changes.push(FieldChange::Hashes(old.hashes.clone(), new.hashes.clone()));
675 }
676
677 if should_include(Field::Ecosystem) && old.ecosystem != new.ecosystem {
678 changes.push(FieldChange::Ecosystem(
679 old.ecosystem.clone(),
680 new.ecosystem.clone(),
681 ));
682 }
683
684 if changes.is_empty() {
685 None
686 } else {
687 Some(ComponentChange {
688 id: new.id.clone(),
689 old: old.clone(),
690 new: new.clone(),
691 changes,
692 })
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn test_diff_added_removed() {
703 let mut old = Sbom::default();
704 let mut new = Sbom::default();
705
706 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
707 let c2 = Component::new("pkg-b".to_string(), Some("1.0".to_string()));
708
709 old.components.insert(c1.id.clone(), c1);
710 new.components.insert(c2.id.clone(), c2);
711
712 let diff = Differ::diff(&old, &new, None);
713 assert_eq!(diff.added.len(), 1);
714 assert_eq!(diff.removed.len(), 1);
715 assert_eq!(diff.changed.len(), 0);
716 }
717
718 #[test]
719 fn test_diff_changed() {
720 let mut old = Sbom::default();
721 let mut new = Sbom::default();
722
723 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
724 let mut c2 = c1.clone();
725 c2.version = Some("1.1".to_string());
726
727 old.components.insert(c1.id.clone(), c1);
728 new.components.insert(c2.id.clone(), c2);
729
730 let diff = Differ::diff(&old, &new, None);
731 assert_eq!(diff.added.len(), 0);
732 assert_eq!(diff.removed.len(), 0);
733 assert_eq!(diff.changed.len(), 1);
734 assert!(matches!(
735 diff.changed[0].changes[0],
736 FieldChange::Version(_, _)
737 ));
738 }
739
740 #[test]
741 fn test_diff_identity_reconciliation() {
742 let mut old = Sbom::default();
743 let mut new = Sbom::default();
744
745 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
746 let c2 = Component::new("pkg-a".to_string(), Some("1.1".to_string()));
747
748 old.components.insert(c1.id.clone(), c1);
749 new.components.insert(c2.id.clone(), c2);
750
751 let diff = Differ::diff(&old, &new, None);
752 assert_eq!(diff.changed.len(), 1);
753 assert_eq!(diff.added.len(), 0);
754 }
755
756 #[test]
757 fn test_diff_license_change() {
758 let mut old = Sbom::default();
759 let mut new = Sbom::default();
760
761 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
762 c1.licenses.insert("MIT".into());
763 let mut c2 = c1.clone();
764 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
765
766 old.components.insert(c1.id.clone(), c1);
767 new.components.insert(c2.id.clone(), c2);
768
769 let diff = Differ::diff(&old, &new, None);
770 assert_eq!(diff.changed.len(), 1);
771 assert!(diff.changed[0]
772 .changes
773 .iter()
774 .any(|c| matches!(c, FieldChange::License(_, _))));
775 }
776
777 #[test]
778 fn test_diff_supplier_change() {
779 let mut old = Sbom::default();
780 let mut new = Sbom::default();
781
782 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
783 c1.supplier = Some("Acme Corp".into());
784 let mut c2 = c1.clone();
785 c2.supplier = Some("New Corp".into());
786
787 old.components.insert(c1.id.clone(), c1);
788 new.components.insert(c2.id.clone(), c2);
789
790 let diff = Differ::diff(&old, &new, None);
791 assert_eq!(diff.changed.len(), 1);
792 assert!(diff.changed[0]
793 .changes
794 .iter()
795 .any(|c| matches!(c, FieldChange::Supplier(_, _))));
796 }
797
798 #[test]
799 fn test_diff_hashes_change() {
800 let mut old = Sbom::default();
801 let mut new = Sbom::default();
802
803 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
804 c1.hashes.insert("sha256".into(), "aaa".into());
805 let mut c2 = c1.clone();
806 c2.hashes.insert("sha256".into(), "bbb".into());
807
808 old.components.insert(c1.id.clone(), c1);
809 new.components.insert(c2.id.clone(), c2);
810
811 let diff = Differ::diff(&old, &new, None);
812 assert_eq!(diff.changed.len(), 1);
813 assert!(diff.changed[0]
814 .changes
815 .iter()
816 .any(|c| matches!(c, FieldChange::Hashes(_, _))));
817 }
818
819 #[test]
820 fn test_diff_description_change() {
821 let mut old = Sbom::default();
822 let mut new = Sbom::default();
823
824 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
825 c1.description = Some("Old description".into());
826 let mut c2 = c1.clone();
827 c2.description = Some("New description".into());
828
829 old.components.insert(c1.id.clone(), c1);
830 new.components.insert(c2.id.clone(), c2);
831
832 let diff = Differ::diff(&old, &new, None);
833 assert_eq!(diff.changed.len(), 1);
834 assert!(diff.changed[0]
835 .changes
836 .iter()
837 .any(|c| matches!(c, FieldChange::Description(_, _))));
838 }
839
840 #[test]
841 fn test_diff_description_added() {
842 let mut old = Sbom::default();
843 let mut new = Sbom::default();
844
845 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
846 let mut c2 = c1.clone();
847 c2.description = Some("A new description".into());
848
849 old.components.insert(c1.id.clone(), c1);
850 new.components.insert(c2.id.clone(), c2);
851
852 let diff = Differ::diff(&old, &new, None);
853 assert_eq!(diff.changed.len(), 1);
854 assert!(diff.changed[0]
855 .changes
856 .iter()
857 .any(|c| matches!(c, FieldChange::Description(None, Some(_)))));
858 }
859
860 #[test]
861 fn test_diff_description_removed() {
862 let mut old = Sbom::default();
863 let mut new = Sbom::default();
864
865 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
866 c1.description = Some("Had a description".into());
867 let mut c2 = c1.clone();
868 c2.description = None;
869
870 old.components.insert(c1.id.clone(), c1);
871 new.components.insert(c2.id.clone(), c2);
872
873 let diff = Differ::diff(&old, &new, None);
874 assert_eq!(diff.changed.len(), 1);
875 assert!(diff.changed[0]
876 .changes
877 .iter()
878 .any(|c| matches!(c, FieldChange::Description(Some(_), None))));
879 }
880
881 #[test]
882 fn test_diff_description_unchanged() {
883 let mut old = Sbom::default();
884 let mut new = Sbom::default();
885
886 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
887 c1.description = Some("Same description".into());
888 let c2 = c1.clone();
889
890 old.components.insert(c1.id.clone(), c1);
891 new.components.insert(c2.id.clone(), c2);
892
893 let diff = Differ::diff(&old, &new, None);
894 assert!(diff.changed.is_empty());
895 }
896
897 #[test]
898 fn test_diff_description_filtering() {
899 let mut old = Sbom::default();
900 let mut new = Sbom::default();
901
902 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
903 c1.description = Some("Old".into());
904 let mut c2 = c1.clone();
905 c2.version = Some("2.0".into());
906 c2.description = Some("New".into());
907
908 old.components.insert(c1.id.clone(), c1);
909 new.components.insert(c2.id.clone(), c2);
910
911 let diff = Differ::diff(&old, &new, Some(&[Field::Description]));
913 assert_eq!(diff.changed.len(), 1);
914 assert_eq!(diff.changed[0].changes.len(), 1);
915 assert!(matches!(
916 diff.changed[0].changes[0],
917 FieldChange::Description(_, _)
918 ));
919
920 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
922 assert_eq!(diff.changed.len(), 1);
923 assert_eq!(diff.changed[0].changes.len(), 1);
924 assert!(matches!(
925 diff.changed[0].changes[0],
926 FieldChange::Version(_, _)
927 ));
928 }
929
930 #[test]
931 fn test_diff_ecosystem_change() {
932 let mut old = Sbom::default();
933 let mut new = Sbom::default();
934
935 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
936 c1.ecosystem = Some("npm".to_string());
937 let mut c2 = c1.clone();
938 c2.ecosystem = Some("cargo".to_string());
939
940 old.components.insert(c1.id.clone(), c1);
941 new.components.insert(c2.id.clone(), c2);
942
943 let diff = Differ::diff(&old, &new, None);
944 assert_eq!(diff.changed.len(), 1);
945 assert_eq!(diff.changed[0].changes.len(), 1);
946 assert!(matches!(
947 diff.changed[0].changes[0],
948 FieldChange::Ecosystem(_, _)
949 ));
950
951 if let FieldChange::Ecosystem(ref o, ref n) = diff.changed[0].changes[0] {
952 assert_eq!(o.as_deref(), Some("npm"));
953 assert_eq!(n.as_deref(), Some("cargo"));
954 }
955 }
956
957 #[test]
958 fn test_diff_ecosystem_change_from_none() {
959 let mut old = Sbom::default();
960 let mut new = Sbom::default();
961
962 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
963 let mut c2 = c1.clone();
964 c2.ecosystem = Some("npm".to_string());
965
966 old.components.insert(c1.id.clone(), c1);
967 new.components.insert(c2.id.clone(), c2);
968
969 let diff = Differ::diff(&old, &new, None);
970 assert_eq!(diff.changed.len(), 1);
971 assert_eq!(diff.changed[0].changes.len(), 1);
972 assert!(matches!(
973 diff.changed[0].changes[0],
974 FieldChange::Ecosystem(None, Some(_))
975 ));
976 }
977
978 #[test]
979 fn test_diff_ecosystem_filtering() {
980 let mut old = Sbom::default();
981 let mut new = Sbom::default();
982
983 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
984 c1.ecosystem = Some("npm".to_string());
985 let mut c2 = c1.clone();
986 c2.version = Some("2.0".into());
987 c2.ecosystem = Some("cargo".to_string());
988
989 old.components.insert(c1.id.clone(), c1);
990 new.components.insert(c2.id.clone(), c2);
991
992 let diff = Differ::diff(&old, &new, Some(&[Field::Ecosystem]));
994 assert_eq!(diff.changed.len(), 1);
995 assert_eq!(diff.changed[0].changes.len(), 1);
996 assert!(matches!(
997 diff.changed[0].changes[0],
998 FieldChange::Ecosystem(_, _)
999 ));
1000
1001 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1003 assert_eq!(diff.changed.len(), 1);
1004 assert_eq!(diff.changed[0].changes.len(), 1);
1005 assert!(matches!(
1006 diff.changed[0].changes[0],
1007 FieldChange::Version(_, _)
1008 ));
1009 }
1010
1011 #[test]
1012 fn test_diff_ecosystem_no_change() {
1013 let mut old = Sbom::default();
1014 let mut new = Sbom::default();
1015
1016 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1017 c1.ecosystem = Some("npm".to_string());
1018 let c2 = c1.clone();
1019
1020 old.components.insert(c1.id.clone(), c1);
1021 new.components.insert(c2.id.clone(), c2);
1022
1023 let diff = Differ::diff(&old, &new, None);
1024 assert!(diff.changed.is_empty());
1025 }
1026
1027 #[test]
1028 fn test_diff_multiple_field_changes() {
1029 let mut old = Sbom::default();
1030 let mut new = Sbom::default();
1031
1032 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1033 c1.licenses.insert("MIT".into());
1034 c1.supplier = Some("Old Corp".into());
1035 c1.hashes.insert("sha256".into(), "aaa".into());
1036
1037 let mut c2 = c1.clone();
1038 c2.version = Some("2.0".into());
1039 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1040 c2.supplier = Some("New Corp".into());
1041 c2.hashes.insert("sha256".into(), "bbb".into());
1042
1043 old.components.insert(c1.id.clone(), c1);
1044 new.components.insert(c2.id.clone(), c2);
1045
1046 let diff = Differ::diff(&old, &new, None);
1047 assert_eq!(diff.changed.len(), 1);
1048 assert_eq!(diff.changed[0].changes.len(), 4);
1049 }
1050
1051 #[test]
1052 fn test_diff_no_changes() {
1053 let mut old = Sbom::default();
1054 let mut new = Sbom::default();
1055
1056 let c = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1057 old.components.insert(c.id.clone(), c.clone());
1058 new.components.insert(c.id.clone(), c);
1059
1060 let diff = Differ::diff(&old, &new, None);
1061 assert!(diff.added.is_empty());
1062 assert!(diff.removed.is_empty());
1063 assert!(diff.changed.is_empty());
1064 assert!(diff.edge_diffs.is_empty());
1065 }
1066
1067 #[test]
1068 fn test_diff_metadata_changed_timestamp() {
1069 let mut old = Sbom::default();
1070 let mut new = Sbom::default();
1071
1072 old.metadata.timestamp = Some("2024-01-01".into());
1073 new.metadata.timestamp = Some("2024-01-02".into());
1074
1075 let diff = Differ::diff(&old, &new, None);
1076 let mc = diff.metadata_changed.as_ref().unwrap();
1077 assert_eq!(
1078 mc.timestamp,
1079 Some((Some("2024-01-01".into()), Some("2024-01-02".into())))
1080 );
1081 assert!(mc.tools.is_none());
1082 assert!(mc.authors.is_none());
1083 assert!(!diff.is_empty());
1084 }
1085
1086 #[test]
1087 fn test_diff_metadata_changed_tools() {
1088 let mut old = Sbom::default();
1089 let mut new = Sbom::default();
1090
1091 old.metadata.tools = vec!["syft".into()];
1092 new.metadata.tools = vec!["trivy".into()];
1093
1094 let diff = Differ::diff(&old, &new, None);
1095 let mc = diff.metadata_changed.as_ref().unwrap();
1096 assert!(mc.timestamp.is_none());
1097 assert_eq!(mc.tools, Some((vec!["syft".into()], vec!["trivy".into()])));
1098 assert!(mc.authors.is_none());
1099 }
1100
1101 #[test]
1102 fn test_diff_metadata_changed_authors() {
1103 let mut old = Sbom::default();
1104 let mut new = Sbom::default();
1105
1106 old.metadata.authors = vec!["alice".into()];
1107 new.metadata.authors = vec!["bob".into()];
1108
1109 let diff = Differ::diff(&old, &new, None);
1110 let mc = diff.metadata_changed.as_ref().unwrap();
1111 assert!(mc.timestamp.is_none());
1112 assert!(mc.tools.is_none());
1113 assert_eq!(mc.authors, Some((vec!["alice".into()], vec!["bob".into()])));
1114 }
1115
1116 #[test]
1117 fn test_diff_metadata_unchanged() {
1118 let mut old = Sbom::default();
1119 let mut new = Sbom::default();
1120
1121 old.metadata.timestamp = Some("2024-01-01".into());
1122 new.metadata.timestamp = Some("2024-01-01".into());
1123 old.metadata.tools = vec!["syft".into()];
1124 new.metadata.tools = vec!["syft".into()];
1125
1126 let diff = Differ::diff(&old, &new, None);
1127 assert!(diff.metadata_changed.is_none());
1128 }
1129
1130 #[test]
1131 fn test_diff_metadata_changed_multiple_fields() {
1132 let mut old = Sbom::default();
1133 let mut new = Sbom::default();
1134
1135 old.metadata.timestamp = Some("2024-01-01".into());
1136 new.metadata.timestamp = Some("2024-01-02".into());
1137 old.metadata.tools = vec!["syft".into()];
1138 new.metadata.tools = vec!["trivy".into()];
1139 old.metadata.authors = vec!["alice".into()];
1140 new.metadata.authors = vec!["bob".into()];
1141
1142 let diff = Differ::diff(&old, &new, None);
1143 let mc = diff.metadata_changed.as_ref().unwrap();
1144 assert!(mc.timestamp.is_some());
1145 assert!(mc.tools.is_some());
1146 assert!(mc.authors.is_some());
1147 }
1148
1149 #[test]
1150 fn test_diff_filtering() {
1151 let mut old = Sbom::default();
1152 let mut new = Sbom::default();
1153
1154 let mut c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1155 c1.licenses.insert("MIT".into());
1156
1157 let mut c2 = c1.clone();
1158 c2.version = Some("1.1".to_string());
1159 c2.licenses = BTreeSet::from(["Apache-2.0".into()]);
1160
1161 old.components.insert(c1.id.clone(), c1);
1162 new.components.insert(c2.id.clone(), c2);
1163
1164 let diff = Differ::diff(&old, &new, Some(&[Field::Version]));
1165 assert_eq!(diff.changed.len(), 1);
1166 assert_eq!(diff.changed[0].changes.len(), 1);
1167 assert!(matches!(
1168 diff.changed[0].changes[0],
1169 FieldChange::Version(_, _)
1170 ));
1171 }
1172
1173 #[test]
1174 fn test_purl_change_same_ecosystem_name_is_change_not_add_remove() {
1175 let mut old = Sbom::default();
1178 let mut new = Sbom::default();
1179
1180 let mut c_old = Component::new("lodash".to_string(), Some("4.17.20".to_string()));
1182 c_old.purl = Some("pkg:npm/lodash@4.17.20".to_string());
1183 c_old.ecosystem = Some("npm".to_string());
1184 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1185
1186 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1188 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1189 c_new.ecosystem = Some("npm".to_string());
1190 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1191
1192 old.components.insert(c_old.id.clone(), c_old);
1193 new.components.insert(c_new.id.clone(), c_new);
1194
1195 let diff = Differ::diff(&old, &new, None);
1196
1197 assert_eq!(diff.added.len(), 0, "Should not have added components");
1199 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1200
1201 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1203
1204 let changes = &diff.changed[0].changes;
1206 assert!(changes
1207 .iter()
1208 .any(|c| matches!(c, FieldChange::Version(_, _))));
1209 assert!(changes.iter().any(|c| matches!(c, FieldChange::Purl(_, _))));
1210 }
1211
1212 #[test]
1213 fn test_purl_removed_is_change() {
1214 let mut old = Sbom::default();
1217 let mut new = Sbom::default();
1218
1219 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1220 c_old.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1221 c_old.ecosystem = Some("npm".to_string()); c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1223
1224 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1226 c_new.purl = None;
1227 c_new.ecosystem = None; c_new.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1230
1231 old.components.insert(c_old.id.clone(), c_old);
1232 new.components.insert(c_new.id.clone(), c_new);
1233
1234 let diff = Differ::diff(&old, &new, None);
1235
1236 assert_eq!(diff.added.len(), 0, "Should not have added components");
1237 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1238 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1239
1240 assert!(diff.changed[0]
1242 .changes
1243 .iter()
1244 .any(|c| matches!(c, FieldChange::Purl(_, _))));
1245 }
1246
1247 #[test]
1248 fn test_purl_added_is_change() {
1249 let mut old = Sbom::default();
1252 let mut new = Sbom::default();
1253
1254 let mut c_old = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1255 c_old.purl = None;
1256 c_old.ecosystem = None; c_old.id = ComponentId::new(None, &[("name", "lodash"), ("version", "4.17.21")]);
1258
1259 let mut c_new = Component::new("lodash".to_string(), Some("4.17.21".to_string()));
1260 c_new.purl = Some("pkg:npm/lodash@4.17.21".to_string());
1261 c_new.ecosystem = Some("npm".to_string()); c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1263
1264 old.components.insert(c_old.id.clone(), c_old);
1265 new.components.insert(c_new.id.clone(), c_new);
1266
1267 let diff = Differ::diff(&old, &new, None);
1268
1269 assert_eq!(diff.added.len(), 0, "Should not have added components");
1270 assert_eq!(diff.removed.len(), 0, "Should not have removed components");
1271 assert_eq!(diff.changed.len(), 1, "Should have one changed component");
1272 }
1273
1274 #[test]
1275 fn test_same_name_different_ecosystems_not_matched() {
1276 let mut old = Sbom::default();
1278 let mut new = Sbom::default();
1279
1280 let mut c_old = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1282 c_old.purl = Some("pkg:npm/utils@1.0.0".to_string());
1283 c_old.ecosystem = Some("npm".to_string());
1284 c_old.id = ComponentId::new(c_old.purl.as_deref(), &[]);
1285
1286 let mut c_new = Component::new("utils".to_string(), Some("1.0.0".to_string()));
1288 c_new.purl = Some("pkg:pypi/utils@1.0.0".to_string());
1289 c_new.ecosystem = Some("pypi".to_string());
1290 c_new.id = ComponentId::new(c_new.purl.as_deref(), &[]);
1291
1292 old.components.insert(c_old.id.clone(), c_old);
1293 new.components.insert(c_new.id.clone(), c_new);
1294
1295 let diff = Differ::diff(&old, &new, None);
1296
1297 assert_eq!(diff.added.len(), 1, "pypi/utils should be added");
1299 assert_eq!(diff.removed.len(), 1, "npm/utils should be removed");
1300 assert_eq!(
1301 diff.changed.len(),
1302 0,
1303 "Should not match different ecosystems"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_same_name_both_no_ecosystem_matched() {
1309 let mut old = Sbom::default();
1312 let mut new = Sbom::default();
1313
1314 let mut c_old = Component::new("mystery-pkg".to_string(), Some("1.0.0".to_string()));
1315 c_old.ecosystem = None;
1316
1317 let mut c_new = Component::new("mystery-pkg".to_string(), Some("2.0.0".to_string()));
1318 c_new.ecosystem = None;
1319
1320 old.components.insert(c_old.id.clone(), c_old);
1321 new.components.insert(c_new.id.clone(), c_new);
1322
1323 let diff = Differ::diff(&old, &new, None);
1324
1325 assert_eq!(diff.added.len(), 0);
1326 assert_eq!(diff.removed.len(), 0);
1327 assert_eq!(
1328 diff.changed.len(),
1329 1,
1330 "Same name with None ecosystems should match"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_edge_diff_added_removed() {
1336 let mut old = Sbom::default();
1337 let mut new = Sbom::default();
1338
1339 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1340 let c2 = Component::new("child-a".to_string(), Some("1.0".to_string()));
1341 let c3 = Component::new("child-b".to_string(), Some("1.0".to_string()));
1342
1343 let parent_id = c1.id.clone();
1344 let child_a_id = c2.id.clone();
1345 let child_b_id = c3.id.clone();
1346
1347 old.components.insert(c1.id.clone(), c1.clone());
1349 old.components.insert(c2.id.clone(), c2.clone());
1350 old.components.insert(c3.id.clone(), c3.clone());
1351
1352 new.components.insert(c1.id.clone(), c1);
1353 new.components.insert(c2.id.clone(), c2);
1354 new.components.insert(c3.id.clone(), c3);
1355
1356 old.dependencies
1358 .entry(parent_id.clone())
1359 .or_default()
1360 .insert(child_a_id.clone(), DependencyKind::Runtime);
1361
1362 new.dependencies
1364 .entry(parent_id.clone())
1365 .or_default()
1366 .insert(child_b_id.clone(), DependencyKind::Runtime);
1367
1368 let diff = Differ::diff(&old, &new, None);
1369
1370 assert_eq!(diff.edge_diffs.len(), 1);
1371 assert_eq!(diff.edge_diffs[0].parent, parent_id);
1372 assert!(diff.edge_diffs[0].added.contains_key(&child_b_id));
1373 assert!(diff.edge_diffs[0].removed.contains_key(&child_a_id));
1374 }
1375
1376 #[test]
1377 fn test_edge_diff_with_identity_reconciliation() {
1378 let mut old = Sbom::default();
1381 let mut new = Sbom::default();
1382
1383 let mut parent_old = Component::new("parent".to_string(), Some("1.0".to_string()));
1385 parent_old.purl = Some("pkg:npm/parent@1.0".to_string());
1386 parent_old.ecosystem = Some("npm".to_string());
1387 parent_old.id = ComponentId::new(parent_old.purl.as_deref(), &[]);
1388
1389 let mut parent_new = Component::new("parent".to_string(), Some("1.1".to_string()));
1391 parent_new.purl = Some("pkg:npm/parent@1.1".to_string());
1392 parent_new.ecosystem = Some("npm".to_string());
1393 parent_new.id = ComponentId::new(parent_new.purl.as_deref(), &[]);
1394
1395 let child = Component::new("child".to_string(), Some("1.0".to_string()));
1397
1398 old.components
1399 .insert(parent_old.id.clone(), parent_old.clone());
1400 old.components.insert(child.id.clone(), child.clone());
1401
1402 new.components
1403 .insert(parent_new.id.clone(), parent_new.clone());
1404 new.components.insert(child.id.clone(), child.clone());
1405
1406 old.dependencies
1408 .entry(parent_old.id.clone())
1409 .or_default()
1410 .insert(child.id.clone(), DependencyKind::Runtime);
1411
1412 new.dependencies
1414 .entry(parent_new.id.clone())
1415 .or_default()
1416 .insert(child.id.clone(), DependencyKind::Runtime);
1417
1418 let diff = Differ::diff(&old, &new, None);
1419
1420 assert_eq!(
1423 diff.edge_diffs.len(),
1424 0,
1425 "No edge changes expected when parent is reconciled by identity"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_edge_diff_filtering() {
1431 let mut old = Sbom::default();
1433 let mut new = Sbom::default();
1434
1435 let c1 = Component::new("parent".to_string(), Some("1.0".to_string()));
1436 let c2 = Component::new("child".to_string(), Some("1.0".to_string()));
1437
1438 let parent_id = c1.id.clone();
1439 let child_id = c2.id.clone();
1440
1441 old.components.insert(c1.id.clone(), c1.clone());
1442 old.components.insert(c2.id.clone(), c2.clone());
1443
1444 new.components.insert(c1.id.clone(), c1);
1445 new.components.insert(c2.id.clone(), c2);
1446
1447 new.dependencies
1449 .entry(parent_id.clone())
1450 .or_default()
1451 .insert(child_id, DependencyKind::Runtime);
1452
1453 let diff = Differ::diff(&old, &new, None);
1455 assert_eq!(diff.edge_diffs.len(), 1);
1456
1457 let diff_filtered = Differ::diff(&old, &new, Some(&[Field::Version]));
1459 assert_eq!(diff_filtered.edge_diffs.len(), 0);
1460
1461 let diff_with_deps = Differ::diff(&old, &new, Some(&[Field::Deps]));
1463 assert_eq!(diff_with_deps.edge_diffs.len(), 1);
1464 }
1465
1466 #[test]
1467 fn test_ecosystem_breakdown() {
1468 let mut old = Sbom::default();
1469 let mut new = Sbom::default();
1470
1471 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1473 c1.ecosystem = Some("npm".into());
1474 old.components.insert(c1.id.clone(), c1);
1475
1476 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1478 c2.ecosystem = Some("npm".into());
1479 new.components.insert(c2.id.clone(), c2);
1480
1481 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1483 c3.ecosystem = Some("cargo".into());
1484 new.components.insert(c3.id.clone(), c3);
1485
1486 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1488 c4_old.ecosystem = Some("npm".into());
1489 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1490 c4_new.ecosystem = Some("npm".into());
1491 old.components.insert(c4_old.id.clone(), c4_old);
1492 new.components.insert(c4_new.id.clone(), c4_new);
1493
1494 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1496 new.components.insert(c5.id.clone(), c5);
1497
1498 let diff = Differ::diff(&old, &new, None);
1499 let breakdown = diff.ecosystem_breakdown();
1500
1501 let npm = breakdown.get("npm").unwrap();
1502 assert_eq!(npm.added, 1);
1503 assert_eq!(npm.removed, 1);
1504 assert_eq!(npm.changed, 1);
1505
1506 let cargo = breakdown.get("cargo").unwrap();
1507 assert_eq!(cargo.added, 1);
1508 assert_eq!(cargo.removed, 0);
1509 assert_eq!(cargo.changed, 0);
1510
1511 let unknown = breakdown.get("unknown").unwrap();
1512 assert_eq!(unknown.added, 1);
1513 assert_eq!(unknown.removed, 0);
1514 assert_eq!(unknown.changed, 0);
1515 }
1516
1517 #[test]
1518 fn test_ecosystem_breakdown_empty_diff() {
1519 let old = Sbom::default();
1520 let new = Sbom::default();
1521
1522 let diff = Differ::diff(&old, &new, None);
1523 assert!(diff.is_empty());
1524 assert!(diff.ecosystem_breakdown().is_empty());
1525 }
1526
1527 #[test]
1528 fn test_group_by_ecosystem_empty_diff() {
1529 let old = Sbom::default();
1530 let new = Sbom::default();
1531
1532 let diff = Differ::diff(&old, &new, None);
1533 let grouped = diff.group_by_ecosystem();
1534 assert!(grouped.by_ecosystem.is_empty());
1535 assert!(grouped.edge_diffs.is_empty());
1536 assert!(grouped.metadata_changed.is_none());
1537 assert!(grouped.ecosystem_breakdown().is_empty());
1538 }
1539
1540 #[test]
1541 fn test_group_by_ecosystem_groups_correctly() {
1542 let mut old = Sbom::default();
1543 let mut new = Sbom::default();
1544
1545 let mut c1 = Component::new("lodash".into(), Some("4.17.21".into()));
1547 c1.ecosystem = Some("npm".into());
1548 old.components.insert(c1.id.clone(), c1);
1549
1550 let mut c2 = Component::new("express".into(), Some("4.18.0".into()));
1552 c2.ecosystem = Some("npm".into());
1553 new.components.insert(c2.id.clone(), c2);
1554
1555 let mut c3 = Component::new("serde".into(), Some("1.0.0".into()));
1557 c3.ecosystem = Some("cargo".into());
1558 new.components.insert(c3.id.clone(), c3);
1559
1560 let mut c4_old = Component::new("react".into(), Some("17.0.0".into()));
1562 c4_old.ecosystem = Some("npm".into());
1563 let mut c4_new = Component::new("react".into(), Some("18.0.0".into()));
1564 c4_new.ecosystem = Some("npm".into());
1565 old.components.insert(c4_old.id.clone(), c4_old);
1566 new.components.insert(c4_new.id.clone(), c4_new);
1567
1568 let c5 = Component::new("mystery".into(), Some("1.0".into()));
1570 new.components.insert(c5.id.clone(), c5);
1571
1572 let diff = Differ::diff(&old, &new, None);
1573 let grouped = diff.group_by_ecosystem();
1574
1575 let npm = grouped.by_ecosystem.get("npm").unwrap();
1576 assert_eq!(npm.added.len(), 1);
1577 assert_eq!(npm.removed.len(), 1);
1578 assert_eq!(npm.changed.len(), 1);
1579
1580 let cargo = grouped.by_ecosystem.get("cargo").unwrap();
1581 assert_eq!(cargo.added.len(), 1);
1582 assert_eq!(cargo.removed.len(), 0);
1583 assert_eq!(cargo.changed.len(), 0);
1584
1585 let unknown = grouped.by_ecosystem.get("unknown").unwrap();
1586 assert_eq!(unknown.added.len(), 1);
1587 assert_eq!(unknown.removed.len(), 0);
1588 assert_eq!(unknown.changed.len(), 0);
1589
1590 let grouped_counts = grouped.ecosystem_breakdown();
1592 let direct_counts = diff.ecosystem_breakdown();
1593 assert_eq!(grouped_counts, direct_counts);
1594 }
1595
1596 #[test]
1597 fn test_totals_no_changes() {
1598 let mut old = Sbom::default();
1599 let mut new = Sbom::default();
1600
1601 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1602 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1603
1604 old.components.insert(c1.id.clone(), c1.clone());
1605 old.components.insert(c2.id.clone(), c2.clone());
1606 new.components.insert(c1.id.clone(), c1);
1607 new.components.insert(c2.id.clone(), c2);
1608
1609 let diff = Differ::diff(&old, &new, None);
1610 assert_eq!(diff.old_total, 2);
1611 assert_eq!(diff.new_total, 2);
1612 assert_eq!(diff.unchanged, 2);
1613 }
1614
1615 #[test]
1616 fn test_totals_with_changes() {
1617 let mut old = Sbom::default();
1618 let mut new = Sbom::default();
1619
1620 let c1 = Component::new("pkg-a".to_string(), Some("1.0".to_string()));
1621 let mut c1_updated = c1.clone();
1622 c1_updated.version = Some("1.1".to_string());
1623 let c2 = Component::new("pkg-b".to_string(), Some("2.0".to_string()));
1624 let c3 = Component::new("pkg-c".to_string(), Some("3.0".to_string()));
1625 let c4 = Component::new("pkg-d".to_string(), Some("4.0".to_string()));
1626
1627 old.components.insert(c1.id.clone(), c1);
1628 old.components.insert(c2.id.clone(), c2.clone());
1629 old.components.insert(c3.id.clone(), c3);
1630 new.components.insert(c1_updated.id.clone(), c1_updated);
1631 new.components.insert(c2.id.clone(), c2);
1632 new.components.insert(c4.id.clone(), c4);
1633
1634 let diff = Differ::diff(&old, &new, None);
1635 assert_eq!(diff.old_total, 3);
1636 assert_eq!(diff.new_total, 3);
1637 assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.changed.len(), 1); assert_eq!(diff.unchanged, 1); }
1642
1643 #[test]
1644 fn test_component_names_for_hash_ids_in_edge_diffs() {
1645 let mut old = Sbom::default();
1646 let mut new = Sbom::default();
1647
1648 let parent = Component::new("my-app".to_string(), Some("1.0".to_string()));
1650 let child_a = Component::new("dep-old".to_string(), Some("0.1".to_string()));
1651 let child_b = Component::new("dep-new".to_string(), Some("0.2".to_string()));
1652
1653 old.components.insert(parent.id.clone(), parent.clone());
1654 old.components.insert(child_a.id.clone(), child_a.clone());
1655 new.components.insert(parent.id.clone(), parent.clone());
1656 new.components.insert(child_b.id.clone(), child_b.clone());
1657
1658 old.dependencies.insert(
1660 parent.id.clone(),
1661 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1662 );
1663 new.dependencies.insert(
1664 parent.id.clone(),
1665 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1666 );
1667
1668 let diff = Differ::diff(&old, &new, None);
1669
1670 assert!(diff.edge_diffs[0].parent.as_str().starts_with("h:"));
1672
1673 assert_eq!(diff.display_name(&diff.edge_diffs[0].parent), "my-app@1.0");
1675 for added in diff.edge_diffs[0].added.keys() {
1676 assert!(!diff.display_name(added).starts_with("h:"));
1677 }
1678 for removed in diff.edge_diffs[0].removed.keys() {
1679 assert!(!diff.display_name(removed).starts_with("h:"));
1680 }
1681 }
1682
1683 #[test]
1684 fn test_component_names_skips_purl_ids() {
1685 let mut old = Sbom::default();
1686 let mut new = Sbom::default();
1687
1688 let mut parent = Component::new("parent".to_string(), Some("1.0".to_string()));
1689 parent.purl = Some("pkg:npm/parent@1.0".to_string());
1690 parent.id = ComponentId::new(parent.purl.as_deref(), &[]);
1691
1692 let mut child_a = Component::new("child-a".to_string(), Some("1.0".to_string()));
1693 child_a.purl = Some("pkg:npm/child-a@1.0".to_string());
1694 child_a.id = ComponentId::new(child_a.purl.as_deref(), &[]);
1695
1696 let mut child_b = Component::new("child-b".to_string(), Some("1.0".to_string()));
1697 child_b.purl = Some("pkg:npm/child-b@1.0".to_string());
1698 child_b.id = ComponentId::new(child_b.purl.as_deref(), &[]);
1699
1700 old.components.insert(parent.id.clone(), parent.clone());
1701 old.components.insert(child_a.id.clone(), child_a.clone());
1702 new.components.insert(parent.id.clone(), parent.clone());
1703 new.components.insert(child_b.id.clone(), child_b.clone());
1704
1705 old.dependencies.insert(
1706 parent.id.clone(),
1707 BTreeMap::from([(child_a.id.clone(), DependencyKind::Runtime)]),
1708 );
1709 new.dependencies.insert(
1710 parent.id.clone(),
1711 BTreeMap::from([(child_b.id.clone(), DependencyKind::Runtime)]),
1712 );
1713
1714 let diff = Differ::diff(&old, &new, None);
1715
1716 assert!(diff.component_names.is_empty());
1718
1719 assert!(diff
1721 .display_name(&diff.edge_diffs[0].parent)
1722 .starts_with("pkg:npm/parent@"));
1723 }
1724
1725 #[test]
1726 fn test_display_name_fallback() {
1727 let diff = Diff::default();
1728 let unknown_id = ComponentId::new(None, &[("name", "mystery")]);
1729 assert_eq!(diff.display_name(&unknown_id), unknown_id.as_str());
1731 }
1732}