1use super::incremental::IncrementalDiffEngine;
8use super::multi::{MultiDiffResult, SbomInfo, ComparisonResult, MultiDiffSummary, VariableComponent, VersionSpread, InconsistentComponent, DivergentComponent, DivergenceType, TimelineResult, EvolutionSummary, VersionAtPoint, ComponentEvolution, VersionChangeType, VulnerabilitySnapshot, DependencySnapshot, ComplianceSnapshot, ComplianceScoreEntry, MatrixResult, SbomClustering, SbomCluster, SecurityImpact, VulnerabilityMatrix};
9use super::{DiffEngine, DiffResult};
10use crate::matching::FuzzyMatchConfig;
11use crate::model::{NormalizedSbom, VulnerabilityCounts};
12use std::collections::{HashMap, HashSet};
13
14pub struct MultiDiffEngine {
20 fuzzy_config: Option<FuzzyMatchConfig>,
22 include_unchanged: bool,
24 incremental: Option<IncrementalDiffEngine>,
26}
27
28impl MultiDiffEngine {
29 #[must_use]
30 pub const fn new() -> Self {
31 Self {
32 fuzzy_config: None,
33 include_unchanged: false,
34 incremental: None,
35 }
36 }
37
38 #[must_use]
40 pub fn with_fuzzy_config(mut self, config: FuzzyMatchConfig) -> Self {
41 self.fuzzy_config = Some(config);
42 self.incremental = None;
43 self
44 }
45
46 #[must_use]
48 pub fn include_unchanged(mut self, include: bool) -> Self {
49 self.include_unchanged = include;
50 self.incremental = None;
51 self
52 }
53
54 fn ensure_engine(&mut self) {
56 if self.incremental.is_none() {
57 let mut engine = DiffEngine::new();
58 if let Some(config) = self.fuzzy_config.clone() {
59 engine = engine.with_fuzzy_config(config);
60 }
61 engine = engine.include_unchanged(self.include_unchanged);
62 self.incremental = Some(IncrementalDiffEngine::new(engine));
63 }
64 }
65
66 fn cached_diff(&mut self, old: &NormalizedSbom, new: &NormalizedSbom) -> DiffResult {
68 self.ensure_engine();
69 self.incremental
70 .as_ref()
71 .expect("engine initialized by ensure_engine")
72 .diff(old, new)
73 .into_result()
74 }
75
76 pub fn diff_multi(
78 &mut self,
79 baseline: &NormalizedSbom,
80 baseline_name: &str,
81 baseline_path: &str,
82 targets: &[(&NormalizedSbom, &str, &str)], ) -> MultiDiffResult {
84 let baseline_info = SbomInfo::from_sbom(
85 baseline,
86 baseline_name.to_string(),
87 baseline_path.to_string(),
88 );
89
90 let mut comparisons: Vec<ComparisonResult> = Vec::new();
92 let mut all_versions: HashMap<String, HashMap<String, String>> = HashMap::new(); for (id, comp) in &baseline.components {
96 let version = comp.version.clone().unwrap_or_default();
97 all_versions
98 .entry(id.value().to_string())
99 .or_default()
100 .insert(baseline_name.to_string(), version);
101 }
102
103 for (target_sbom, target_name, target_path) in targets {
104 let diff = self.cached_diff(baseline, target_sbom);
105 let target_info = SbomInfo::from_sbom(
106 target_sbom,
107 target_name.to_string(),
108 target_path.to_string(),
109 );
110
111 for (id, comp) in &target_sbom.components {
113 let version = comp.version.clone().unwrap_or_default();
114 all_versions
115 .entry(id.value().to_string())
116 .or_default()
117 .insert(target_name.to_string(), version);
118 }
119
120 comparisons.push(ComparisonResult {
121 target: target_info,
122 diff,
123 unique_components: vec![], divergent_components: vec![], });
126 }
127
128 let summary = self.compute_multi_diff_summary(
130 &baseline_info,
131 baseline,
132 &comparisons,
133 targets,
134 &all_versions,
135 );
136
137 for (i, comp) in comparisons.iter_mut().enumerate() {
139 let (target_sbom, target_name, _) = &targets[i];
140 comp.divergent_components =
141 self.find_divergent_components(baseline, target_sbom, target_name, &all_versions);
142 }
143
144 MultiDiffResult {
145 baseline: baseline_info,
146 comparisons,
147 summary,
148 }
149 }
150
151 fn compute_multi_diff_summary(
152 &self,
153 baseline_info: &SbomInfo,
154 baseline: &NormalizedSbom,
155 comparisons: &[ComparisonResult],
156 targets: &[(&NormalizedSbom, &str, &str)],
157 all_versions: &HashMap<String, HashMap<String, String>>,
158 ) -> MultiDiffSummary {
159 let baseline_components: HashSet<_> = baseline
160 .components
161 .keys()
162 .map(|k| k.value().to_string())
163 .collect();
164 let _target_names: Vec<_> = targets
165 .iter()
166 .map(|(_, name, _)| name.to_string())
167 .collect();
168
169 let mut universal: HashSet<String> = baseline_components.clone();
171 for (target_sbom, _, _) in targets {
172 let target_components: HashSet<_> = target_sbom
173 .components
174 .keys()
175 .map(|k| k.value().to_string())
176 .collect();
177 universal = universal
178 .intersection(&target_components)
179 .cloned()
180 .collect();
181 }
182
183 let mut variable_components: Vec<VariableComponent> = vec![];
185 for (comp_id, versions) in all_versions {
186 let unique_versions: HashSet<_> = versions.values().collect();
187 if unique_versions.len() > 1 {
188 let name = baseline
189 .components
190 .iter()
191 .find(|(id, _)| id.value() == comp_id)
192 .map(|(_, c)| c.name.clone())
193 .or_else(|| {
194 targets.iter().find_map(|(sbom, _, _)| {
195 sbom.components
196 .iter()
197 .find(|(id, _)| id.value() == comp_id)
198 .map(|(_, c)| c.name.clone())
199 })
200 })
201 .unwrap_or_else(|| comp_id.clone());
202
203 let baseline_version = versions.get(&baseline_info.name.clone()).cloned();
204 let all_versions_vec: Vec<_> = unique_versions.into_iter().cloned().collect();
205
206 let major_spread = calculate_major_version_spread(&all_versions_vec);
208
209 variable_components.push(VariableComponent {
210 id: comp_id.clone(),
211 name: name.clone(),
212 ecosystem: None,
213 version_spread: VersionSpread {
214 baseline: baseline_version,
215 min_version: all_versions_vec.iter().min().cloned(),
216 max_version: all_versions_vec.iter().max().cloned(),
217 unique_versions: all_versions_vec,
218 is_consistent: false,
219 major_version_spread: major_spread,
220 },
221 targets_with_component: versions.keys().cloned().collect(),
222 security_impact: classify_security_impact(&name),
223 });
224 }
225 }
226
227 let mut inconsistent_components: Vec<InconsistentComponent> = vec![];
229 let all_component_ids: HashSet<_> = all_versions.keys().cloned().collect();
230
231 for comp_id in &all_component_ids {
232 if universal.contains(comp_id) {
233 continue; }
235
236 let in_baseline = baseline_components.contains(comp_id);
237 let mut present_in: Vec<String> = vec![];
238 let mut missing_from: Vec<String> = vec![];
239
240 if in_baseline {
241 present_in.push(baseline_info.name.clone());
242 } else {
243 missing_from.push(baseline_info.name.clone());
244 }
245
246 for (target_sbom, target_name, _) in targets {
247 let has_component = target_sbom
248 .components
249 .iter()
250 .any(|(id, _)| id.value() == comp_id);
251 if has_component {
252 present_in.push(target_name.to_string());
253 } else {
254 missing_from.push(target_name.to_string());
255 }
256 }
257
258 if !missing_from.is_empty() {
259 let name = all_versions
260 .get(comp_id)
261 .and_then(|_| {
262 baseline
263 .components
264 .iter()
265 .find(|(id, _)| id.value() == comp_id)
266 .map(|(_, c)| c.name.clone())
267 })
268 .unwrap_or_else(|| comp_id.clone());
269
270 inconsistent_components.push(InconsistentComponent {
271 id: comp_id.clone(),
272 name,
273 in_baseline,
274 present_in,
275 missing_from,
276 });
277 }
278 }
279
280 let mut deviation_scores: HashMap<String, f64> = HashMap::new();
282 let mut max_deviation = 0.0f64;
283
284 for comp in comparisons {
285 let score = 100.0 - comp.diff.semantic_score;
286 deviation_scores.insert(comp.target.name.clone(), score);
287 max_deviation = max_deviation.max(score);
288 }
289
290 let vulnerability_matrix =
292 compute_vulnerability_matrix(baseline, &baseline_info.name, targets);
293
294 MultiDiffSummary {
295 baseline_component_count: baseline_info.component_count,
296 universal_components: universal.into_iter().collect(),
297 variable_components,
298 inconsistent_components,
299 deviation_scores,
300 max_deviation,
301 vulnerability_matrix,
302 }
303 }
304
305 fn find_divergent_components(
306 &self,
307 baseline: &NormalizedSbom,
308 target: &NormalizedSbom,
309 _target_name: &str,
310 all_versions: &HashMap<String, HashMap<String, String>>,
311 ) -> Vec<DivergentComponent> {
312 let mut divergent = vec![];
313
314 for (id, comp) in &target.components {
315 let comp_id = id.value().to_string();
316 let target_version = comp.version.clone().unwrap_or_default();
317
318 let baseline_version = baseline
320 .components
321 .iter()
322 .find(|(bid, _)| bid.value() == comp_id)
323 .and_then(|(_, bc)| bc.version.clone());
324
325 let divergence_type = if baseline_version.is_none() {
326 DivergenceType::Added
327 } else if baseline_version.as_ref() != Some(&target_version) {
328 DivergenceType::VersionMismatch
329 } else {
330 continue; };
332
333 divergent.push(DivergentComponent {
334 id: comp_id.clone(),
335 name: comp.name.clone(),
336 baseline_version,
337 target_version,
338 versions_across_targets: all_versions.get(&comp_id).cloned().unwrap_or_default(),
339 divergence_type,
340 });
341 }
342
343 for (id, comp) in &baseline.components {
345 let comp_id = id.value().to_string();
346 let in_target = target
347 .components
348 .iter()
349 .any(|(tid, _)| tid.value() == comp_id);
350
351 if !in_target {
352 divergent.push(DivergentComponent {
353 id: comp_id.clone(),
354 name: comp.name.clone(),
355 baseline_version: comp.version.clone(),
356 target_version: String::new(),
357 versions_across_targets: all_versions
358 .get(&comp_id)
359 .cloned()
360 .unwrap_or_default(),
361 divergence_type: DivergenceType::Removed,
362 });
363 }
364 }
365
366 divergent
367 }
368
369 pub fn timeline(
371 &mut self,
372 sboms: &[(&NormalizedSbom, &str, &str)], ) -> TimelineResult {
374 let sbom_infos: Vec<SbomInfo> = sboms
375 .iter()
376 .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
377 .collect();
378
379 let mut incremental_diffs: Vec<DiffResult> = vec![];
381 for i in 0..sboms.len().saturating_sub(1) {
382 let diff = self.cached_diff(sboms[i].0, sboms[i + 1].0);
383 incremental_diffs.push(diff);
384 }
385
386 let mut cumulative_from_first: Vec<DiffResult> = vec![];
388 if !sboms.is_empty() {
389 for i in 1..sboms.len() {
390 let diff = self.cached_diff(sboms[0].0, sboms[i].0);
391 cumulative_from_first.push(diff);
392 }
393 }
394
395 let evolution_summary =
397 self.build_evolution_summary(sboms, &sbom_infos, &incremental_diffs);
398
399 TimelineResult {
400 sboms: sbom_infos,
401 incremental_diffs,
402 cumulative_from_first,
403 evolution_summary,
404 }
405 }
406
407 fn build_evolution_summary(
408 &self,
409 sboms: &[(&NormalizedSbom, &str, &str)],
410 sbom_infos: &[SbomInfo],
411 _incremental_diffs: &[DiffResult],
412 ) -> EvolutionSummary {
413 let mut version_history: HashMap<String, Vec<VersionAtPoint>> = HashMap::new();
415 let mut components_added: Vec<ComponentEvolution> = vec![];
416 let mut components_removed: Vec<ComponentEvolution> = vec![];
417 let mut all_components: HashSet<String> = HashSet::new();
418
419 for (sbom, _, _) in sboms {
421 for (id, _) in &sbom.components {
422 all_components.insert(id.value().to_string());
423 }
424 }
425
426 for comp_id in &all_components {
428 let mut history: Vec<VersionAtPoint> = vec![];
429 let mut first_seen: Option<(usize, String)> = None;
430 let mut last_seen: Option<usize> = None;
431 let mut prev_version: Option<String> = None;
432 let mut version_change_count: usize = 0;
433
434 for (i, (sbom, name, _)) in sboms.iter().enumerate() {
435 let comp = sbom.components.iter().find(|(id, _)| id.value() == comp_id);
436
437 let (version, change_type) = if let Some((_, c)) = comp {
438 let ver = c.version.clone();
439 let change = if first_seen.is_none() {
440 first_seen = Some((i, ver.clone().unwrap_or_default()));
441 VersionChangeType::Initial
442 } else {
443 let ct = classify_version_change(prev_version.as_ref(), ver.as_ref());
444 if !matches!(ct, VersionChangeType::Unchanged | VersionChangeType::Absent) {
446 version_change_count += 1;
447 }
448 ct
449 };
450 last_seen = Some(i);
451 prev_version.clone_from(&ver);
452 (ver, change)
453 } else if first_seen.is_some() {
454 (None, VersionChangeType::Removed)
455 } else {
456 (None, VersionChangeType::Absent)
457 };
458
459 history.push(VersionAtPoint {
460 sbom_index: i,
461 sbom_name: name.to_string(),
462 version,
463 change_type,
464 });
465 }
466
467 version_history.insert(comp_id.clone(), history);
468
469 if let Some((first_idx, first_ver)) = first_seen {
471 let still_present = last_seen == Some(sboms.len() - 1);
472 let current_version = if still_present {
473 sboms.last().and_then(|(sbom, _, _)| {
474 sbom.components
475 .iter()
476 .find(|(id, _)| id.value() == comp_id)
477 .and_then(|(_, c)| c.version.clone())
478 })
479 } else {
480 None
481 };
482
483 let name = sboms
484 .iter()
485 .find_map(|(sbom, _, _)| {
486 sbom.components
487 .iter()
488 .find(|(id, _)| id.value() == comp_id)
489 .map(|(_, c)| c.name.clone())
490 })
491 .unwrap_or_else(|| comp_id.clone());
492
493 let evolution = ComponentEvolution {
494 id: comp_id.clone(),
495 name,
496 first_seen_index: first_idx,
497 first_seen_version: first_ver,
498 last_seen_index: if still_present { None } else { last_seen },
499 current_version,
500 version_change_count,
501 };
502
503 if first_idx > 0 {
504 components_added.push(evolution.clone());
505 }
506 if !still_present {
507 components_removed.push(evolution);
508 }
509 }
510 }
511
512 let vulnerability_trend: Vec<VulnerabilitySnapshot> = sbom_infos
514 .iter()
515 .enumerate()
516 .map(|(i, info)| VulnerabilitySnapshot {
517 sbom_index: i,
518 sbom_name: info.name.clone(),
519 counts: info.vulnerability_counts.clone(),
520 new_vulnerabilities: vec![],
521 resolved_vulnerabilities: vec![],
522 })
523 .collect();
524
525 let dependency_trend: Vec<DependencySnapshot> = sbom_infos
527 .iter()
528 .enumerate()
529 .map(|(i, info)| DependencySnapshot {
530 sbom_index: i,
531 sbom_name: info.name.clone(),
532 direct_dependencies: info.dependency_count,
533 transitive_dependencies: 0,
534 total_edges: info.dependency_count,
535 })
536 .collect();
537
538 let compliance_trend: Vec<ComplianceSnapshot> = sboms
540 .iter()
541 .enumerate()
542 .map(|(i, (sbom, name, _))| {
543 use crate::quality::{ComplianceChecker, ComplianceLevel};
544 let scores = ComplianceLevel::all()
545 .iter()
546 .map(|level| {
547 let result = ComplianceChecker::new(*level).check(sbom);
548 ComplianceScoreEntry {
549 standard: level.name().to_string(),
550 error_count: result.error_count,
551 warning_count: result.warning_count,
552 info_count: result.info_count,
553 is_compliant: result.is_compliant,
554 }
555 })
556 .collect();
557 ComplianceSnapshot {
558 sbom_index: i,
559 sbom_name: name.to_string(),
560 scores,
561 }
562 })
563 .collect();
564
565 EvolutionSummary {
566 components_added,
567 components_removed,
568 version_history,
569 vulnerability_trend,
570 license_changes: vec![],
571 dependency_trend,
572 compliance_trend,
573 }
574 }
575
576 pub fn matrix(
578 &mut self,
579 sboms: &[(&NormalizedSbom, &str, &str)], similarity_threshold: Option<f64>,
581 ) -> MatrixResult {
582 let sbom_infos: Vec<SbomInfo> = sboms
583 .iter()
584 .map(|(sbom, name, path)| SbomInfo::from_sbom(sbom, name.to_string(), path.to_string()))
585 .collect();
586
587 let n = sboms.len();
588 let num_pairs = n * (n - 1) / 2;
589
590 let mut diffs: Vec<Option<DiffResult>> = vec![None; num_pairs];
591 let mut similarity_scores: Vec<f64> = vec![0.0; num_pairs];
592
593 let mut idx = 0;
595 for i in 0..n {
596 for j in (i + 1)..n {
597 let diff = self.cached_diff(sboms[i].0, sboms[j].0);
598 let similarity = diff.semantic_score / 100.0;
599 similarity_scores[idx] = similarity;
600 diffs[idx] = Some(diff);
601 idx += 1;
602 }
603 }
604
605 let clustering = similarity_threshold
607 .map(|threshold| self.cluster_sboms(&sbom_infos, &similarity_scores, threshold));
608
609 MatrixResult {
610 sboms: sbom_infos,
611 diffs,
612 similarity_scores,
613 clustering,
614 }
615 }
616
617 fn cluster_sboms(
618 &self,
619 sboms: &[SbomInfo],
620 similarity_scores: &[f64],
621 threshold: f64,
622 ) -> SbomClustering {
623 let n = sboms.len();
624 let mut clusters: Vec<SbomCluster> = vec![];
625 let mut assigned: HashSet<usize> = HashSet::new();
626
627 for i in 0..n {
629 if assigned.contains(&i) {
630 continue;
631 }
632
633 let mut cluster_members = vec![i];
634 assigned.insert(i);
635
636 for j in (i + 1)..n {
637 if assigned.contains(&j) {
638 continue;
639 }
640
641 let idx = i * (2 * n - i - 1) / 2 + (j - i - 1);
643 let similarity = similarity_scores.get(idx).copied().unwrap_or(0.0);
644
645 if similarity >= threshold {
646 cluster_members.push(j);
647 assigned.insert(j);
648 }
649 }
650
651 if cluster_members.len() > 1 {
652 let mut total_sim = 0.0;
654 let mut count = 0;
655 for (mi, &a) in cluster_members.iter().enumerate() {
656 for &b in cluster_members.iter().skip(mi + 1) {
657 let (x, y) = if a < b { (a, b) } else { (b, a) };
658 let idx = x * (2 * n - x - 1) / 2 + (y - x - 1);
659 total_sim += similarity_scores.get(idx).copied().unwrap_or(0.0);
660 count += 1;
661 }
662 }
663
664 clusters.push(SbomCluster {
665 members: cluster_members.clone(),
666 centroid_index: cluster_members[0],
667 internal_similarity: if count > 0 {
668 total_sim / f64::from(count)
669 } else {
670 1.0
671 },
672 label: None,
673 });
674 }
675 }
676
677 let outliers: Vec<usize> = (0..n).filter(|i| !assigned.contains(i)).collect();
679
680 SbomClustering {
681 clusters,
682 outliers,
683 algorithm: "greedy".to_string(),
684 threshold,
685 }
686 }
687}
688
689impl Default for MultiDiffEngine {
690 fn default() -> Self {
691 Self::new()
692 }
693}
694
695fn classify_security_impact(name: &str) -> SecurityImpact {
697 let name_lower = name.to_lowercase();
698 let critical_components = [
699 "openssl",
700 "curl",
701 "libcurl",
702 "gnutls",
703 "mbedtls",
704 "wolfssl",
705 "boringssl",
706 ];
707 let high_components = [
708 "zlib", "libssh", "openssh", "gnupg", "gpg", "sqlite", "kernel", "glibc",
709 ];
710
711 if critical_components.iter().any(|c| name_lower.contains(c)) {
712 SecurityImpact::Critical
713 } else if high_components.iter().any(|c| name_lower.contains(c)) {
714 SecurityImpact::High
715 } else {
716 SecurityImpact::Low
717 }
718}
719
720fn calculate_major_version_spread(versions: &[String]) -> u32 {
722 let mut major_versions: HashSet<u64> = HashSet::new();
723
724 for version in versions {
725 if let Ok(v) = semver::Version::parse(version) {
727 major_versions.insert(v.major);
728 } else {
729 if let Some(major_str) = version.split(['.', '-', '_']).next() {
731 if let Ok(major) = major_str.parse::<u64>() {
732 major_versions.insert(major);
733 }
734 }
735 }
736 }
737
738 match (major_versions.iter().min(), major_versions.iter().max()) {
739 (Some(&min), Some(&max)) => (max - min) as u32,
740 _ => 0,
741 }
742}
743
744fn compute_vulnerability_matrix(
746 baseline: &NormalizedSbom,
747 baseline_name: &str,
748 targets: &[(&NormalizedSbom, &str, &str)],
749) -> VulnerabilityMatrix {
750 let mut vuln_sets: HashMap<String, HashSet<String>> = HashMap::new();
752 let mut per_sbom: HashMap<String, VulnerabilityCounts> = HashMap::new();
753
754 let baseline_vulns: HashSet<String> = baseline
756 .all_vulnerabilities()
757 .iter()
758 .map(|(_, v)| v.id.clone())
759 .collect();
760 vuln_sets.insert(baseline_name.to_string(), baseline_vulns);
761 per_sbom.insert(baseline_name.to_string(), baseline.vulnerability_counts());
762
763 for (sbom, name, _) in targets {
765 let target_vulns: HashSet<String> = sbom
766 .all_vulnerabilities()
767 .iter()
768 .map(|(_, v)| v.id.clone())
769 .collect();
770 vuln_sets.insert(name.to_string(), target_vulns);
771 per_sbom.insert(name.to_string(), sbom.vulnerability_counts());
772 }
773
774 let mut common_vulnerabilities: HashSet<String> =
776 vuln_sets.values().next().cloned().unwrap_or_default();
777
778 for vulns in vuln_sets.values() {
779 common_vulnerabilities = common_vulnerabilities
780 .intersection(vulns)
781 .cloned()
782 .collect();
783 }
784
785 let mut unique_vulnerabilities: HashMap<String, Vec<String>> = HashMap::new();
787
788 for (sbom_name, vulns) in &vuln_sets {
789 let mut unique: HashSet<String> = vulns.clone();
790
791 for (other_name, other_vulns) in &vuln_sets {
793 if other_name != sbom_name {
794 unique = unique.difference(other_vulns).cloned().collect();
795 }
796 }
797
798 if !unique.is_empty() {
799 unique_vulnerabilities.insert(sbom_name.clone(), unique.into_iter().collect());
800 }
801 }
802
803 VulnerabilityMatrix {
804 per_sbom,
805 unique_vulnerabilities,
806 common_vulnerabilities: common_vulnerabilities.into_iter().collect(),
807 }
808}
809
810fn classify_version_change(old: Option<&String>, new: Option<&String>) -> VersionChangeType {
812 match (old, new) {
813 (None, Some(_)) => VersionChangeType::Initial,
814 (Some(_), None) => VersionChangeType::Removed,
815 (Some(o), Some(n)) if o == n => VersionChangeType::Unchanged,
816 (Some(o), Some(n)) => {
817 if let (Ok(old_v), Ok(new_v)) = (semver::Version::parse(o), semver::Version::parse(n)) {
819 if new_v.major > old_v.major {
820 VersionChangeType::MajorUpgrade
821 } else if new_v.major < old_v.major {
822 VersionChangeType::Downgrade
823 } else if new_v.minor > old_v.minor {
824 VersionChangeType::MinorUpgrade
825 } else if new_v.minor < old_v.minor {
826 VersionChangeType::Downgrade
827 } else if new_v.patch > old_v.patch {
828 VersionChangeType::PatchUpgrade
829 } else {
830 VersionChangeType::Downgrade
831 }
832 } else {
833 if n > o {
835 VersionChangeType::PatchUpgrade
836 } else {
837 VersionChangeType::Downgrade
838 }
839 }
840 }
841 (None, None) => VersionChangeType::Absent,
842 }
843}