1use crate::diff::{DiffEngine, DiffResult};
22use crate::model::NormalizedSbom;
23use std::collections::HashMap;
24use std::hash::{Hash, Hasher};
25use std::sync::{Arc, RwLock};
26use std::time::{Duration, Instant};
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct DiffCacheKey {
35 pub old_hash: u64,
37 pub new_hash: u64,
39}
40
41impl DiffCacheKey {
42 #[must_use]
44 pub const fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
45 Self {
46 old_hash: old.content_hash,
47 new_hash: new.content_hash,
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SectionHashes {
55 pub components: u64,
57 pub dependencies: u64,
59 pub licenses: u64,
61 pub vulnerabilities: u64,
63}
64
65impl SectionHashes {
66 #[must_use]
68 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
69 use std::collections::hash_map::DefaultHasher;
70
71 let mut hasher = DefaultHasher::new();
73 for (id, comp) in &sbom.components {
74 id.hash(&mut hasher);
75 comp.name.hash(&mut hasher);
76 comp.version.hash(&mut hasher);
77 comp.content_hash.hash(&mut hasher);
78 }
79 let components = hasher.finish();
80
81 let mut hasher = DefaultHasher::new();
83 for edge in &sbom.edges {
84 edge.from.hash(&mut hasher);
85 edge.to.hash(&mut hasher);
86 edge.relationship.to_string().hash(&mut hasher);
87 }
88 let dependencies = hasher.finish();
89
90 let mut hasher = DefaultHasher::new();
92 for (_, comp) in &sbom.components {
93 for lic in &comp.licenses.declared {
94 lic.expression.hash(&mut hasher);
95 }
96 }
97 let licenses = hasher.finish();
98
99 let mut hasher = DefaultHasher::new();
101 for (_, comp) in &sbom.components {
102 for vuln in &comp.vulnerabilities {
103 vuln.id.hash(&mut hasher);
104 }
105 }
106 let vulnerabilities = hasher.finish();
107
108 Self {
109 components,
110 dependencies,
111 licenses,
112 vulnerabilities,
113 }
114 }
115
116 #[must_use]
118 pub const fn changed_sections(&self, other: &Self) -> ChangedSections {
119 ChangedSections {
120 components: self.components != other.components,
121 dependencies: self.dependencies != other.dependencies,
122 licenses: self.licenses != other.licenses,
123 vulnerabilities: self.vulnerabilities != other.vulnerabilities,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default)]
130pub struct ChangedSections {
131 pub components: bool,
132 pub dependencies: bool,
133 pub licenses: bool,
134 pub vulnerabilities: bool,
135}
136
137impl ChangedSections {
138 #[must_use]
140 pub const fn all_changed() -> Self {
141 Self {
142 components: true,
143 dependencies: true,
144 licenses: true,
145 vulnerabilities: true,
146 }
147 }
148
149 #[must_use]
151 pub const fn any(&self) -> bool {
152 self.components || self.dependencies || self.licenses || self.vulnerabilities
153 }
154
155 #[must_use]
157 pub const fn all(&self) -> bool {
158 self.components && self.dependencies && self.licenses && self.vulnerabilities
159 }
160
161 #[must_use]
163 pub fn count(&self) -> usize {
164 [
165 self.components,
166 self.dependencies,
167 self.licenses,
168 self.vulnerabilities,
169 ]
170 .iter()
171 .filter(|&&b| b)
172 .count()
173 }
174}
175
176#[derive(Debug, Clone)]
182pub struct CachedDiffResult {
183 pub result: Arc<DiffResult>,
185 pub computed_at: Instant,
187 pub old_hashes: SectionHashes,
189 pub new_hashes: SectionHashes,
191 pub hit_count: u64,
193}
194
195impl CachedDiffResult {
196 #[must_use]
198 pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
199 Self {
200 result: Arc::new(result),
201 computed_at: Instant::now(),
202 old_hashes,
203 new_hashes,
204 hit_count: 0,
205 }
206 }
207
208 #[must_use]
210 pub fn is_valid(&self, ttl: Duration) -> bool {
211 self.computed_at.elapsed() < ttl
212 }
213
214 #[must_use]
216 pub fn age(&self) -> Duration {
217 self.computed_at.elapsed()
218 }
219}
220
221#[derive(Debug, Clone)]
227pub struct DiffCacheConfig {
228 pub max_entries: usize,
230 pub ttl: Duration,
232 pub enable_incremental: bool,
234}
235
236impl Default for DiffCacheConfig {
237 fn default() -> Self {
238 Self {
239 max_entries: 100,
240 ttl: Duration::from_secs(3600), enable_incremental: true,
242 }
243 }
244}
245
246pub struct DiffCache {
251 cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
253 config: DiffCacheConfig,
255 stats: RwLock<CacheStats>,
257}
258
259#[derive(Debug, Clone, Default)]
261pub struct CacheStats {
262 pub lookups: u64,
264 pub hits: u64,
266 pub misses: u64,
268 pub incremental_hits: u64,
270 pub evictions: u64,
272 pub time_saved_ms: u64,
274}
275
276impl CacheStats {
277 #[must_use]
279 pub fn hit_rate(&self) -> f64 {
280 if self.lookups == 0 {
281 0.0
282 } else {
283 (self.hits + self.incremental_hits) as f64 / self.lookups as f64
284 }
285 }
286}
287
288impl DiffCache {
289 #[must_use]
291 pub fn new() -> Self {
292 Self::with_config(DiffCacheConfig::default())
293 }
294
295 #[must_use]
297 pub fn with_config(config: DiffCacheConfig) -> Self {
298 Self {
299 cache: RwLock::new(HashMap::new()),
300 config,
301 stats: RwLock::new(CacheStats::default()),
302 }
303 }
304
305 pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
309 let mut stats = self.stats.write().expect("stats lock poisoned");
310 stats.lookups += 1;
311
312 let result = {
313 let cache = self.cache.read().expect("cache lock poisoned");
314 cache.get(key).and_then(|entry| {
315 entry
316 .is_valid(self.config.ttl)
317 .then(|| Arc::clone(&entry.result))
318 })
319 };
320
321 if let Some(ref result) = result {
322 stats.hits += 1;
323 stats.time_saved_ms += Self::estimate_computation_time(result);
324 } else {
325 stats.misses += 1;
326 }
327 result
328 }
329
330 pub fn put(
332 &self,
333 key: DiffCacheKey,
334 result: DiffResult,
335 old_hashes: SectionHashes,
336 new_hashes: SectionHashes,
337 ) {
338 let mut cache = self.cache.write().expect("cache lock poisoned");
339
340 while cache.len() >= self.config.max_entries {
342 if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
343 cache.remove(&oldest_key);
344 let mut stats = self.stats.write().expect("stats lock poisoned");
345 stats.evictions += 1;
346 } else {
347 break;
348 }
349 }
350
351 cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
352 }
353
354 fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
356 cache
357 .iter()
358 .max_by_key(|(_, entry)| entry.age())
359 .map(|(key, _)| key.clone())
360 }
361
362 fn estimate_computation_time(result: &DiffResult) -> u64 {
364 let component_count = result.components.added.len()
366 + result.components.removed.len()
367 + result.components.modified.len();
368 (component_count / 10).max(1) as u64
369 }
370
371 pub fn stats(&self) -> CacheStats {
373 self.stats.read().expect("stats lock poisoned").clone()
374 }
375
376 pub fn clear(&self) {
378 let mut cache = self.cache.write().expect("cache lock poisoned");
379 cache.clear();
380 }
381
382 pub fn len(&self) -> usize {
384 self.cache.read().expect("cache lock poisoned").len()
385 }
386
387 pub fn is_empty(&self) -> bool {
389 self.cache.read().expect("cache lock poisoned").is_empty()
390 }
391}
392
393impl Default for DiffCache {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399pub struct IncrementalDiffEngine {
410 engine: DiffEngine,
412 cache: DiffCache,
414 last_old_hashes: RwLock<Option<SectionHashes>>,
416 last_new_hashes: RwLock<Option<SectionHashes>>,
417}
418
419impl IncrementalDiffEngine {
420 #[must_use]
422 pub fn new(engine: DiffEngine) -> Self {
423 Self {
424 engine,
425 cache: DiffCache::new(),
426 last_old_hashes: RwLock::new(None),
427 last_new_hashes: RwLock::new(None),
428 }
429 }
430
431 #[must_use]
433 pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
434 Self {
435 engine,
436 cache: DiffCache::with_config(config),
437 last_old_hashes: RwLock::new(None),
438 last_new_hashes: RwLock::new(None),
439 }
440 }
441
442 pub fn diff(&self, old: &NormalizedSbom, new: &NormalizedSbom) -> IncrementalDiffResult {
446 let start = Instant::now();
447 let cache_key = DiffCacheKey::from_sboms(old, new);
448
449 if let Some(cached) = self.cache.get(&cache_key) {
451 return IncrementalDiffResult {
452 result: (*cached).clone(),
453 cache_hit: CacheHitType::Full,
454 sections_recomputed: ChangedSections::default(),
455 computation_time: start.elapsed(),
456 };
457 }
458
459 let old_hashes = SectionHashes::from_sbom(old);
461 let new_hashes = SectionHashes::from_sbom(new);
462
463 let changed = {
465 let last_old = self
466 .last_old_hashes
467 .read()
468 .expect("last_old_hashes lock poisoned");
469 let last_new = self
470 .last_new_hashes
471 .read()
472 .expect("last_new_hashes lock poisoned");
473
474 if let (Some(prev_old), Some(prev_new)) = (&*last_old, &*last_new) {
475 let old_changed = old_hashes != *prev_old;
477 let new_changed = new_hashes != *prev_new;
478
479 if !old_changed && !new_changed {
480 None
483 } else {
484 Some(
485 prev_old
486 .changed_sections(&old_hashes)
487 .or(&prev_new.changed_sections(&new_hashes)),
488 )
489 }
490 } else {
491 None
492 }
493 };
494
495 let (result, cache_hit, sections_recomputed) = if let Some(ref changed) = changed
497 && !changed.all()
498 && changed.any()
499 {
500 if let Some(prev_result) = self.find_previous_result() {
503 match self.engine.diff_sections(old, new, changed, &prev_result) {
504 Ok(result) => (result, CacheHitType::Partial, changed.clone()),
505 Err(_) => {
506 let result = self.engine.diff(old, new).unwrap_or_default();
508 (result, CacheHitType::Miss, ChangedSections::all_changed())
509 }
510 }
511 } else {
512 let result = self.engine.diff(old, new).unwrap_or_default();
514 (result, CacheHitType::Miss, ChangedSections::all_changed())
515 }
516 } else {
517 let result = self.engine.diff(old, new).unwrap_or_default();
519 let sections = changed.unwrap_or_else(ChangedSections::all_changed);
520 (result, CacheHitType::Miss, sections)
521 };
522
523 if cache_hit == CacheHitType::Partial
525 && let Ok(mut stats) = self.cache.stats.write()
526 {
527 stats.incremental_hits += 1;
528 }
529
530 self.cache.put(
532 cache_key,
533 result.clone(),
534 old_hashes.clone(),
535 new_hashes.clone(),
536 );
537
538 *self
540 .last_old_hashes
541 .write()
542 .expect("last_old_hashes lock poisoned") = Some(old_hashes);
543 *self
544 .last_new_hashes
545 .write()
546 .expect("last_new_hashes lock poisoned") = Some(new_hashes);
547
548 IncrementalDiffResult {
549 result,
550 cache_hit,
551 sections_recomputed,
552 computation_time: start.elapsed(),
553 }
554 }
555
556 fn find_previous_result(&self) -> Option<Arc<DiffResult>> {
561 let cache = self.cache.cache.read().ok()?;
562 cache
563 .values()
564 .filter(|e| e.is_valid(Duration::from_secs(3600)))
565 .max_by_key(|e| e.hit_count)
566 .map(|e| Arc::clone(&e.result))
567 }
568
569 pub const fn engine(&self) -> &DiffEngine {
571 &self.engine
572 }
573
574 pub fn cache_stats(&self) -> CacheStats {
576 self.cache.stats()
577 }
578
579 pub fn clear_cache(&self) {
581 self.cache.clear();
582 }
583}
584
585impl ChangedSections {
586 const fn or(&self, other: &Self) -> Self {
588 Self {
589 components: self.components || other.components,
590 dependencies: self.dependencies || other.dependencies,
591 licenses: self.licenses || other.licenses,
592 vulnerabilities: self.vulnerabilities || other.vulnerabilities,
593 }
594 }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum CacheHitType {
600 Full,
602 Partial,
604 Miss,
606}
607
608#[derive(Debug)]
610pub struct IncrementalDiffResult {
611 pub result: DiffResult,
613 pub cache_hit: CacheHitType,
615 pub sections_recomputed: ChangedSections,
617 pub computation_time: Duration,
619}
620
621impl IncrementalDiffResult {
622 pub fn into_result(self) -> DiffResult {
624 self.result
625 }
626
627 #[must_use]
629 pub fn was_cached(&self) -> bool {
630 self.cache_hit == CacheHitType::Full
631 }
632}
633
634#[cfg(test)]
639mod tests {
640 use super::*;
641 use crate::model::DocumentMetadata;
642
643 fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
644 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
645 for comp_name in components {
646 let comp = crate::model::Component::new(
647 comp_name.to_string(),
648 format!("{}-{}", name, comp_name),
649 );
650 sbom.add_component(comp);
651 }
652 sbom.content_hash = {
654 use std::collections::hash_map::DefaultHasher;
655 let mut hasher = DefaultHasher::new();
656 name.hash(&mut hasher);
657 for c in components {
658 c.hash(&mut hasher);
659 }
660 hasher.finish()
661 };
662 sbom
663 }
664
665 #[test]
666 fn test_section_hashes() {
667 let sbom1 = make_sbom("test1", &["a", "b", "c"]);
668 let sbom2 = make_sbom("test2", &["a", "b", "c"]);
669 let sbom3 = make_sbom("test3", &["a", "b", "d"]);
670
671 let hash1 = SectionHashes::from_sbom(&sbom1);
672 let hash2 = SectionHashes::from_sbom(&sbom2);
673 let hash3 = SectionHashes::from_sbom(&sbom3);
674
675 assert_ne!(hash1.components, hash2.components);
678
679 assert_ne!(hash1.components, hash3.components);
681 }
682
683 #[test]
684 fn test_changed_sections() {
685 let hash1 = SectionHashes {
686 components: 100,
687 dependencies: 200,
688 licenses: 300,
689 vulnerabilities: 400,
690 };
691
692 let hash2 = SectionHashes {
693 components: 100,
694 dependencies: 200,
695 licenses: 999, vulnerabilities: 400,
697 };
698
699 let changed = hash1.changed_sections(&hash2);
700 assert!(!changed.components);
701 assert!(!changed.dependencies);
702 assert!(changed.licenses);
703 assert!(!changed.vulnerabilities);
704 assert_eq!(changed.count(), 1);
705 }
706
707 #[test]
708 fn test_diff_cache_basic() {
709 let cache = DiffCache::new();
710 let key = DiffCacheKey {
711 old_hash: 123,
712 new_hash: 456,
713 };
714
715 assert!(cache.get(&key).is_none());
717 assert!(cache.is_empty());
718
719 let result = DiffResult::new();
721 let hashes = SectionHashes {
722 components: 0,
723 dependencies: 0,
724 licenses: 0,
725 vulnerabilities: 0,
726 };
727 cache.put(key.clone(), result, hashes.clone(), hashes.clone());
728
729 assert!(cache.get(&key).is_some());
731 assert_eq!(cache.len(), 1);
732
733 let stats = cache.stats();
735 assert_eq!(stats.hits, 1);
736 assert_eq!(stats.misses, 1);
737 }
738
739 #[test]
740 fn test_diff_cache_eviction() {
741 let config = DiffCacheConfig {
742 max_entries: 3,
743 ttl: Duration::from_secs(3600),
744 enable_incremental: true,
745 };
746 let cache = DiffCache::with_config(config);
747
748 let hashes = SectionHashes {
749 components: 0,
750 dependencies: 0,
751 licenses: 0,
752 vulnerabilities: 0,
753 };
754
755 for i in 0..5 {
757 let key = DiffCacheKey {
758 old_hash: i,
759 new_hash: i + 100,
760 };
761 cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
762 }
763
764 assert_eq!(cache.len(), 3);
765 }
766
767 #[test]
768 fn test_cache_hit_type() {
769 assert_eq!(CacheHitType::Full, CacheHitType::Full);
770 assert_ne!(CacheHitType::Full, CacheHitType::Miss);
771 }
772
773 #[test]
774 fn test_incremental_diff_engine() {
775 let engine = DiffEngine::new();
776 let incremental = IncrementalDiffEngine::new(engine);
777
778 let old = make_sbom("old", &["a", "b", "c"]);
779 let new = make_sbom("new", &["a", "b", "d"]);
780
781 let result1 = incremental.diff(&old, &new);
783 assert_eq!(result1.cache_hit, CacheHitType::Miss);
784
785 let result2 = incremental.diff(&old, &new);
787 assert_eq!(result2.cache_hit, CacheHitType::Full);
788
789 let stats = incremental.cache_stats();
791 assert_eq!(stats.hits, 1);
792 assert_eq!(stats.misses, 1);
793 }
794
795 #[test]
796 fn test_changed_sections_all_changed() {
797 let all = ChangedSections::all_changed();
798 assert!(all.components);
799 assert!(all.dependencies);
800 assert!(all.licenses);
801 assert!(all.vulnerabilities);
802 assert!(all.all());
803 assert!(all.any());
804 assert_eq!(all.count(), 4);
805 }
806
807 #[test]
808 fn test_changed_sections_or_combine() {
809 let a = ChangedSections {
810 components: true,
811 dependencies: false,
812 licenses: false,
813 vulnerabilities: false,
814 };
815 let b = ChangedSections {
816 components: false,
817 dependencies: false,
818 licenses: true,
819 vulnerabilities: false,
820 };
821 let combined = a.or(&b);
822 assert!(combined.components);
823 assert!(!combined.dependencies);
824 assert!(combined.licenses);
825 assert!(!combined.vulnerabilities);
826 assert_eq!(combined.count(), 2);
827 }
828
829 #[test]
830 fn test_diff_sections_selective_recomputation() {
831 let engine = DiffEngine::new();
834 let old = make_sbom("old", &["a", "b", "c"]);
835 let new = make_sbom("new", &["a", "b", "d"]);
836
837 let full_result = engine.diff(&old, &new).expect("diff should succeed");
839
840 let sections = ChangedSections {
842 components: true,
843 dependencies: false,
844 licenses: false,
845 vulnerabilities: false,
846 };
847 let selective_result = engine
848 .diff_sections(&old, &new, §ions, &full_result)
849 .expect("diff_sections should succeed");
850
851 assert_eq!(
853 selective_result.components.added.len(),
854 full_result.components.added.len()
855 );
856 assert_eq!(
857 selective_result.components.removed.len(),
858 full_result.components.removed.len()
859 );
860 assert_eq!(
861 selective_result.components.modified.len(),
862 full_result.components.modified.len()
863 );
864
865 assert_eq!(
867 selective_result.dependencies.added.len(),
868 full_result.dependencies.added.len()
869 );
870 assert_eq!(
871 selective_result.dependencies.removed.len(),
872 full_result.dependencies.removed.len()
873 );
874 }
875
876 #[test]
877 fn test_diff_sections_all_changed_matches_full_diff() {
878 let engine = DiffEngine::new();
881 let old = make_sbom("old", &["a", "b", "c"]);
882 let new = make_sbom("new", &["a", "b", "d"]);
883
884 let full_result = engine.diff(&old, &new).expect("diff should succeed");
885 let sections = ChangedSections::all_changed();
886 let selective_result = engine
887 .diff_sections(&old, &new, §ions, &DiffResult::new())
888 .expect("diff_sections should succeed");
889
890 assert_eq!(
891 selective_result.components.added.len(),
892 full_result.components.added.len()
893 );
894 assert_eq!(
895 selective_result.components.removed.len(),
896 full_result.components.removed.len()
897 );
898 assert_eq!(
899 selective_result.vulnerabilities.introduced.len(),
900 full_result.vulnerabilities.introduced.len()
901 );
902 }
903
904 #[test]
905 fn test_incremental_partial_change_detection() {
906 let engine = DiffEngine::new();
911 let incremental = IncrementalDiffEngine::new(engine);
912
913 let old = make_sbom("old", &["a", "b", "c"]);
914 let new1 = make_sbom("new1", &["a", "b", "d"]);
915
916 let result1 = incremental.diff(&old, &new1);
918 assert_eq!(result1.cache_hit, CacheHitType::Miss);
919
920 let new2 = make_sbom("new2", &["a", "b", "e"]);
923 let result2 = incremental.diff(&old, &new2);
924
925 assert!(
928 result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
929 );
930 assert!(result2.sections_recomputed.any());
932 }
933
934 #[test]
935 fn test_find_previous_result_empty_cache() {
936 let engine = DiffEngine::new();
937 let incremental = IncrementalDiffEngine::new(engine);
938 assert!(incremental.find_previous_result().is_none());
940 }
941
942 #[test]
943 fn test_find_previous_result_after_diff() {
944 let engine = DiffEngine::new();
945 let incremental = IncrementalDiffEngine::new(engine);
946
947 let old = make_sbom("old", &["a", "b"]);
948 let new = make_sbom("new", &["a", "c"]);
949
950 let _ = incremental.diff(&old, &new);
952
953 assert!(incremental.find_previous_result().is_some());
955 }
956}