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)
486 .unwrap_or_default();
487
488 self.cache.put(
490 cache_key,
491 result.clone(),
492 old_hashes.clone(),
493 new_hashes.clone(),
494 );
495
496 *self
498 .last_old_hashes
499 .write()
500 .expect("last_old_hashes lock poisoned") = Some(old_hashes);
501 *self
502 .last_new_hashes
503 .write()
504 .expect("last_new_hashes lock poisoned") = Some(new_hashes);
505
506 let sections_recomputed = changed.unwrap_or(ChangedSections {
507 components: true,
508 dependencies: true,
509 licenses: true,
510 vulnerabilities: true,
511 });
512
513 IncrementalDiffResult {
514 result,
515 cache_hit: CacheHitType::Miss,
516 sections_recomputed,
517 computation_time: start.elapsed(),
518 }
519 }
520
521 pub const fn engine(&self) -> &DiffEngine {
523 &self.engine
524 }
525
526 pub fn cache_stats(&self) -> CacheStats {
528 self.cache.stats()
529 }
530
531 pub fn clear_cache(&self) {
533 self.cache.clear();
534 }
535}
536
537impl ChangedSections {
538 const fn or(&self, other: &Self) -> Self {
540 Self {
541 components: self.components || other.components,
542 dependencies: self.dependencies || other.dependencies,
543 licenses: self.licenses || other.licenses,
544 vulnerabilities: self.vulnerabilities || other.vulnerabilities,
545 }
546 }
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum CacheHitType {
552 Full,
554 Partial,
556 Miss,
558}
559
560#[derive(Debug)]
562pub struct IncrementalDiffResult {
563 pub result: DiffResult,
565 pub cache_hit: CacheHitType,
567 pub sections_recomputed: ChangedSections,
569 pub computation_time: Duration,
571}
572
573impl IncrementalDiffResult {
574 pub fn into_result(self) -> DiffResult {
576 self.result
577 }
578
579 #[must_use]
581 pub fn was_cached(&self) -> bool {
582 self.cache_hit == CacheHitType::Full
583 }
584}
585
586#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::model::DocumentMetadata;
594
595 fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
596 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
597 for comp_name in components {
598 let comp = crate::model::Component::new(
599 comp_name.to_string(),
600 format!("{}-{}", name, comp_name),
601 );
602 sbom.add_component(comp);
603 }
604 sbom.content_hash = {
606 use std::collections::hash_map::DefaultHasher;
607 let mut hasher = DefaultHasher::new();
608 name.hash(&mut hasher);
609 for c in components {
610 c.hash(&mut hasher);
611 }
612 hasher.finish()
613 };
614 sbom
615 }
616
617 #[test]
618 fn test_section_hashes() {
619 let sbom1 = make_sbom("test1", &["a", "b", "c"]);
620 let sbom2 = make_sbom("test2", &["a", "b", "c"]);
621 let sbom3 = make_sbom("test3", &["a", "b", "d"]);
622
623 let hash1 = SectionHashes::from_sbom(&sbom1);
624 let hash2 = SectionHashes::from_sbom(&sbom2);
625 let hash3 = SectionHashes::from_sbom(&sbom3);
626
627 assert_ne!(hash1.components, hash2.components);
630
631 assert_ne!(hash1.components, hash3.components);
633 }
634
635 #[test]
636 fn test_changed_sections() {
637 let hash1 = SectionHashes {
638 components: 100,
639 dependencies: 200,
640 licenses: 300,
641 vulnerabilities: 400,
642 };
643
644 let hash2 = SectionHashes {
645 components: 100,
646 dependencies: 200,
647 licenses: 999, vulnerabilities: 400,
649 };
650
651 let changed = hash1.changed_sections(&hash2);
652 assert!(!changed.components);
653 assert!(!changed.dependencies);
654 assert!(changed.licenses);
655 assert!(!changed.vulnerabilities);
656 assert_eq!(changed.count(), 1);
657 }
658
659 #[test]
660 fn test_diff_cache_basic() {
661 let cache = DiffCache::new();
662 let key = DiffCacheKey {
663 old_hash: 123,
664 new_hash: 456,
665 };
666
667 assert!(cache.get(&key).is_none());
669 assert!(cache.is_empty());
670
671 let result = DiffResult::new();
673 let hashes = SectionHashes {
674 components: 0,
675 dependencies: 0,
676 licenses: 0,
677 vulnerabilities: 0,
678 };
679 cache.put(key.clone(), result, hashes.clone(), hashes.clone());
680
681 assert!(cache.get(&key).is_some());
683 assert_eq!(cache.len(), 1);
684
685 let stats = cache.stats();
687 assert_eq!(stats.hits, 1);
688 assert_eq!(stats.misses, 1);
689 }
690
691 #[test]
692 fn test_diff_cache_eviction() {
693 let config = DiffCacheConfig {
694 max_entries: 3,
695 ttl: Duration::from_secs(3600),
696 enable_incremental: true,
697 };
698 let cache = DiffCache::with_config(config);
699
700 let hashes = SectionHashes {
701 components: 0,
702 dependencies: 0,
703 licenses: 0,
704 vulnerabilities: 0,
705 };
706
707 for i in 0..5 {
709 let key = DiffCacheKey {
710 old_hash: i,
711 new_hash: i + 100,
712 };
713 cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
714 }
715
716 assert_eq!(cache.len(), 3);
717 }
718
719 #[test]
720 fn test_cache_hit_type() {
721 assert_eq!(CacheHitType::Full, CacheHitType::Full);
722 assert_ne!(CacheHitType::Full, CacheHitType::Miss);
723 }
724
725 #[test]
726 fn test_incremental_diff_engine() {
727 let engine = DiffEngine::new();
728 let incremental = IncrementalDiffEngine::new(engine);
729
730 let old = make_sbom("old", &["a", "b", "c"]);
731 let new = make_sbom("new", &["a", "b", "d"]);
732
733 let result1 = incremental.diff(&old, &new);
735 assert_eq!(result1.cache_hit, CacheHitType::Miss);
736
737 let result2 = incremental.diff(&old, &new);
739 assert_eq!(result2.cache_hit, CacheHitType::Full);
740
741 let stats = incremental.cache_stats();
743 assert_eq!(stats.hits, 1);
744 assert_eq!(stats.misses, 1);
745 }
746}