1use super::changes::{
4 ComponentChangeComputer, DependencyChangeComputer, LicenseChangeComputer,
5 VulnerabilityChangeComputer, compute_metadata_changes,
6};
7pub use super::engine_config::LargeSbomConfig;
8use super::engine_matching::{ComponentMatchResult, match_components};
9use super::engine_rules::apply_rules;
10use super::incremental::ChangedSections;
11use super::result::MatchMetrics;
12use super::traits::ChangeComputer;
13use super::{CostModel, DiffResult, GraphDiffConfig, MatchInfo, diff_dependency_graph};
14use crate::error::SbomDiffError;
15use crate::matching::{ComponentMatcher, FuzzyMatchConfig, FuzzyMatcher, RuleEngine};
16use crate::model::NormalizedSbom;
17use std::borrow::Cow;
18
19#[must_use]
21pub struct DiffEngine {
22 cost_model: CostModel,
23 fuzzy_config: FuzzyMatchConfig,
24 include_unchanged: bool,
25 graph_diff_config: Option<GraphDiffConfig>,
26 rule_engine: Option<RuleEngine>,
27 custom_matcher: Option<Box<dyn ComponentMatcher>>,
28 large_sbom_config: LargeSbomConfig,
29}
30
31impl DiffEngine {
32 pub fn new() -> Self {
34 Self {
35 cost_model: CostModel::default(),
36 fuzzy_config: FuzzyMatchConfig::balanced(),
37 include_unchanged: false,
38 graph_diff_config: None,
39 rule_engine: None,
40 custom_matcher: None,
41 large_sbom_config: LargeSbomConfig::default(),
42 }
43 }
44
45 pub const fn with_cost_model(mut self, cost_model: CostModel) -> Self {
47 self.cost_model = cost_model;
48 self
49 }
50
51 pub const fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
53 self.fuzzy_config = config;
54 self
55 }
56
57 pub const fn include_unchanged(mut self, include: bool) -> Self {
59 self.include_unchanged = include;
60 self
61 }
62
63 pub fn with_graph_diff(mut self, config: GraphDiffConfig) -> Self {
65 self.graph_diff_config = Some(config);
66 self
67 }
68
69 pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
71 self.rule_engine = Some(engine);
72 self
73 }
74
75 pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
77 self.custom_matcher = Some(matcher);
78 self
79 }
80
81 pub const fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
83 self.large_sbom_config = config;
84 self
85 }
86
87 #[must_use]
89 pub const fn large_sbom_config(&self) -> &LargeSbomConfig {
90 &self.large_sbom_config
91 }
92
93 #[must_use]
95 pub fn has_custom_matcher(&self) -> bool {
96 self.custom_matcher.is_some()
97 }
98
99 #[must_use]
101 pub const fn graph_diff_enabled(&self) -> bool {
102 self.graph_diff_config.is_some()
103 }
104
105 #[must_use]
107 pub const fn has_matching_rules(&self) -> bool {
108 self.rule_engine.is_some()
109 }
110
111 #[must_use = "diff result contains all changes and should not be discarded"]
113 pub fn diff(
114 &self,
115 old: &NormalizedSbom,
116 new: &NormalizedSbom,
117 ) -> Result<DiffResult, SbomDiffError> {
118 let _span = tracing::info_span!(
119 "diff_engine::diff",
120 old_components = old.component_count(),
121 new_components = new.component_count(),
122 )
123 .entered();
124
125 let mut result = DiffResult::new();
126
127 if old.content_hash == new.content_hash && old.content_hash != 0 && !self.include_unchanged
132 {
133 result.semantic_score = PERCENT_MAX;
134 return Ok(result);
135 }
136
137 let (old_filtered, new_filtered, canonical_maps) =
140 if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
141 result.rules_applied = rule_result.rules_count;
142 (
143 Cow::Owned(rule_result.old_filtered),
144 Cow::Owned(rule_result.new_filtered),
145 Some((rule_result.old_canonical, rule_result.new_canonical)),
146 )
147 } else {
148 (Cow::Borrowed(old), Cow::Borrowed(new), None)
149 };
150
151 let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone())
155 .with_cross_ecosystem(self.large_sbom_config.cross_ecosystem.clone());
156 let matcher: &dyn ComponentMatcher = self
157 .custom_matcher
158 .as_ref()
159 .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
160
161 let component_matches = match_components(
164 &old_filtered,
165 &new_filtered,
166 matcher,
167 &self.large_sbom_config,
168 canonical_maps
169 .as_ref()
170 .map(|(old_map, new_map)| (old_map, new_map)),
171 );
172
173 self.compute_sections(
177 &old_filtered,
178 &new_filtered,
179 &component_matches,
180 matcher,
181 &ChangedSections::all_changed(),
182 &mut result,
183 );
184 self.finalize_result(
185 &old_filtered,
186 &new_filtered,
187 &component_matches,
188 true,
189 &mut result,
190 );
191 Ok(result)
192 }
193
194 fn compute_sections(
202 &self,
203 old: &NormalizedSbom,
204 new: &NormalizedSbom,
205 match_result: &ComponentMatchResult,
206 matcher: &dyn ComponentMatcher,
207 sections: &ChangedSections,
208 result: &mut DiffResult,
209 ) {
210 if sections.components {
211 let comp_computer = ComponentChangeComputer::new(self.cost_model.clone())
212 .with_include_unchanged(self.include_unchanged);
213 let comp_changes = comp_computer.compute(old, new, &match_result.matches);
214 result.components.added = comp_changes.added;
215 result.components.removed = comp_changes.removed;
216 result.components.modified = comp_changes
217 .modified
218 .into_iter()
219 .map(|mut change| {
220 if change.change_type != crate::diff::ChangeType::Unchanged
225 && let (Some(old_id), Some(new_id)) =
226 (&change.old_canonical_id, &change.canonical_id)
227 && let (Some(old_comp), Some(new_comp)) =
228 (old.components.get(old_id), new.components.get(new_id))
229 {
230 let pair = (old_id.clone(), new_id.clone());
231 let match_info = if match_result.rule_bridged.contains(&pair) {
232 let score = match_result.pairs.get(&pair).copied().unwrap_or(1.0);
237 MatchInfo {
238 score,
239 method: "EquivalenceRule".to_string(),
240 reason: "Declared equivalent by a user matching rule".to_string(),
241 score_breakdown: Vec::new(),
242 normalizations: vec!["equivalence_rule".to_string()],
243 confidence_interval: Some(
244 super::result::ConfidenceInterval::from_tier(
245 score,
246 "EquivalenceRule",
247 ),
248 ),
249 }
250 } else {
251 let explanation = matcher.explain_match(old_comp, new_comp);
252 let mut match_info = MatchInfo::from_explanation(&explanation);
253
254 if let Some(&score) = match_result.pairs.get(&pair) {
256 match_info.score = score;
257 }
258 match_info
259 };
260
261 change = change.with_match_info(match_info);
262 }
263 change
264 })
265 .collect();
266 }
267
268 if sections.dependencies {
269 let dep_computer = DependencyChangeComputer::new();
270 let dep_changes = dep_computer.compute(old, new, &match_result.matches);
271 result.dependencies.added = dep_changes.added;
272 result.dependencies.removed = dep_changes.removed;
273 }
274
275 if sections.licenses {
276 let lic_computer = LicenseChangeComputer::new();
277 let lic_changes = lic_computer.compute(old, new, &match_result.matches);
278 result.licenses.new_licenses = lic_changes.new_licenses;
279 result.licenses.removed_licenses = lic_changes.removed_licenses;
280 result.licenses.component_changes = lic_changes.component_changes;
281 }
282
283 if sections.vulnerabilities {
284 let vuln_computer = VulnerabilityChangeComputer::new();
285 let vuln_changes = vuln_computer.compute(old, new, &match_result.matches);
286 result.vulnerabilities.introduced = vuln_changes.introduced;
287 result.vulnerabilities.resolved = vuln_changes.resolved;
288 result.vulnerabilities.persistent = vuln_changes.persistent;
289 result.vulnerabilities.vex_changes = vuln_changes.vex_changes;
290 }
291
292 result.metadata_changes = compute_metadata_changes(old, new);
296 }
297
298 fn finalize_result(
306 &self,
307 old_filtered: &NormalizedSbom,
308 new_filtered: &NormalizedSbom,
309 match_result: &ComponentMatchResult,
310 refresh_graph: bool,
311 result: &mut DiffResult,
312 ) {
313 result.match_metrics =
314 Some(self.compute_match_metrics(match_result, old_filtered, new_filtered));
315
316 if refresh_graph && let Some(ref graph_config) = self.graph_diff_config {
317 let (graph_changes, graph_summary) = diff_dependency_graph(
318 old_filtered,
319 new_filtered,
320 &match_result.matches,
321 graph_config,
322 );
323 result.graph_changes = graph_changes;
324 result.graph_summary = Some(graph_summary);
325 }
326
327 result.semantic_score = self.compute_semantic_score(result, old_filtered, new_filtered);
328 result.calculate_summary();
329 }
330
331 fn compute_match_metrics(
333 &self,
334 match_result: &ComponentMatchResult,
335 old_filtered: &NormalizedSbom,
336 new_filtered: &NormalizedSbom,
337 ) -> MatchMetrics {
338 let mut scores: Vec<f64> = match_result.pairs.values().copied().collect();
341 scores.sort_unstable_by(f64::total_cmp);
342 let exact = scores.iter().filter(|&&s| s >= 0.995).count();
346 let fuzzy = scores.len() - exact;
347 let matched_count = scores.len();
348 let unmatched_old = old_filtered.component_count().saturating_sub(matched_count);
349 let unmatched_new = new_filtered.component_count().saturating_sub(matched_count);
350 let avg = if scores.is_empty() {
351 0.0
352 } else {
353 scores.iter().sum::<f64>() / scores.len() as f64
354 };
355 let min = scores.iter().copied().fold(f64::INFINITY, f64::min);
356
357 MatchMetrics {
358 exact_matches: exact,
359 fuzzy_matches: fuzzy,
360 rule_matches: match_result.rule_bridged.len(),
363 unmatched_old,
364 unmatched_new,
365 avg_match_score: avg,
366 min_match_score: if min.is_infinite() { 0.0 } else { min },
367 }
368 }
369
370 pub(crate) fn diff_sections(
378 &self,
379 old: &NormalizedSbom,
380 new: &NormalizedSbom,
381 sections: &ChangedSections,
382 cached: &DiffResult,
383 ) -> Result<DiffResult, SbomDiffError> {
384 let mut result = cached.clone();
386
387 let (old_filtered, new_filtered, canonical_maps) =
389 if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
390 result.rules_applied = rule_result.rules_count;
391 (
392 Cow::Owned(rule_result.old_filtered),
393 Cow::Owned(rule_result.new_filtered),
394 Some((rule_result.old_canonical, rule_result.new_canonical)),
395 )
396 } else {
397 (Cow::Borrowed(old), Cow::Borrowed(new), None)
398 };
399
400 let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone())
402 .with_cross_ecosystem(self.large_sbom_config.cross_ecosystem.clone());
403 let matcher: &dyn ComponentMatcher = self
404 .custom_matcher
405 .as_ref()
406 .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
407
408 let component_matches = match_components(
411 &old_filtered,
412 &new_filtered,
413 matcher,
414 &self.large_sbom_config,
415 canonical_maps
416 .as_ref()
417 .map(|(old_map, new_map)| (old_map, new_map)),
418 );
419
420 let mut effective_sections = sections.clone();
435 effective_sections.dependencies |= sections.components;
436 effective_sections.licenses |= sections.components;
437 effective_sections.vulnerabilities |= sections.dependencies || sections.components;
438 self.compute_sections(
439 &old_filtered,
440 &new_filtered,
441 &component_matches,
442 matcher,
443 &effective_sections,
444 &mut result,
445 );
446
447 let refresh_graph = sections.components || sections.dependencies;
453 self.finalize_result(
454 &old_filtered,
455 &new_filtered,
456 &component_matches,
457 refresh_graph,
458 &mut result,
459 );
460 Ok(result)
461 }
462
463 fn compute_semantic_score(
465 &self,
466 result: &DiffResult,
467 old_sbom: &NormalizedSbom,
468 new_sbom: &NormalizedSbom,
469 ) -> f64 {
470 let modified_count = result
474 .components
475 .modified
476 .iter()
477 .filter(|c| c.change_type != crate::diff::ChangeType::Unchanged)
478 .count();
479 let raw_cost = self.cost_model.calculate_semantic_score(
480 result.components.added.len(),
481 result.components.removed.len(),
482 modified_count,
483 result.licenses.component_changes.len(),
484 result.vulnerabilities.introduced.len(),
485 result.vulnerabilities.resolved.len(),
486 result.dependencies.added.len(),
487 result.dependencies.removed.len(),
488 );
489
490 self.normalize_semantic_score(raw_cost, result, old_sbom, new_sbom)
491 }
492
493 fn normalize_semantic_score(
513 &self,
514 raw_cost: f64,
515 result: &DiffResult,
516 old_sbom: &NormalizedSbom,
517 new_sbom: &NormalizedSbom,
518 ) -> f64 {
519 let component_budget = (old_sbom.component_count() + new_sbom.component_count()) as f64
520 * f64::from(
521 self.cost_model
522 .component_added
523 .max(self.cost_model.component_removed),
524 );
525 let dependency_budget = (old_sbom.edges.len() + new_sbom.edges.len()) as f64
526 * f64::from(
527 self.cost_model
528 .dependency_added
529 .max(self.cost_model.dependency_removed),
530 );
531 let vulnerability_budget = (old_sbom.vulnerability_counts().total()
532 + new_sbom.vulnerability_counts().total()) as f64
533 * f64::from(self.cost_model.vulnerability_introduced);
534 let modification_budget = result
536 .components
537 .modified
538 .iter()
539 .filter(|c| c.change_type != crate::diff::ChangeType::Unchanged)
540 .count() as f64
541 * f64::from(
542 self.cost_model
543 .version_major
544 .max(self.cost_model.license_changed),
545 );
546
547 let total_budget =
548 component_budget + dependency_budget + vulnerability_budget + modification_budget;
549
550 if total_budget <= f64::EPSILON {
551 return PERCENT_MAX;
552 }
553
554 (PERCENT_MAX * (1.0 - (raw_cost / total_budget))).clamp(0.0, PERCENT_MAX)
555 }
556}
557
558const PERCENT_MAX: f64 = 100.0;
561
562impl Default for DiffEngine {
563 fn default() -> Self {
564 Self::new()
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn test_empty_diff() {
574 let engine = DiffEngine::new();
575 let sbom = NormalizedSbom::default();
576 let result = engine.diff(&sbom, &sbom).expect("diff should succeed");
577 assert!(!result.has_changes());
578 }
579
580 #[test]
584 fn include_unchanged_emits_inventory_entries_without_changing_scores() {
585 use crate::diff::ChangeType;
586 use crate::model::{Component, DocumentMetadata};
587
588 let build = |bump_app: bool| {
589 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
590 let app_version = if bump_app { "2.0.0" } else { "1.0.0" };
591 for (name, version) in [
592 ("app", app_version),
593 ("stable-lib", "3.1.0"),
594 ("other-lib", "0.9.0"),
595 ] {
596 let mut c = Component::new(name.to_string(), format!("pkg:npm/{name}@{version}"));
597 c.version = Some(version.to_string());
598 c.calculate_content_hash();
599 sbom.add_component(c);
600 }
601 sbom.calculate_content_hash();
602 sbom
603 };
604 let old = build(false);
605 let new = build(true);
606
607 let without = DiffEngine::new().diff(&old, &new).expect("diff");
608 let with = DiffEngine::new()
609 .include_unchanged(true)
610 .diff(&old, &new)
611 .expect("diff");
612
613 let unchanged: Vec<_> = with
614 .components
615 .modified
616 .iter()
617 .filter(|c| c.change_type == ChangeType::Unchanged)
618 .collect();
619 assert_eq!(
620 unchanged.len(),
621 2,
622 "the two stable components must appear as Unchanged inventory entries"
623 );
624 assert!(unchanged.iter().all(|c| c.cost == 0));
625
626 assert_eq!(
628 with.summary.components_modified,
629 without.summary.components_modified
630 );
631 assert_eq!(with.summary.total_changes, without.summary.total_changes);
632 assert!(
633 (with.semantic_score - without.semantic_score).abs() < 1e-9,
634 "the flag must not change the semantic score: {} vs {}",
635 with.semantic_score,
636 without.semantic_score
637 );
638
639 assert!(
641 without
642 .components
643 .modified
644 .iter()
645 .all(|c| c.change_type != ChangeType::Unchanged)
646 );
647
648 assert_eq!(with.has_changes(), without.has_changes());
650
651 let identical = DiffEngine::new()
655 .include_unchanged(true)
656 .diff(&old, &old)
657 .expect("diff");
658 assert_eq!(
659 identical
660 .components
661 .modified
662 .iter()
663 .filter(|c| c.change_type == ChangeType::Unchanged)
664 .count(),
665 3,
666 "identical SBOMs must yield one Unchanged entry per component"
667 );
668 assert!(!identical.has_changes());
669 assert!((identical.semantic_score - 100.0).abs() < 1e-9);
670 }
671
672 fn rules_component(name: &str, purl: &str, version: &str, id: &str) -> crate::model::Component {
673 let mut c = crate::model::Component::new(name.to_string(), id.to_string());
674 c.version = Some(version.to_string());
675 c.identifiers.purl = Some(purl.to_string());
676 c.calculate_content_hash();
677 c
678 }
679
680 fn rules_sbom(comps: Vec<crate::model::Component>) -> NormalizedSbom {
681 let mut sbom = NormalizedSbom::new(crate::model::DocumentMetadata::default());
682 for c in comps {
683 sbom.add_component(c);
684 }
685 sbom.calculate_content_hash();
686 sbom
687 }
688
689 fn equivalence_engine() -> DiffEngine {
690 use crate::matching::{AliasPattern, EquivalenceGroup, MatchingRulesConfig};
691 let rules = MatchingRulesConfig {
692 equivalences: vec![EquivalenceGroup {
693 name: Some("foo family".to_string()),
694 canonical: "pkg:npm/foo".to_string(),
695 aliases: vec![
696 AliasPattern::exact("pkg:npm/foo-fork"),
697 AliasPattern::exact("pkg:npm/foo-legacy"),
698 ],
699 version_sensitive: false,
700 }],
701 ..Default::default()
702 };
703 DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"))
704 }
705
706 #[test]
712 fn equivalence_rules_bridge_matches_without_corrupting_the_diff() {
713 let old = rules_sbom(vec![rules_component(
716 "foo",
717 "pkg:npm/foo",
718 "1.0.0",
719 "old-foo",
720 )]);
721 let new = rules_sbom(vec![rules_component(
722 "foo",
723 "pkg:npm/foo",
724 "1.0.0",
725 "new-foo",
726 )]);
727 let result = equivalence_engine().diff(&old, &new).expect("diff");
728 assert_eq!(
731 result.summary.components_added, 0,
732 "identical rule-matched components were re-reported as Added: {:?}",
733 result.summary
734 );
735 assert_eq!(result.summary.components_removed, 0);
736 assert_eq!(result.summary.components_modified, 0);
737
738 let old = rules_sbom(vec![rules_component(
741 "foo-fork",
742 "pkg:npm/foo-fork",
743 "1.0.0",
744 "old-fork",
745 )]);
746 let new = rules_sbom(vec![rules_component(
747 "foo",
748 "pkg:npm/foo",
749 "1.0.0",
750 "new-foo",
751 )]);
752 let result = equivalence_engine().diff(&old, &new).expect("diff");
753 assert_eq!(result.components.added.len(), 0, "must not report Added");
754 assert_eq!(
755 result.components.removed.len(),
756 0,
757 "must not report Removed"
758 );
759 assert_eq!(result.components.modified.len(), 1);
760 assert!(
761 result.components.modified[0]
762 .field_changes
763 .iter()
764 .any(|f| f.field == "name"),
765 "the migration must surface as a name change"
766 );
767 let match_info = result.components.modified[0]
768 .match_info
769 .as_ref()
770 .expect("bridged pair carries match info");
771 assert!(
772 (match_info.score - 1.0).abs() < 1e-9,
773 "an equivalence bridge asserts identity: score must be 1.0, got {}",
774 match_info.score
775 );
776 assert_eq!(
777 match_info.method, "EquivalenceRule",
778 "bridged pairs must carry rule provenance, not a fuzzy explanation"
779 );
780 assert_eq!(
781 result
782 .match_metrics
783 .as_ref()
784 .expect("metrics populated")
785 .rule_matches,
786 1,
787 "rule_matches must count bridged pairs"
788 );
789
790 let old = rules_sbom(vec![rules_component(
793 "foo-fork",
794 "pkg:npm/foo-fork",
795 "1.0.0",
796 "old-fork",
797 )]);
798 let new = rules_sbom(vec![]);
799 let result = equivalence_engine().diff(&old, &new).expect("diff");
800 assert_eq!(result.components.removed.len(), 1);
801 assert_eq!(result.components.removed[0].name, "foo-fork");
802
803 let old = rules_sbom(vec![
807 rules_component("foo-fork", "pkg:npm/foo-fork", "1.0.0", "old-fork"),
808 rules_component("foo-legacy", "pkg:npm/foo-legacy", "1.0.0", "old-legacy"),
809 ]);
810 let new = rules_sbom(vec![rules_component(
811 "foo",
812 "pkg:npm/foo",
813 "1.0.0",
814 "new-foo",
815 )]);
816 let result = equivalence_engine().diff(&old, &new).expect("diff");
817 assert_eq!(result.components.added.len(), 0);
818 assert_eq!(result.components.modified.len(), 1);
819 assert_eq!(
820 result.components.removed.len(),
821 1,
822 "the losing alias must be reported removed, not silently dropped"
823 );
824 }
825
826 #[test]
829 fn exclusion_rules_drop_edges_with_their_components() {
830 use crate::matching::{ExclusionRule, MatchingRulesConfig};
831 use crate::model::{DependencyEdge, DependencyType};
832
833 let rules = MatchingRulesConfig {
834 exclusions: vec![ExclusionRule::exact("pkg:npm/noise")],
835 ..Default::default()
836 };
837 let engine =
838 DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"));
839
840 let build = |with_edge: bool| {
841 let app = rules_component("app", "pkg:npm/app", "1.0.0", "app-ref");
842 let noise = rules_component("noise", "pkg:npm/noise", "1.0.0", "noise-ref");
843 let app_id = app.canonical_id.clone();
844 let noise_id = noise.canonical_id.clone();
845 let mut sbom = rules_sbom(vec![app, noise]);
846 if with_edge {
847 sbom.add_edge(DependencyEdge::new(
848 app_id,
849 noise_id,
850 DependencyType::DependsOn,
851 ));
852 }
853 sbom.calculate_content_hash();
854 sbom
855 };
856
857 let old = build(true);
859 let new = build(false);
860 let result = engine.diff(&old, &new).expect("diff");
861 assert_eq!(
862 result.dependencies.removed.len(),
863 0,
864 "edges of excluded components must not surface in the diff: {:?}",
865 result.dependencies.removed
866 );
867 assert_eq!(result.dependencies.added.len(), 0);
868 }
869
870 #[test]
874 fn version_sensitive_equivalences_bridge_only_matching_versions() {
875 use crate::matching::{AliasPattern, EquivalenceGroup, MatchingRulesConfig};
876 let engine = |sensitive: bool| {
877 let rules = MatchingRulesConfig {
878 equivalences: vec![EquivalenceGroup {
879 name: None,
880 canonical: "pkg:npm/foo".to_string(),
881 aliases: vec![AliasPattern::exact("pkg:npm/foo-fork")],
882 version_sensitive: sensitive,
883 }],
884 ..Default::default()
885 };
886 DiffEngine::new().with_rule_engine(RuleEngine::new(rules).expect("valid rules"))
887 };
888
889 let old = rules_sbom(vec![rules_component(
890 "foo-fork",
891 "pkg:npm/foo-fork",
892 "1.0.0",
893 "old-fork",
894 )]);
895 let new = rules_sbom(vec![rules_component(
896 "foo",
897 "pkg:npm/foo",
898 "2.0.0",
899 "new-foo",
900 )]);
901
902 let result = engine(false).diff(&old, &new).expect("diff");
904 assert_eq!(result.components.modified.len(), 1);
905 assert_eq!(result.components.added.len(), 0);
906
907 let result = engine(true).diff(&old, &new).expect("diff");
910 assert_eq!(result.components.modified.len(), 0);
911 assert_eq!(result.components.added.len(), 1);
912 assert_eq!(result.components.removed.len(), 1);
913
914 let new_same = rules_sbom(vec![rules_component(
916 "foo",
917 "pkg:npm/foo",
918 "1.0.0",
919 "new-foo-same",
920 )]);
921 let result = engine(true).diff(&old, &new_same).expect("diff");
922 assert_eq!(result.components.modified.len(), 1);
923 assert_eq!(result.components.added.len(), 0);
924 }
925}