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 any(&self) -> bool {
141 self.components || self.dependencies || self.licenses || self.vulnerabilities
142 }
143
144 #[must_use]
146 pub const fn all(&self) -> bool {
147 self.components && self.dependencies && self.licenses && self.vulnerabilities
148 }
149
150 #[must_use]
152 pub fn count(&self) -> usize {
153 [
154 self.components,
155 self.dependencies,
156 self.licenses,
157 self.vulnerabilities,
158 ]
159 .iter()
160 .filter(|&&b| b)
161 .count()
162 }
163}
164
165#[derive(Debug, Clone)]
171pub struct CachedDiffResult {
172 pub result: Arc<DiffResult>,
174 pub computed_at: Instant,
176 pub old_hashes: SectionHashes,
178 pub new_hashes: SectionHashes,
180 pub hit_count: u64,
182}
183
184impl CachedDiffResult {
185 #[must_use]
187 pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
188 Self {
189 result: Arc::new(result),
190 computed_at: Instant::now(),
191 old_hashes,
192 new_hashes,
193 hit_count: 0,
194 }
195 }
196
197 #[must_use]
199 pub fn is_valid(&self, ttl: Duration) -> bool {
200 self.computed_at.elapsed() < ttl
201 }
202
203 #[must_use]
205 pub fn age(&self) -> Duration {
206 self.computed_at.elapsed()
207 }
208}
209
210#[derive(Debug, Clone)]
216pub struct DiffCacheConfig {
217 pub max_entries: usize,
219 pub ttl: Duration,
221 pub enable_incremental: bool,
223}
224
225impl Default for DiffCacheConfig {
226 fn default() -> Self {
227 Self {
228 max_entries: 100,
229 ttl: Duration::from_secs(3600), enable_incremental: true,
231 }
232 }
233}
234
235pub struct DiffCache {
240 cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
242 config: DiffCacheConfig,
244 stats: RwLock<CacheStats>,
246}
247
248#[derive(Debug, Clone, Default)]
250pub struct CacheStats {
251 pub lookups: u64,
253 pub hits: u64,
255 pub misses: u64,
257 pub incremental_hits: u64,
259 pub evictions: u64,
261 pub time_saved_ms: u64,
263}
264
265impl CacheStats {
266 #[must_use]
268 pub fn hit_rate(&self) -> f64 {
269 if self.lookups == 0 {
270 0.0
271 } else {
272 (self.hits + self.incremental_hits) as f64 / self.lookups as f64
273 }
274 }
275}
276
277impl DiffCache {
278 #[must_use]
280 pub fn new() -> Self {
281 Self::with_config(DiffCacheConfig::default())
282 }
283
284 #[must_use]
286 pub fn with_config(config: DiffCacheConfig) -> Self {
287 Self {
288 cache: RwLock::new(HashMap::new()),
289 config,
290 stats: RwLock::new(CacheStats::default()),
291 }
292 }
293
294 pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
298 let mut stats = self.stats.write().expect("stats lock poisoned");
299 stats.lookups += 1;
300
301 let result = {
302 let cache = self.cache.read().expect("cache lock poisoned");
303 cache.get(key).and_then(|entry| {
304 entry
305 .is_valid(self.config.ttl)
306 .then(|| Arc::clone(&entry.result))
307 })
308 };
309
310 if let Some(ref result) = result {
311 stats.hits += 1;
312 stats.time_saved_ms += Self::estimate_computation_time(result);
313 } else {
314 stats.misses += 1;
315 }
316 result
317 }
318
319 pub fn put(
321 &self,
322 key: DiffCacheKey,
323 result: DiffResult,
324 old_hashes: SectionHashes,
325 new_hashes: SectionHashes,
326 ) {
327 let mut cache = self.cache.write().expect("cache lock poisoned");
328
329 while cache.len() >= self.config.max_entries {
331 if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
332 cache.remove(&oldest_key);
333 let mut stats = self.stats.write().expect("stats lock poisoned");
334 stats.evictions += 1;
335 } else {
336 break;
337 }
338 }
339
340 cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
341 }
342
343 fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
345 cache
346 .iter()
347 .max_by_key(|(_, entry)| entry.age())
348 .map(|(key, _)| key.clone())
349 }
350
351 fn estimate_computation_time(result: &DiffResult) -> u64 {
353 let component_count = result.components.added.len()
355 + result.components.removed.len()
356 + result.components.modified.len();
357 (component_count / 10).max(1) as u64
358 }
359
360 pub fn stats(&self) -> CacheStats {
362 self.stats.read().expect("stats lock poisoned").clone()
363 }
364
365 pub fn clear(&self) {
367 let mut cache = self.cache.write().expect("cache lock poisoned");
368 cache.clear();
369 }
370
371 pub fn len(&self) -> usize {
373 self.cache.read().expect("cache lock poisoned").len()
374 }
375
376 pub fn is_empty(&self) -> bool {
378 self.cache.read().expect("cache lock poisoned").is_empty()
379 }
380}
381
382impl Default for DiffCache {
383 fn default() -> Self {
384 Self::new()
385 }
386}
387
388pub struct IncrementalDiffEngine {
399 engine: DiffEngine,
401 cache: DiffCache,
403 last_old_hashes: RwLock<Option<SectionHashes>>,
405 last_new_hashes: RwLock<Option<SectionHashes>>,
406}
407
408impl IncrementalDiffEngine {
409 #[must_use]
411 pub fn new(engine: DiffEngine) -> Self {
412 Self {
413 engine,
414 cache: DiffCache::new(),
415 last_old_hashes: RwLock::new(None),
416 last_new_hashes: RwLock::new(None),
417 }
418 }
419
420 #[must_use]
422 pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
423 Self {
424 engine,
425 cache: DiffCache::with_config(config),
426 last_old_hashes: RwLock::new(None),
427 last_new_hashes: RwLock::new(None),
428 }
429 }
430
431 pub fn diff(&self, old: &NormalizedSbom, new: &NormalizedSbom) -> IncrementalDiffResult {
435 let start = Instant::now();
436 let cache_key = DiffCacheKey::from_sboms(old, new);
437
438 if let Some(cached) = self.cache.get(&cache_key) {
440 return IncrementalDiffResult {
441 result: (*cached).clone(),
442 cache_hit: CacheHitType::Full,
443 sections_recomputed: ChangedSections::default(),
444 computation_time: start.elapsed(),
445 };
446 }
447
448 let old_hashes = SectionHashes::from_sbom(old);
450 let new_hashes = SectionHashes::from_sbom(new);
451
452 let changed = {
454 let last_old = self
455 .last_old_hashes
456 .read()
457 .expect("last_old_hashes lock poisoned");
458 let last_new = self
459 .last_new_hashes
460 .read()
461 .expect("last_new_hashes lock poisoned");
462
463 if let (Some(prev_old), Some(prev_new)) = (&*last_old, &*last_new) {
464 let old_changed = old_hashes != *prev_old;
466 let new_changed = new_hashes != *prev_new;
467
468 if !old_changed && !new_changed {
469 None
472 } else {
473 Some(
474 prev_old
475 .changed_sections(&old_hashes)
476 .or(&prev_new.changed_sections(&new_hashes)),
477 )
478 }
479 } else {
480 None
481 }
482 };
483
484 let result = self.engine.diff(old, new).unwrap_or_default();
486
487 self.cache.put(
489 cache_key,
490 result.clone(),
491 old_hashes.clone(),
492 new_hashes.clone(),
493 );
494
495 *self
497 .last_old_hashes
498 .write()
499 .expect("last_old_hashes lock poisoned") = Some(old_hashes);
500 *self
501 .last_new_hashes
502 .write()
503 .expect("last_new_hashes lock poisoned") = Some(new_hashes);
504
505 let sections_recomputed = changed.unwrap_or(ChangedSections {
506 components: true,
507 dependencies: true,
508 licenses: true,
509 vulnerabilities: true,
510 });
511
512 IncrementalDiffResult {
513 result,
514 cache_hit: CacheHitType::Miss,
515 sections_recomputed,
516 computation_time: start.elapsed(),
517 }
518 }
519
520 pub const fn engine(&self) -> &DiffEngine {
522 &self.engine
523 }
524
525 pub fn cache_stats(&self) -> CacheStats {
527 self.cache.stats()
528 }
529
530 pub fn clear_cache(&self) {
532 self.cache.clear();
533 }
534}
535
536impl ChangedSections {
537 const fn or(&self, other: &Self) -> Self {
539 Self {
540 components: self.components || other.components,
541 dependencies: self.dependencies || other.dependencies,
542 licenses: self.licenses || other.licenses,
543 vulnerabilities: self.vulnerabilities || other.vulnerabilities,
544 }
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum CacheHitType {
551 Full,
553 Partial,
555 Miss,
557}
558
559#[derive(Debug)]
561pub struct IncrementalDiffResult {
562 pub result: DiffResult,
564 pub cache_hit: CacheHitType,
566 pub sections_recomputed: ChangedSections,
568 pub computation_time: Duration,
570}
571
572impl IncrementalDiffResult {
573 pub fn into_result(self) -> DiffResult {
575 self.result
576 }
577
578 #[must_use]
580 pub fn was_cached(&self) -> bool {
581 self.cache_hit == CacheHitType::Full
582 }
583}
584
585#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::model::DocumentMetadata;
593
594 fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
595 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
596 for comp_name in components {
597 let comp = crate::model::Component::new(
598 comp_name.to_string(),
599 format!("{}-{}", name, comp_name),
600 );
601 sbom.add_component(comp);
602 }
603 sbom.content_hash = {
605 use std::collections::hash_map::DefaultHasher;
606 let mut hasher = DefaultHasher::new();
607 name.hash(&mut hasher);
608 for c in components {
609 c.hash(&mut hasher);
610 }
611 hasher.finish()
612 };
613 sbom
614 }
615
616 #[test]
617 fn test_section_hashes() {
618 let sbom1 = make_sbom("test1", &["a", "b", "c"]);
619 let sbom2 = make_sbom("test2", &["a", "b", "c"]);
620 let sbom3 = make_sbom("test3", &["a", "b", "d"]);
621
622 let hash1 = SectionHashes::from_sbom(&sbom1);
623 let hash2 = SectionHashes::from_sbom(&sbom2);
624 let hash3 = SectionHashes::from_sbom(&sbom3);
625
626 assert_ne!(hash1.components, hash2.components);
629
630 assert_ne!(hash1.components, hash3.components);
632 }
633
634 #[test]
635 fn test_changed_sections() {
636 let hash1 = SectionHashes {
637 components: 100,
638 dependencies: 200,
639 licenses: 300,
640 vulnerabilities: 400,
641 };
642
643 let hash2 = SectionHashes {
644 components: 100,
645 dependencies: 200,
646 licenses: 999, vulnerabilities: 400,
648 };
649
650 let changed = hash1.changed_sections(&hash2);
651 assert!(!changed.components);
652 assert!(!changed.dependencies);
653 assert!(changed.licenses);
654 assert!(!changed.vulnerabilities);
655 assert_eq!(changed.count(), 1);
656 }
657
658 #[test]
659 fn test_diff_cache_basic() {
660 let cache = DiffCache::new();
661 let key = DiffCacheKey {
662 old_hash: 123,
663 new_hash: 456,
664 };
665
666 assert!(cache.get(&key).is_none());
668 assert!(cache.is_empty());
669
670 let result = DiffResult::new();
672 let hashes = SectionHashes {
673 components: 0,
674 dependencies: 0,
675 licenses: 0,
676 vulnerabilities: 0,
677 };
678 cache.put(key.clone(), result, hashes.clone(), hashes.clone());
679
680 assert!(cache.get(&key).is_some());
682 assert_eq!(cache.len(), 1);
683
684 let stats = cache.stats();
686 assert_eq!(stats.hits, 1);
687 assert_eq!(stats.misses, 1);
688 }
689
690 #[test]
691 fn test_diff_cache_eviction() {
692 let config = DiffCacheConfig {
693 max_entries: 3,
694 ttl: Duration::from_secs(3600),
695 enable_incremental: true,
696 };
697 let cache = DiffCache::with_config(config);
698
699 let hashes = SectionHashes {
700 components: 0,
701 dependencies: 0,
702 licenses: 0,
703 vulnerabilities: 0,
704 };
705
706 for i in 0..5 {
708 let key = DiffCacheKey {
709 old_hash: i,
710 new_hash: i + 100,
711 };
712 cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
713 }
714
715 assert_eq!(cache.len(), 3);
716 }
717
718 #[test]
719 fn test_cache_hit_type() {
720 assert_eq!(CacheHitType::Full, CacheHitType::Full);
721 assert_ne!(CacheHitType::Full, CacheHitType::Miss);
722 }
723
724 #[test]
725 fn test_incremental_diff_engine() {
726 let engine = DiffEngine::new();
727 let incremental = IncrementalDiffEngine::new(engine);
728
729 let old = make_sbom("old", &["a", "b", "c"]);
730 let new = make_sbom("new", &["a", "b", "d"]);
731
732 let result1 = incremental.diff(&old, &new);
734 assert_eq!(result1.cache_hit, CacheHitType::Miss);
735
736 let result2 = incremental.diff(&old, &new);
738 assert_eq!(result2.cache_hit, CacheHitType::Full);
739
740 let stats = incremental.cache_stats();
742 assert_eq!(stats.hits, 1);
743 assert_eq!(stats.misses, 1);
744 }
745}