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 pub fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
44 Self {
45 old_hash: old.content_hash,
46 new_hash: new.content_hash,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SectionHashes {
54 pub components: u64,
56 pub dependencies: u64,
58 pub licenses: u64,
60 pub vulnerabilities: u64,
62}
63
64impl SectionHashes {
65 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
67 use std::collections::hash_map::DefaultHasher;
68
69 let mut hasher = DefaultHasher::new();
71 for (id, comp) in &sbom.components {
72 id.hash(&mut hasher);
73 comp.name.hash(&mut hasher);
74 comp.version.hash(&mut hasher);
75 comp.content_hash.hash(&mut hasher);
76 }
77 let components = hasher.finish();
78
79 let mut hasher = DefaultHasher::new();
81 for edge in &sbom.edges {
82 edge.from.hash(&mut hasher);
83 edge.to.hash(&mut hasher);
84 edge.relationship.to_string().hash(&mut hasher);
85 }
86 let dependencies = hasher.finish();
87
88 let mut hasher = DefaultHasher::new();
90 for (_, comp) in &sbom.components {
91 for lic in &comp.licenses.declared {
92 lic.expression.hash(&mut hasher);
93 }
94 }
95 let licenses = hasher.finish();
96
97 let mut hasher = DefaultHasher::new();
99 for (_, comp) in &sbom.components {
100 for vuln in &comp.vulnerabilities {
101 vuln.id.hash(&mut hasher);
102 }
103 }
104 let vulnerabilities = hasher.finish();
105
106 Self {
107 components,
108 dependencies,
109 licenses,
110 vulnerabilities,
111 }
112 }
113
114 pub fn changed_sections(&self, other: &SectionHashes) -> ChangedSections {
116 ChangedSections {
117 components: self.components != other.components,
118 dependencies: self.dependencies != other.dependencies,
119 licenses: self.licenses != other.licenses,
120 vulnerabilities: self.vulnerabilities != other.vulnerabilities,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct ChangedSections {
128 pub components: bool,
129 pub dependencies: bool,
130 pub licenses: bool,
131 pub vulnerabilities: bool,
132}
133
134impl ChangedSections {
135 pub fn any(&self) -> bool {
137 self.components || self.dependencies || self.licenses || self.vulnerabilities
138 }
139
140 pub fn all(&self) -> bool {
142 self.components && self.dependencies && self.licenses && self.vulnerabilities
143 }
144
145 pub fn count(&self) -> usize {
147 [
148 self.components,
149 self.dependencies,
150 self.licenses,
151 self.vulnerabilities,
152 ]
153 .iter()
154 .filter(|&&b| b)
155 .count()
156 }
157}
158
159#[derive(Debug, Clone)]
165pub struct CachedDiffResult {
166 pub result: Arc<DiffResult>,
168 pub computed_at: Instant,
170 pub old_hashes: SectionHashes,
172 pub new_hashes: SectionHashes,
174 pub hit_count: u64,
176}
177
178impl CachedDiffResult {
179 pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
181 Self {
182 result: Arc::new(result),
183 computed_at: Instant::now(),
184 old_hashes,
185 new_hashes,
186 hit_count: 0,
187 }
188 }
189
190 pub fn is_valid(&self, ttl: Duration) -> bool {
192 self.computed_at.elapsed() < ttl
193 }
194
195 pub fn age(&self) -> Duration {
197 self.computed_at.elapsed()
198 }
199}
200
201#[derive(Debug, Clone)]
207pub struct DiffCacheConfig {
208 pub max_entries: usize,
210 pub ttl: Duration,
212 pub enable_incremental: bool,
214}
215
216impl Default for DiffCacheConfig {
217 fn default() -> Self {
218 Self {
219 max_entries: 100,
220 ttl: Duration::from_secs(3600), enable_incremental: true,
222 }
223 }
224}
225
226pub struct DiffCache {
231 cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
233 config: DiffCacheConfig,
235 stats: RwLock<CacheStats>,
237}
238
239#[derive(Debug, Clone, Default)]
241pub struct CacheStats {
242 pub lookups: u64,
244 pub hits: u64,
246 pub misses: u64,
248 pub incremental_hits: u64,
250 pub evictions: u64,
252 pub time_saved_ms: u64,
254}
255
256impl CacheStats {
257 pub fn hit_rate(&self) -> f64 {
259 if self.lookups == 0 {
260 0.0
261 } else {
262 (self.hits + self.incremental_hits) as f64 / self.lookups as f64
263 }
264 }
265}
266
267impl DiffCache {
268 pub fn new() -> Self {
270 Self::with_config(DiffCacheConfig::default())
271 }
272
273 pub fn with_config(config: DiffCacheConfig) -> Self {
275 Self {
276 cache: RwLock::new(HashMap::new()),
277 config,
278 stats: RwLock::new(CacheStats::default()),
279 }
280 }
281
282 pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
286 let mut stats = self.stats.write().expect("stats lock poisoned");
287 stats.lookups += 1;
288
289 let cache = self.cache.read().expect("cache lock poisoned");
290 if let Some(entry) = cache.get(key) {
291 if entry.is_valid(self.config.ttl) {
292 stats.hits += 1;
293 stats.time_saved_ms += Self::estimate_computation_time(&entry.result);
295 return Some(Arc::clone(&entry.result));
296 }
297 }
298 stats.misses += 1;
299 None
300 }
301
302 pub fn put(
304 &self,
305 key: DiffCacheKey,
306 result: DiffResult,
307 old_hashes: SectionHashes,
308 new_hashes: SectionHashes,
309 ) {
310 let mut cache = self.cache.write().expect("cache lock poisoned");
311
312 while cache.len() >= self.config.max_entries {
314 if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
315 cache.remove(&oldest_key);
316 let mut stats = self.stats.write().expect("stats lock poisoned");
317 stats.evictions += 1;
318 } else {
319 break;
320 }
321 }
322
323 cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
324 }
325
326 fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
328 cache
329 .iter()
330 .max_by_key(|(_, entry)| entry.age())
331 .map(|(key, _)| key.clone())
332 }
333
334 fn estimate_computation_time(result: &DiffResult) -> u64 {
336 let component_count = result.components.added.len()
338 + result.components.removed.len()
339 + result.components.modified.len();
340 (component_count / 10).max(1) as u64
341 }
342
343 pub fn stats(&self) -> CacheStats {
345 self.stats.read().expect("stats lock poisoned").clone()
346 }
347
348 pub fn clear(&self) {
350 let mut cache = self.cache.write().expect("cache lock poisoned");
351 cache.clear();
352 }
353
354 pub fn len(&self) -> usize {
356 self.cache.read().expect("cache lock poisoned").len()
357 }
358
359 pub fn is_empty(&self) -> bool {
361 self.cache.read().expect("cache lock poisoned").is_empty()
362 }
363}
364
365impl Default for DiffCache {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371pub struct IncrementalDiffEngine {
382 engine: DiffEngine,
384 cache: DiffCache,
386 last_old_hashes: RwLock<Option<SectionHashes>>,
388 last_new_hashes: RwLock<Option<SectionHashes>>,
389}
390
391impl IncrementalDiffEngine {
392 pub fn new(engine: DiffEngine) -> Self {
394 Self {
395 engine,
396 cache: DiffCache::new(),
397 last_old_hashes: RwLock::new(None),
398 last_new_hashes: RwLock::new(None),
399 }
400 }
401
402 pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
404 Self {
405 engine,
406 cache: DiffCache::with_config(config),
407 last_old_hashes: RwLock::new(None),
408 last_new_hashes: RwLock::new(None),
409 }
410 }
411
412 pub fn diff(&self, old: &NormalizedSbom, new: &NormalizedSbom) -> IncrementalDiffResult {
416 let start = Instant::now();
417 let cache_key = DiffCacheKey::from_sboms(old, new);
418
419 if let Some(cached) = self.cache.get(&cache_key) {
421 return IncrementalDiffResult {
422 result: (*cached).clone(),
423 cache_hit: CacheHitType::Full,
424 sections_recomputed: ChangedSections::default(),
425 computation_time: start.elapsed(),
426 };
427 }
428
429 let old_hashes = SectionHashes::from_sbom(old);
431 let new_hashes = SectionHashes::from_sbom(new);
432
433 let changed = {
435 let last_old = self
436 .last_old_hashes
437 .read()
438 .expect("last_old_hashes lock poisoned");
439 let last_new = self
440 .last_new_hashes
441 .read()
442 .expect("last_new_hashes lock poisoned");
443
444 if let (Some(ref prev_old), Some(ref prev_new)) = (&*last_old, &*last_new) {
445 let old_changed = old_hashes != *prev_old;
447 let new_changed = new_hashes != *prev_new;
448
449 if !old_changed && !new_changed {
450 None
453 } else {
454 Some(
455 prev_old
456 .changed_sections(&old_hashes)
457 .or(&prev_new.changed_sections(&new_hashes)),
458 )
459 }
460 } else {
461 None
462 }
463 };
464
465 let result = self.engine.diff(old, new)
467 .unwrap_or_default();
468
469 self.cache.put(
471 cache_key,
472 result.clone(),
473 old_hashes.clone(),
474 new_hashes.clone(),
475 );
476
477 *self
479 .last_old_hashes
480 .write()
481 .expect("last_old_hashes lock poisoned") = Some(old_hashes);
482 *self
483 .last_new_hashes
484 .write()
485 .expect("last_new_hashes lock poisoned") = Some(new_hashes);
486
487 let sections_recomputed = changed.unwrap_or(ChangedSections {
488 components: true,
489 dependencies: true,
490 licenses: true,
491 vulnerabilities: true,
492 });
493
494 IncrementalDiffResult {
495 result,
496 cache_hit: CacheHitType::Miss,
497 sections_recomputed,
498 computation_time: start.elapsed(),
499 }
500 }
501
502 pub fn engine(&self) -> &DiffEngine {
504 &self.engine
505 }
506
507 pub fn cache_stats(&self) -> CacheStats {
509 self.cache.stats()
510 }
511
512 pub fn clear_cache(&self) {
514 self.cache.clear();
515 }
516}
517
518impl ChangedSections {
519 fn or(&self, other: &ChangedSections) -> ChangedSections {
521 ChangedSections {
522 components: self.components || other.components,
523 dependencies: self.dependencies || other.dependencies,
524 licenses: self.licenses || other.licenses,
525 vulnerabilities: self.vulnerabilities || other.vulnerabilities,
526 }
527 }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum CacheHitType {
533 Full,
535 Partial,
537 Miss,
539}
540
541#[derive(Debug)]
543pub struct IncrementalDiffResult {
544 pub result: DiffResult,
546 pub cache_hit: CacheHitType,
548 pub sections_recomputed: ChangedSections,
550 pub computation_time: Duration,
552}
553
554impl IncrementalDiffResult {
555 pub fn into_result(self) -> DiffResult {
557 self.result
558 }
559
560 pub fn was_cached(&self) -> bool {
562 self.cache_hit == CacheHitType::Full
563 }
564}
565
566#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::model::DocumentMetadata;
574
575 fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
576 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
577 for comp_name in components {
578 let comp = crate::model::Component::new(
579 comp_name.to_string(),
580 format!("{}-{}", name, comp_name),
581 );
582 sbom.add_component(comp);
583 }
584 sbom.content_hash = {
586 use std::collections::hash_map::DefaultHasher;
587 let mut hasher = DefaultHasher::new();
588 name.hash(&mut hasher);
589 for c in components {
590 c.hash(&mut hasher);
591 }
592 hasher.finish()
593 };
594 sbom
595 }
596
597 #[test]
598 fn test_section_hashes() {
599 let sbom1 = make_sbom("test1", &["a", "b", "c"]);
600 let sbom2 = make_sbom("test2", &["a", "b", "c"]);
601 let sbom3 = make_sbom("test3", &["a", "b", "d"]);
602
603 let hash1 = SectionHashes::from_sbom(&sbom1);
604 let hash2 = SectionHashes::from_sbom(&sbom2);
605 let hash3 = SectionHashes::from_sbom(&sbom3);
606
607 assert_ne!(hash1.components, hash2.components);
610
611 assert_ne!(hash1.components, hash3.components);
613 }
614
615 #[test]
616 fn test_changed_sections() {
617 let hash1 = SectionHashes {
618 components: 100,
619 dependencies: 200,
620 licenses: 300,
621 vulnerabilities: 400,
622 };
623
624 let hash2 = SectionHashes {
625 components: 100,
626 dependencies: 200,
627 licenses: 999, vulnerabilities: 400,
629 };
630
631 let changed = hash1.changed_sections(&hash2);
632 assert!(!changed.components);
633 assert!(!changed.dependencies);
634 assert!(changed.licenses);
635 assert!(!changed.vulnerabilities);
636 assert_eq!(changed.count(), 1);
637 }
638
639 #[test]
640 fn test_diff_cache_basic() {
641 let cache = DiffCache::new();
642 let key = DiffCacheKey {
643 old_hash: 123,
644 new_hash: 456,
645 };
646
647 assert!(cache.get(&key).is_none());
649 assert!(cache.is_empty());
650
651 let result = DiffResult::new();
653 let hashes = SectionHashes {
654 components: 0,
655 dependencies: 0,
656 licenses: 0,
657 vulnerabilities: 0,
658 };
659 cache.put(key.clone(), result, hashes.clone(), hashes.clone());
660
661 assert!(cache.get(&key).is_some());
663 assert_eq!(cache.len(), 1);
664
665 let stats = cache.stats();
667 assert_eq!(stats.hits, 1);
668 assert_eq!(stats.misses, 1);
669 }
670
671 #[test]
672 fn test_diff_cache_eviction() {
673 let config = DiffCacheConfig {
674 max_entries: 3,
675 ttl: Duration::from_secs(3600),
676 enable_incremental: true,
677 };
678 let cache = DiffCache::with_config(config);
679
680 let hashes = SectionHashes {
681 components: 0,
682 dependencies: 0,
683 licenses: 0,
684 vulnerabilities: 0,
685 };
686
687 for i in 0..5 {
689 let key = DiffCacheKey {
690 old_hash: i,
691 new_hash: i + 100,
692 };
693 cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
694 }
695
696 assert_eq!(cache.len(), 3);
697 }
698
699 #[test]
700 fn test_cache_hit_type() {
701 assert_eq!(CacheHitType::Full, CacheHitType::Full);
702 assert_ne!(CacheHitType::Full, CacheHitType::Miss);
703 }
704
705 #[test]
706 fn test_incremental_diff_engine() {
707 let engine = DiffEngine::new();
708 let incremental = IncrementalDiffEngine::new(engine);
709
710 let old = make_sbom("old", &["a", "b", "c"]);
711 let new = make_sbom("new", &["a", "b", "d"]);
712
713 let result1 = incremental.diff(&old, &new);
715 assert_eq!(result1.cache_hit, CacheHitType::Miss);
716
717 let result2 = incremental.diff(&old, &new);
719 assert_eq!(result2.cache_hit, CacheHitType::Full);
720
721 let stats = incremental.cache_stats();
723 assert_eq!(stats.hits, 1);
724 assert_eq!(stats.misses, 1);
725 }
726}