1use super::changes::{
4 ComponentChangeComputer, DependencyChangeComputer, LicenseChangeComputer,
5 VulnerabilityChangeComputer,
6};
7pub use super::engine_config::LargeSbomConfig;
8use super::engine_matching::{ComponentMatchResult, match_components};
9use super::engine_rules::{apply_rules, remap_match_result};
10use super::traits::ChangeComputer;
11use super::{CostModel, DiffResult, GraphDiffConfig, MatchInfo, diff_dependency_graph};
12use crate::error::SbomDiffError;
13use crate::matching::{
14 ComponentMatcher, FuzzyMatchConfig, FuzzyMatcher, MatchingRulesConfig, RuleEngine,
15};
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_matching_rules(mut self, config: MatchingRulesConfig) -> Result<Self, String> {
71 self.rule_engine = Some(RuleEngine::new(config)?);
72 Ok(self)
73 }
74
75 pub fn with_rule_engine(mut self, engine: RuleEngine) -> Self {
77 self.rule_engine = Some(engine);
78 self
79 }
80
81 pub fn with_matcher(mut self, matcher: Box<dyn ComponentMatcher>) -> Self {
83 self.custom_matcher = Some(matcher);
84 self
85 }
86
87 pub const fn with_large_sbom_config(mut self, config: LargeSbomConfig) -> Self {
89 self.large_sbom_config = config;
90 self
91 }
92
93 #[must_use]
95 pub const fn large_sbom_config(&self) -> &LargeSbomConfig {
96 &self.large_sbom_config
97 }
98
99 #[must_use]
101 pub fn has_custom_matcher(&self) -> bool {
102 self.custom_matcher.is_some()
103 }
104
105 #[must_use]
107 pub const fn graph_diff_enabled(&self) -> bool {
108 self.graph_diff_config.is_some()
109 }
110
111 #[must_use]
113 pub const fn has_matching_rules(&self) -> bool {
114 self.rule_engine.is_some()
115 }
116
117 #[must_use = "diff result contains all changes and should not be discarded"]
119 pub fn diff(
120 &self,
121 old: &NormalizedSbom,
122 new: &NormalizedSbom,
123 ) -> Result<DiffResult, SbomDiffError> {
124 let mut result = DiffResult::new();
125
126 if old.content_hash == new.content_hash && old.content_hash != 0 {
128 return Ok(result);
129 }
130
131 let (old_filtered, new_filtered, canonical_maps) =
134 if let Some(rule_result) = apply_rules(self.rule_engine.as_ref(), old, new) {
135 result.rules_applied = rule_result.rules_count;
136 (
137 Cow::Owned(rule_result.old_filtered),
138 Cow::Owned(rule_result.new_filtered),
139 Some((rule_result.old_canonical, rule_result.new_canonical)),
140 )
141 } else {
142 (Cow::Borrowed(old), Cow::Borrowed(new), None)
143 };
144
145 let default_matcher = FuzzyMatcher::new(self.fuzzy_config.clone());
147 let matcher: &dyn ComponentMatcher = self
148 .custom_matcher
149 .as_ref()
150 .map_or(&default_matcher as &dyn ComponentMatcher, |m| m.as_ref());
151
152 let mut component_matches = match_components(
153 &old_filtered,
154 &new_filtered,
155 matcher,
156 &self.fuzzy_config,
157 &self.large_sbom_config,
158 );
159
160 if let Some((old_canonical, new_canonical)) = &canonical_maps {
162 component_matches =
163 remap_match_result(&component_matches, old_canonical, new_canonical);
164 }
165
166 self.compute_all_changes(
168 &old_filtered,
169 &new_filtered,
170 &component_matches,
171 matcher,
172 &mut result,
173 );
174
175 if let Some(ref graph_config) = self.graph_diff_config {
177 let (graph_changes, graph_summary) = diff_dependency_graph(
178 &old_filtered,
179 &new_filtered,
180 &component_matches.matches,
181 graph_config,
182 );
183 result.graph_changes = graph_changes;
184 result.graph_summary = Some(graph_summary);
185 }
186
187 result.semantic_score = self.cost_model.calculate_semantic_score(
189 result.components.added.len(),
190 result.components.removed.len(),
191 result.components.modified.len(),
192 result.licenses.component_changes.len(),
193 result.vulnerabilities.introduced.len(),
194 result.vulnerabilities.resolved.len(),
195 result.dependencies.added.len(),
196 result.dependencies.removed.len(),
197 );
198
199 result.calculate_summary();
200 Ok(result)
201 }
202
203 fn compute_all_changes(
205 &self,
206 old: &NormalizedSbom,
207 new: &NormalizedSbom,
208 match_result: &ComponentMatchResult,
209 matcher: &dyn ComponentMatcher,
210 result: &mut DiffResult,
211 ) {
212 let comp_computer = ComponentChangeComputer::new(self.cost_model.clone());
214 let comp_changes = comp_computer.compute(old, new, &match_result.matches);
215 result.components.added = comp_changes.added;
216 result.components.removed = comp_changes.removed;
217 result.components.modified = comp_changes
218 .modified
219 .into_iter()
220 .map(|mut change| {
221 if let (Some(old_id), Some(new_id)) =
224 (&change.old_canonical_id, &change.canonical_id)
225 && let (Some(old_comp), Some(new_comp)) =
226 (old.components.get(old_id), new.components.get(new_id))
227 {
228 let explanation = matcher.explain_match(old_comp, new_comp);
229 let mut match_info = MatchInfo::from_explanation(&explanation);
230
231 if let Some(&score) = match_result.pairs.get(&(old_id.clone(), new_id.clone()))
233 {
234 match_info.score = score;
235 }
236
237 change = change.with_match_info(match_info);
238 }
239 change
240 })
241 .collect();
242
243 let dep_computer = DependencyChangeComputer::new();
245 let dep_changes = dep_computer.compute(old, new, &match_result.matches);
246 result.dependencies.added = dep_changes.added;
247 result.dependencies.removed = dep_changes.removed;
248
249 let lic_computer = LicenseChangeComputer::new();
251 let lic_changes = lic_computer.compute(old, new, &match_result.matches);
252 result.licenses.new_licenses = lic_changes.new_licenses;
253 result.licenses.removed_licenses = lic_changes.removed_licenses;
254
255 let vuln_computer = VulnerabilityChangeComputer::new();
257 let vuln_changes = vuln_computer.compute(old, new, &match_result.matches);
258 result.vulnerabilities.introduced = vuln_changes.introduced;
259 result.vulnerabilities.resolved = vuln_changes.resolved;
260 result.vulnerabilities.persistent = vuln_changes.persistent;
261 }
262}
263
264impl Default for DiffEngine {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_empty_diff() {
276 let engine = DiffEngine::new();
277 let sbom = NormalizedSbom::default();
278 let result = engine.diff(&sbom, &sbom).expect("diff should succeed");
279 assert!(!result.has_changes());
280 }
281}