1use crate::diff::{DiffEngine, DiffResult};
22use crate::error::SbomDiffError;
23use crate::model::NormalizedSbom;
24use std::collections::HashMap;
25use std::hash::{Hash, Hasher};
26use std::sync::{Arc, RwLock};
27use std::time::{Duration, Instant};
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct DiffCacheKey {
36 pub old_hash: u64,
38 pub new_hash: u64,
40}
41
42impl DiffCacheKey {
43 #[must_use]
45 pub const fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
46 Self {
47 old_hash: old.content_hash,
48 new_hash: new.content_hash,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SectionHashes {
56 pub components: u64,
58 pub dependencies: u64,
60 pub licenses: u64,
62 pub vulnerabilities: u64,
64}
65
66impl SectionHashes {
67 #[must_use]
69 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
70 use std::collections::hash_map::DefaultHasher;
71
72 let mut hasher = DefaultHasher::new();
74 for (id, comp) in &sbom.components {
75 id.hash(&mut hasher);
76 comp.name.hash(&mut hasher);
77 comp.version.hash(&mut hasher);
78 comp.content_hash.hash(&mut hasher);
79 }
80 let components = hasher.finish();
81
82 let mut hasher = DefaultHasher::new();
84 for edge in &sbom.edges {
85 edge.from.hash(&mut hasher);
86 edge.to.hash(&mut hasher);
87 edge.relationship.to_string().hash(&mut hasher);
88 }
89 let dependencies = hasher.finish();
90
91 let mut hasher = DefaultHasher::new();
93 for (_, comp) in &sbom.components {
94 for lic in &comp.licenses.declared {
95 lic.expression.hash(&mut hasher);
96 }
97 }
98 let licenses = hasher.finish();
99
100 let mut hasher = DefaultHasher::new();
102 for (_, comp) in &sbom.components {
103 for vuln in &comp.vulnerabilities {
104 vuln.id.hash(&mut hasher);
105 }
106 }
107 let vulnerabilities = hasher.finish();
108
109 Self {
110 components,
111 dependencies,
112 licenses,
113 vulnerabilities,
114 }
115 }
116
117 #[must_use]
119 pub const fn changed_sections(&self, other: &Self) -> ChangedSections {
120 ChangedSections {
121 components: self.components != other.components,
122 dependencies: self.dependencies != other.dependencies,
123 licenses: self.licenses != other.licenses,
124 vulnerabilities: self.vulnerabilities != other.vulnerabilities,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default)]
131pub struct ChangedSections {
132 pub components: bool,
133 pub dependencies: bool,
134 pub licenses: bool,
135 pub vulnerabilities: bool,
136}
137
138impl ChangedSections {
139 #[must_use]
141 pub const fn all_changed() -> Self {
142 Self {
143 components: true,
144 dependencies: true,
145 licenses: true,
146 vulnerabilities: true,
147 }
148 }
149
150 #[must_use]
152 pub const fn any(&self) -> bool {
153 self.components || self.dependencies || self.licenses || self.vulnerabilities
154 }
155
156 #[must_use]
158 pub const fn all(&self) -> bool {
159 self.components && self.dependencies && self.licenses && self.vulnerabilities
160 }
161
162 #[must_use]
164 pub fn count(&self) -> usize {
165 [
166 self.components,
167 self.dependencies,
168 self.licenses,
169 self.vulnerabilities,
170 ]
171 .iter()
172 .filter(|&&b| b)
173 .count()
174 }
175}
176
177#[derive(Debug, Clone)]
183pub struct CachedDiffResult {
184 pub result: Arc<DiffResult>,
186 pub computed_at: Instant,
188 pub old_hashes: SectionHashes,
190 pub new_hashes: SectionHashes,
192 pub hit_count: u64,
194}
195
196impl CachedDiffResult {
197 #[must_use]
199 pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
200 Self {
201 result: Arc::new(result),
202 computed_at: Instant::now(),
203 old_hashes,
204 new_hashes,
205 hit_count: 0,
206 }
207 }
208
209 #[must_use]
211 pub fn is_valid(&self, ttl: Duration) -> bool {
212 self.computed_at.elapsed() < ttl
213 }
214
215 #[must_use]
217 pub fn age(&self) -> Duration {
218 self.computed_at.elapsed()
219 }
220}
221
222#[derive(Debug, Clone)]
228pub struct DiffCacheConfig {
229 pub max_entries: usize,
231 pub ttl: Duration,
233 pub enable_incremental: bool,
235}
236
237impl Default for DiffCacheConfig {
238 fn default() -> Self {
239 Self {
240 max_entries: 100,
241 ttl: Duration::from_secs(3600), enable_incremental: true,
243 }
244 }
245}
246
247pub struct DiffCache {
252 cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
254 config: DiffCacheConfig,
256 stats: RwLock<CacheStats>,
258}
259
260#[derive(Debug, Clone, Default)]
262pub struct CacheStats {
263 pub lookups: u64,
265 pub hits: u64,
267 pub misses: u64,
269 pub incremental_hits: u64,
271 pub evictions: u64,
273 pub time_saved_ms: u64,
275}
276
277impl CacheStats {
278 #[must_use]
280 pub fn hit_rate(&self) -> f64 {
281 if self.lookups == 0 {
282 0.0
283 } else {
284 (self.hits + self.incremental_hits) as f64 / self.lookups as f64
285 }
286 }
287}
288
289impl DiffCache {
290 #[must_use]
292 pub fn new() -> Self {
293 Self::with_config(DiffCacheConfig::default())
294 }
295
296 #[must_use]
298 pub fn with_config(config: DiffCacheConfig) -> Self {
299 Self {
300 cache: RwLock::new(HashMap::new()),
301 config,
302 stats: RwLock::new(CacheStats::default()),
303 }
304 }
305
306 pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
310 let result = {
311 let mut cache = self.cache.write().expect("cache lock poisoned");
312 cache.get_mut(key).and_then(|entry| {
313 entry.is_valid(self.config.ttl).then(|| {
314 entry.hit_count += 1;
315 Arc::clone(&entry.result)
316 })
317 })
318 };
319
320 let mut stats = self.stats.write().expect("stats lock poisoned");
321 stats.lookups += 1;
322 if let Some(ref result) = result {
323 stats.hits += 1;
324 stats.time_saved_ms += Self::estimate_computation_time(result);
325 } else {
326 stats.misses += 1;
327 }
328 result
329 }
330
331 pub fn put(
333 &self,
334 key: DiffCacheKey,
335 result: DiffResult,
336 old_hashes: SectionHashes,
337 new_hashes: SectionHashes,
338 ) {
339 let mut cache = self.cache.write().expect("cache lock poisoned");
340
341 while cache.len() >= self.config.max_entries {
343 if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
344 cache.remove(&oldest_key);
345 let mut stats = self.stats.write().expect("stats lock poisoned");
346 stats.evictions += 1;
347 } else {
348 break;
349 }
350 }
351
352 cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
353 }
354
355 fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
357 cache
358 .iter()
359 .max_by_key(|(_, entry)| entry.age())
360 .map(|(key, _)| key.clone())
361 }
362
363 fn estimate_computation_time(result: &DiffResult) -> u64 {
365 let component_count = result.components.added.len()
367 + result.components.removed.len()
368 + result.components.modified.len();
369 (component_count / 10).max(1) as u64
370 }
371
372 pub fn stats(&self) -> CacheStats {
374 self.stats.read().expect("stats lock poisoned").clone()
375 }
376
377 pub fn clear(&self) {
379 let mut cache = self.cache.write().expect("cache lock poisoned");
380 cache.clear();
381 }
382
383 pub fn len(&self) -> usize {
385 self.cache.read().expect("cache lock poisoned").len()
386 }
387
388 pub fn is_empty(&self) -> bool {
390 self.cache.read().expect("cache lock poisoned").is_empty()
391 }
392}
393
394impl Default for DiffCache {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400struct LastDiffMeta {
406 key: DiffCacheKey,
408 old_hashes: SectionHashes,
410 new_hashes: SectionHashes,
412}
413
414pub struct IncrementalDiffEngine {
421 engine: DiffEngine,
423 cache: DiffCache,
425 last_diff: RwLock<Option<LastDiffMeta>>,
427}
428
429impl IncrementalDiffEngine {
430 #[must_use]
432 pub fn new(engine: DiffEngine) -> Self {
433 Self {
434 engine,
435 cache: DiffCache::new(),
436 last_diff: RwLock::new(None),
437 }
438 }
439
440 #[must_use]
442 pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
443 Self {
444 engine,
445 cache: DiffCache::with_config(config),
446 last_diff: RwLock::new(None),
447 }
448 }
449
450 pub fn diff(
458 &self,
459 old: &NormalizedSbom,
460 new: &NormalizedSbom,
461 ) -> Result<IncrementalDiffResult, SbomDiffError> {
462 let start = Instant::now();
463 let cache_key = DiffCacheKey::from_sboms(old, new);
464
465 if let Some(cached) = self.cache.get(&cache_key) {
467 return Ok(IncrementalDiffResult {
468 result: (*cached).clone(),
469 cache_hit: CacheHitType::Full,
470 sections_recomputed: ChangedSections::default(),
471 computation_time: start.elapsed(),
472 });
473 }
474
475 let old_hashes = SectionHashes::from_sbom(old);
477 let new_hashes = SectionHashes::from_sbom(new);
478
479 let (changed, prev_key) = {
481 let last = self.last_diff.read().expect("last_diff lock poisoned");
482 match &*last {
483 Some(meta) => {
484 let old_changed = old_hashes != meta.old_hashes;
485 let new_changed = new_hashes != meta.new_hashes;
486
487 if !old_changed && !new_changed {
488 (None, None)
491 } else {
492 (
493 Some(
494 meta.old_hashes
495 .changed_sections(&old_hashes)
496 .or(&meta.new_hashes.changed_sections(&new_hashes)),
497 ),
498 Some(meta.key.clone()),
499 )
500 }
501 }
502 None => (None, None),
503 }
504 };
505
506 let (result, cache_hit, sections_recomputed) = if let Some(ref changed) = changed
508 && let Some(ref prev_key) = prev_key
509 && !changed.all()
510 && changed.any()
511 {
512 if let Some(prev_result) = self.find_previous_result(prev_key) {
515 match self.engine.diff_sections(old, new, changed, &prev_result) {
516 Ok(result) => (result, CacheHitType::Partial, changed.clone()),
517 Err(_) => {
518 let result = self.engine.diff(old, new)?;
520 (result, CacheHitType::Miss, ChangedSections::all_changed())
521 }
522 }
523 } else {
524 let result = self.engine.diff(old, new)?;
526 (result, CacheHitType::Miss, ChangedSections::all_changed())
527 }
528 } else {
529 let result = self.engine.diff(old, new)?;
531 let sections = changed.unwrap_or_else(ChangedSections::all_changed);
532 (result, CacheHitType::Miss, sections)
533 };
534
535 if cache_hit == CacheHitType::Partial
537 && let Ok(mut stats) = self.cache.stats.write()
538 {
539 stats.incremental_hits += 1;
540 }
541
542 self.cache.put(
544 cache_key.clone(),
545 result.clone(),
546 old_hashes.clone(),
547 new_hashes.clone(),
548 );
549
550 *self.last_diff.write().expect("last_diff lock poisoned") = Some(LastDiffMeta {
552 key: cache_key,
553 old_hashes,
554 new_hashes,
555 });
556
557 Ok(IncrementalDiffResult {
558 result,
559 cache_hit,
560 sections_recomputed,
561 computation_time: start.elapsed(),
562 })
563 }
564
565 fn find_previous_result(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
571 let cache = self.cache.cache.read().ok()?;
572 cache
573 .get(key)
574 .filter(|e| e.is_valid(self.cache.config.ttl))
575 .map(|e| Arc::clone(&e.result))
576 }
577
578 pub const fn engine(&self) -> &DiffEngine {
580 &self.engine
581 }
582
583 pub fn cache_stats(&self) -> CacheStats {
585 self.cache.stats()
586 }
587
588 pub fn clear_cache(&self) {
590 self.cache.clear();
591 }
592}
593
594impl ChangedSections {
595 const fn or(&self, other: &Self) -> Self {
597 Self {
598 components: self.components || other.components,
599 dependencies: self.dependencies || other.dependencies,
600 licenses: self.licenses || other.licenses,
601 vulnerabilities: self.vulnerabilities || other.vulnerabilities,
602 }
603 }
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum CacheHitType {
609 Full,
611 Partial,
613 Miss,
615}
616
617#[derive(Debug)]
619pub struct IncrementalDiffResult {
620 pub result: DiffResult,
622 pub cache_hit: CacheHitType,
624 pub sections_recomputed: ChangedSections,
626 pub computation_time: Duration,
628}
629
630impl IncrementalDiffResult {
631 pub fn into_result(self) -> DiffResult {
633 self.result
634 }
635
636 #[must_use]
638 pub fn was_cached(&self) -> bool {
639 self.cache_hit == CacheHitType::Full
640 }
641}
642
643#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::model::DocumentMetadata;
651
652 fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
653 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
654 for comp_name in components {
655 let comp = crate::model::Component::new(
656 comp_name.to_string(),
657 format!("{}-{}", name, comp_name),
658 );
659 sbom.add_component(comp);
660 }
661 sbom.content_hash = {
663 use std::collections::hash_map::DefaultHasher;
664 let mut hasher = DefaultHasher::new();
665 name.hash(&mut hasher);
666 for c in components {
667 c.hash(&mut hasher);
668 }
669 hasher.finish()
670 };
671 sbom
672 }
673
674 #[test]
675 fn test_section_hashes() {
676 let sbom1 = make_sbom("test1", &["a", "b", "c"]);
677 let sbom2 = make_sbom("test2", &["a", "b", "c"]);
678 let sbom3 = make_sbom("test3", &["a", "b", "d"]);
679
680 let hash1 = SectionHashes::from_sbom(&sbom1);
681 let hash2 = SectionHashes::from_sbom(&sbom2);
682 let hash3 = SectionHashes::from_sbom(&sbom3);
683
684 assert_ne!(hash1.components, hash2.components);
687
688 assert_ne!(hash1.components, hash3.components);
690 }
691
692 #[test]
693 fn test_changed_sections() {
694 let hash1 = SectionHashes {
695 components: 100,
696 dependencies: 200,
697 licenses: 300,
698 vulnerabilities: 400,
699 };
700
701 let hash2 = SectionHashes {
702 components: 100,
703 dependencies: 200,
704 licenses: 999, vulnerabilities: 400,
706 };
707
708 let changed = hash1.changed_sections(&hash2);
709 assert!(!changed.components);
710 assert!(!changed.dependencies);
711 assert!(changed.licenses);
712 assert!(!changed.vulnerabilities);
713 assert_eq!(changed.count(), 1);
714 }
715
716 #[test]
717 fn test_diff_cache_basic() {
718 let cache = DiffCache::new();
719 let key = DiffCacheKey {
720 old_hash: 123,
721 new_hash: 456,
722 };
723
724 assert!(cache.get(&key).is_none());
726 assert!(cache.is_empty());
727
728 let result = DiffResult::new();
730 let hashes = SectionHashes {
731 components: 0,
732 dependencies: 0,
733 licenses: 0,
734 vulnerabilities: 0,
735 };
736 cache.put(key.clone(), result, hashes.clone(), hashes.clone());
737
738 assert!(cache.get(&key).is_some());
740 assert_eq!(cache.len(), 1);
741
742 let stats = cache.stats();
744 assert_eq!(stats.hits, 1);
745 assert_eq!(stats.misses, 1);
746 }
747
748 #[test]
749 fn test_diff_cache_eviction() {
750 let config = DiffCacheConfig {
751 max_entries: 3,
752 ttl: Duration::from_secs(3600),
753 enable_incremental: true,
754 };
755 let cache = DiffCache::with_config(config);
756
757 let hashes = SectionHashes {
758 components: 0,
759 dependencies: 0,
760 licenses: 0,
761 vulnerabilities: 0,
762 };
763
764 for i in 0..5 {
766 let key = DiffCacheKey {
767 old_hash: i,
768 new_hash: i + 100,
769 };
770 cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
771 }
772
773 assert_eq!(cache.len(), 3);
774 }
775
776 #[test]
777 fn test_cache_hit_type() {
778 assert_eq!(CacheHitType::Full, CacheHitType::Full);
779 assert_ne!(CacheHitType::Full, CacheHitType::Miss);
780 }
781
782 #[test]
783 fn test_incremental_diff_engine() {
784 let engine = DiffEngine::new();
785 let incremental = IncrementalDiffEngine::new(engine);
786
787 let old = make_sbom("old", &["a", "b", "c"]);
788 let new = make_sbom("new", &["a", "b", "d"]);
789
790 let result1 = incremental.diff(&old, &new).expect("diff should succeed");
792 assert_eq!(result1.cache_hit, CacheHitType::Miss);
793
794 let result2 = incremental.diff(&old, &new).expect("diff should succeed");
796 assert_eq!(result2.cache_hit, CacheHitType::Full);
797
798 let stats = incremental.cache_stats();
800 assert_eq!(stats.hits, 1);
801 assert_eq!(stats.misses, 1);
802 }
803
804 #[test]
805 fn test_changed_sections_all_changed() {
806 let all = ChangedSections::all_changed();
807 assert!(all.components);
808 assert!(all.dependencies);
809 assert!(all.licenses);
810 assert!(all.vulnerabilities);
811 assert!(all.all());
812 assert!(all.any());
813 assert_eq!(all.count(), 4);
814 }
815
816 #[test]
817 fn test_changed_sections_or_combine() {
818 let a = ChangedSections {
819 components: true,
820 dependencies: false,
821 licenses: false,
822 vulnerabilities: false,
823 };
824 let b = ChangedSections {
825 components: false,
826 dependencies: false,
827 licenses: true,
828 vulnerabilities: false,
829 };
830 let combined = a.or(&b);
831 assert!(combined.components);
832 assert!(!combined.dependencies);
833 assert!(combined.licenses);
834 assert!(!combined.vulnerabilities);
835 assert_eq!(combined.count(), 2);
836 }
837
838 #[test]
839 fn test_diff_sections_selective_recomputation() {
840 let engine = DiffEngine::new();
843 let old = make_sbom("old", &["a", "b", "c"]);
844 let new = make_sbom("new", &["a", "b", "d"]);
845
846 let full_result = engine.diff(&old, &new).expect("diff should succeed");
848
849 let sections = ChangedSections {
851 components: true,
852 dependencies: false,
853 licenses: false,
854 vulnerabilities: false,
855 };
856 let selective_result = engine
857 .diff_sections(&old, &new, §ions, &full_result)
858 .expect("diff_sections should succeed");
859
860 assert_eq!(
862 selective_result.components.added.len(),
863 full_result.components.added.len()
864 );
865 assert_eq!(
866 selective_result.components.removed.len(),
867 full_result.components.removed.len()
868 );
869 assert_eq!(
870 selective_result.components.modified.len(),
871 full_result.components.modified.len()
872 );
873
874 assert_eq!(
876 selective_result.dependencies.added.len(),
877 full_result.dependencies.added.len()
878 );
879 assert_eq!(
880 selective_result.dependencies.removed.len(),
881 full_result.dependencies.removed.len()
882 );
883 }
884
885 #[test]
886 fn test_diff_sections_all_changed_matches_full_diff() {
887 let engine = DiffEngine::new();
890 let old = make_sbom("old", &["a", "b", "c"]);
891 let new = make_sbom("new", &["a", "b", "d"]);
892
893 let full_result = engine.diff(&old, &new).expect("diff should succeed");
894 let sections = ChangedSections::all_changed();
895 let selective_result = engine
896 .diff_sections(&old, &new, §ions, &DiffResult::new())
897 .expect("diff_sections should succeed");
898
899 assert_eq!(
900 selective_result.components.added.len(),
901 full_result.components.added.len()
902 );
903 assert_eq!(
904 selective_result.components.removed.len(),
905 full_result.components.removed.len()
906 );
907 assert_eq!(
908 selective_result.vulnerabilities.introduced.len(),
909 full_result.vulnerabilities.introduced.len()
910 );
911 }
912
913 #[test]
914 fn test_incremental_partial_change_detection() {
915 let engine = DiffEngine::new();
920 let incremental = IncrementalDiffEngine::new(engine);
921
922 let old = make_sbom("old", &["a", "b", "c"]);
923 let new1 = make_sbom("new1", &["a", "b", "d"]);
924
925 let result1 = incremental.diff(&old, &new1).expect("diff should succeed");
927 assert_eq!(result1.cache_hit, CacheHitType::Miss);
928
929 let new2 = make_sbom("new2", &["a", "b", "e"]);
932 let result2 = incremental.diff(&old, &new2).expect("diff should succeed");
933
934 assert!(
937 result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
938 );
939 assert!(result2.sections_recomputed.any());
941 }
942
943 #[test]
944 fn test_find_previous_result_empty_cache() {
945 let engine = DiffEngine::new();
946 let incremental = IncrementalDiffEngine::new(engine);
947 let key = DiffCacheKey {
948 old_hash: 1,
949 new_hash: 2,
950 };
951 assert!(incremental.find_previous_result(&key).is_none());
953 }
954
955 #[test]
956 fn test_find_previous_result_after_diff() {
957 let engine = DiffEngine::new();
958 let incremental = IncrementalDiffEngine::new(engine);
959
960 let old = make_sbom("old", &["a", "b"]);
961 let new = make_sbom("new", &["a", "c"]);
962
963 let _ = incremental.diff(&old, &new).expect("diff should succeed");
965
966 let key = DiffCacheKey::from_sboms(&old, &new);
968 assert!(incremental.find_previous_result(&key).is_some());
969 let other_key = DiffCacheKey {
970 old_hash: key.old_hash.wrapping_add(1),
971 new_hash: key.new_hash,
972 };
973 assert!(incremental.find_previous_result(&other_key).is_none());
974 }
975
976 fn assert_sections_match(actual: &DiffResult, expected: &DiffResult) {
977 assert_eq!(
978 actual.components.added.len(),
979 expected.components.added.len()
980 );
981 assert_eq!(
982 actual.components.removed.len(),
983 expected.components.removed.len()
984 );
985 assert_eq!(
986 actual.components.modified.len(),
987 expected.components.modified.len()
988 );
989 assert_eq!(
990 actual.licenses.new_licenses.len(),
991 expected.licenses.new_licenses.len()
992 );
993 assert_eq!(
994 actual.licenses.removed_licenses.len(),
995 expected.licenses.removed_licenses.len()
996 );
997 assert_eq!(
998 actual.vulnerabilities.introduced.len(),
999 expected.vulnerabilities.introduced.len()
1000 );
1001 assert_eq!(
1002 actual.vulnerabilities.resolved.len(),
1003 expected.vulnerabilities.resolved.len()
1004 );
1005 assert_eq!(
1006 actual.dependencies.added.len(),
1007 expected.dependencies.added.len()
1008 );
1009 assert_eq!(
1010 actual.dependencies.removed.len(),
1011 expected.dependencies.removed.len()
1012 );
1013 }
1014
1015 #[test]
1016 fn test_no_cross_pair_section_splice() {
1017 let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1020
1021 let a_old = make_sbom("a-old", &["a", "b", "c"]);
1022 let a_new = make_sbom("a-new", &["a", "b", "d"]);
1023 let b_old = make_sbom("b-old", &["x", "y"]);
1024 let b_new = make_sbom("b-new", &["x", "z", "w"]);
1025
1026 let _ = incremental
1028 .diff(&a_old, &a_new)
1029 .expect("diff should succeed");
1030 let b_result = incremental
1031 .diff(&b_old, &b_new)
1032 .expect("diff should succeed");
1033
1034 let fresh = DiffEngine::new()
1037 .diff(&b_old, &b_new)
1038 .expect("diff should succeed");
1039 assert_sections_match(&b_result.result, &fresh);
1040 }
1041
1042 #[test]
1043 fn test_partial_splice_uses_last_pair_base() {
1044 let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1045
1046 let s0 = make_sbom("s0", &["a", "b", "c"]);
1047 let s1 = make_sbom("s1", &["a", "b", "d"]);
1048 let s2 = make_sbom("s2", &["a", "b", "e"]);
1049
1050 let _ = incremental.diff(&s0, &s1).expect("diff should succeed");
1052 let result = incremental.diff(&s0, &s2).expect("diff should succeed");
1053 assert_eq!(result.cache_hit, CacheHitType::Partial);
1054
1055 let fresh = DiffEngine::new()
1057 .diff(&s0, &s2)
1058 .expect("diff should succeed");
1059 assert_sections_match(&result.result, &fresh);
1060 }
1061}