use crate::diff::{DiffEngine, DiffResult};
use crate::model::NormalizedSbom;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DiffCacheKey {
pub old_hash: u64,
pub new_hash: u64,
}
impl DiffCacheKey {
#[must_use]
pub const fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
Self {
old_hash: old.content_hash,
new_hash: new.content_hash,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SectionHashes {
pub components: u64,
pub dependencies: u64,
pub licenses: u64,
pub vulnerabilities: u64,
}
impl SectionHashes {
#[must_use]
pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
for (id, comp) in &sbom.components {
id.hash(&mut hasher);
comp.name.hash(&mut hasher);
comp.version.hash(&mut hasher);
comp.content_hash.hash(&mut hasher);
}
let components = hasher.finish();
let mut hasher = DefaultHasher::new();
for edge in &sbom.edges {
edge.from.hash(&mut hasher);
edge.to.hash(&mut hasher);
edge.relationship.to_string().hash(&mut hasher);
}
let dependencies = hasher.finish();
let mut hasher = DefaultHasher::new();
for (_, comp) in &sbom.components {
for lic in &comp.licenses.declared {
lic.expression.hash(&mut hasher);
}
}
let licenses = hasher.finish();
let mut hasher = DefaultHasher::new();
for (_, comp) in &sbom.components {
for vuln in &comp.vulnerabilities {
vuln.id.hash(&mut hasher);
}
}
let vulnerabilities = hasher.finish();
Self {
components,
dependencies,
licenses,
vulnerabilities,
}
}
#[must_use]
pub const fn changed_sections(&self, other: &Self) -> ChangedSections {
ChangedSections {
components: self.components != other.components,
dependencies: self.dependencies != other.dependencies,
licenses: self.licenses != other.licenses,
vulnerabilities: self.vulnerabilities != other.vulnerabilities,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChangedSections {
pub components: bool,
pub dependencies: bool,
pub licenses: bool,
pub vulnerabilities: bool,
}
impl ChangedSections {
#[must_use]
pub const fn all_changed() -> Self {
Self {
components: true,
dependencies: true,
licenses: true,
vulnerabilities: true,
}
}
#[must_use]
pub const fn any(&self) -> bool {
self.components || self.dependencies || self.licenses || self.vulnerabilities
}
#[must_use]
pub const fn all(&self) -> bool {
self.components && self.dependencies && self.licenses && self.vulnerabilities
}
#[must_use]
pub fn count(&self) -> usize {
[
self.components,
self.dependencies,
self.licenses,
self.vulnerabilities,
]
.iter()
.filter(|&&b| b)
.count()
}
}
#[derive(Debug, Clone)]
pub struct CachedDiffResult {
pub result: Arc<DiffResult>,
pub computed_at: Instant,
pub old_hashes: SectionHashes,
pub new_hashes: SectionHashes,
pub hit_count: u64,
}
impl CachedDiffResult {
#[must_use]
pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
Self {
result: Arc::new(result),
computed_at: Instant::now(),
old_hashes,
new_hashes,
hit_count: 0,
}
}
#[must_use]
pub fn is_valid(&self, ttl: Duration) -> bool {
self.computed_at.elapsed() < ttl
}
#[must_use]
pub fn age(&self) -> Duration {
self.computed_at.elapsed()
}
}
#[derive(Debug, Clone)]
pub struct DiffCacheConfig {
pub max_entries: usize,
pub ttl: Duration,
pub enable_incremental: bool,
}
impl Default for DiffCacheConfig {
fn default() -> Self {
Self {
max_entries: 100,
ttl: Duration::from_secs(3600), enable_incremental: true,
}
}
}
pub struct DiffCache {
cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
config: DiffCacheConfig,
stats: RwLock<CacheStats>,
}
#[derive(Debug, Clone, Default)]
pub struct CacheStats {
pub lookups: u64,
pub hits: u64,
pub misses: u64,
pub incremental_hits: u64,
pub evictions: u64,
pub time_saved_ms: u64,
}
impl CacheStats {
#[must_use]
pub fn hit_rate(&self) -> f64 {
if self.lookups == 0 {
0.0
} else {
(self.hits + self.incremental_hits) as f64 / self.lookups as f64
}
}
}
impl DiffCache {
#[must_use]
pub fn new() -> Self {
Self::with_config(DiffCacheConfig::default())
}
#[must_use]
pub fn with_config(config: DiffCacheConfig) -> Self {
Self {
cache: RwLock::new(HashMap::new()),
config,
stats: RwLock::new(CacheStats::default()),
}
}
pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
let mut stats = self.stats.write().expect("stats lock poisoned");
stats.lookups += 1;
let result = {
let cache = self.cache.read().expect("cache lock poisoned");
cache.get(key).and_then(|entry| {
entry
.is_valid(self.config.ttl)
.then(|| Arc::clone(&entry.result))
})
};
if let Some(ref result) = result {
stats.hits += 1;
stats.time_saved_ms += Self::estimate_computation_time(result);
} else {
stats.misses += 1;
}
result
}
pub fn put(
&self,
key: DiffCacheKey,
result: DiffResult,
old_hashes: SectionHashes,
new_hashes: SectionHashes,
) {
let mut cache = self.cache.write().expect("cache lock poisoned");
while cache.len() >= self.config.max_entries {
if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
cache.remove(&oldest_key);
let mut stats = self.stats.write().expect("stats lock poisoned");
stats.evictions += 1;
} else {
break;
}
}
cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
}
fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
cache
.iter()
.max_by_key(|(_, entry)| entry.age())
.map(|(key, _)| key.clone())
}
fn estimate_computation_time(result: &DiffResult) -> u64 {
let component_count = result.components.added.len()
+ result.components.removed.len()
+ result.components.modified.len();
(component_count / 10).max(1) as u64
}
pub fn stats(&self) -> CacheStats {
self.stats.read().expect("stats lock poisoned").clone()
}
pub fn clear(&self) {
let mut cache = self.cache.write().expect("cache lock poisoned");
cache.clear();
}
pub fn len(&self) -> usize {
self.cache.read().expect("cache lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.cache.read().expect("cache lock poisoned").is_empty()
}
}
impl Default for DiffCache {
fn default() -> Self {
Self::new()
}
}
pub struct IncrementalDiffEngine {
engine: DiffEngine,
cache: DiffCache,
last_old_hashes: RwLock<Option<SectionHashes>>,
last_new_hashes: RwLock<Option<SectionHashes>>,
}
impl IncrementalDiffEngine {
#[must_use]
pub fn new(engine: DiffEngine) -> Self {
Self {
engine,
cache: DiffCache::new(),
last_old_hashes: RwLock::new(None),
last_new_hashes: RwLock::new(None),
}
}
#[must_use]
pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
Self {
engine,
cache: DiffCache::with_config(config),
last_old_hashes: RwLock::new(None),
last_new_hashes: RwLock::new(None),
}
}
pub fn diff(&self, old: &NormalizedSbom, new: &NormalizedSbom) -> IncrementalDiffResult {
let start = Instant::now();
let cache_key = DiffCacheKey::from_sboms(old, new);
if let Some(cached) = self.cache.get(&cache_key) {
return IncrementalDiffResult {
result: (*cached).clone(),
cache_hit: CacheHitType::Full,
sections_recomputed: ChangedSections::default(),
computation_time: start.elapsed(),
};
}
let old_hashes = SectionHashes::from_sbom(old);
let new_hashes = SectionHashes::from_sbom(new);
let changed = {
let last_old = self
.last_old_hashes
.read()
.expect("last_old_hashes lock poisoned");
let last_new = self
.last_new_hashes
.read()
.expect("last_new_hashes lock poisoned");
if let (Some(prev_old), Some(prev_new)) = (&*last_old, &*last_new) {
let old_changed = old_hashes != *prev_old;
let new_changed = new_hashes != *prev_new;
if !old_changed && !new_changed {
None
} else {
Some(
prev_old
.changed_sections(&old_hashes)
.or(&prev_new.changed_sections(&new_hashes)),
)
}
} else {
None
}
};
let (result, cache_hit, sections_recomputed) = if let Some(ref changed) = changed
&& !changed.all()
&& changed.any()
{
if let Some(prev_result) = self.find_previous_result() {
match self.engine.diff_sections(old, new, changed, &prev_result) {
Ok(result) => (result, CacheHitType::Partial, changed.clone()),
Err(_) => {
let result = self.engine.diff(old, new).unwrap_or_default();
(result, CacheHitType::Miss, ChangedSections::all_changed())
}
}
} else {
let result = self.engine.diff(old, new).unwrap_or_default();
(result, CacheHitType::Miss, ChangedSections::all_changed())
}
} else {
let result = self.engine.diff(old, new).unwrap_or_default();
let sections = changed.unwrap_or_else(ChangedSections::all_changed);
(result, CacheHitType::Miss, sections)
};
if cache_hit == CacheHitType::Partial
&& let Ok(mut stats) = self.cache.stats.write()
{
stats.incremental_hits += 1;
}
self.cache.put(
cache_key,
result.clone(),
old_hashes.clone(),
new_hashes.clone(),
);
*self
.last_old_hashes
.write()
.expect("last_old_hashes lock poisoned") = Some(old_hashes);
*self
.last_new_hashes
.write()
.expect("last_new_hashes lock poisoned") = Some(new_hashes);
IncrementalDiffResult {
result,
cache_hit,
sections_recomputed,
computation_time: start.elapsed(),
}
}
fn find_previous_result(&self) -> Option<Arc<DiffResult>> {
let cache = self.cache.cache.read().ok()?;
cache
.values()
.filter(|e| e.is_valid(Duration::from_secs(3600)))
.max_by_key(|e| e.hit_count)
.map(|e| Arc::clone(&e.result))
}
pub const fn engine(&self) -> &DiffEngine {
&self.engine
}
pub fn cache_stats(&self) -> CacheStats {
self.cache.stats()
}
pub fn clear_cache(&self) {
self.cache.clear();
}
}
impl ChangedSections {
const fn or(&self, other: &Self) -> Self {
Self {
components: self.components || other.components,
dependencies: self.dependencies || other.dependencies,
licenses: self.licenses || other.licenses,
vulnerabilities: self.vulnerabilities || other.vulnerabilities,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheHitType {
Full,
Partial,
Miss,
}
#[derive(Debug)]
pub struct IncrementalDiffResult {
pub result: DiffResult,
pub cache_hit: CacheHitType,
pub sections_recomputed: ChangedSections,
pub computation_time: Duration,
}
impl IncrementalDiffResult {
pub fn into_result(self) -> DiffResult {
self.result
}
#[must_use]
pub fn was_cached(&self) -> bool {
self.cache_hit == CacheHitType::Full
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::DocumentMetadata;
fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
for comp_name in components {
let comp = crate::model::Component::new(
comp_name.to_string(),
format!("{}-{}", name, comp_name),
);
sbom.add_component(comp);
}
sbom.content_hash = {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
name.hash(&mut hasher);
for c in components {
c.hash(&mut hasher);
}
hasher.finish()
};
sbom
}
#[test]
fn test_section_hashes() {
let sbom1 = make_sbom("test1", &["a", "b", "c"]);
let sbom2 = make_sbom("test2", &["a", "b", "c"]);
let sbom3 = make_sbom("test3", &["a", "b", "d"]);
let hash1 = SectionHashes::from_sbom(&sbom1);
let hash2 = SectionHashes::from_sbom(&sbom2);
let hash3 = SectionHashes::from_sbom(&sbom3);
assert_ne!(hash1.components, hash2.components);
assert_ne!(hash1.components, hash3.components);
}
#[test]
fn test_changed_sections() {
let hash1 = SectionHashes {
components: 100,
dependencies: 200,
licenses: 300,
vulnerabilities: 400,
};
let hash2 = SectionHashes {
components: 100,
dependencies: 200,
licenses: 999, vulnerabilities: 400,
};
let changed = hash1.changed_sections(&hash2);
assert!(!changed.components);
assert!(!changed.dependencies);
assert!(changed.licenses);
assert!(!changed.vulnerabilities);
assert_eq!(changed.count(), 1);
}
#[test]
fn test_diff_cache_basic() {
let cache = DiffCache::new();
let key = DiffCacheKey {
old_hash: 123,
new_hash: 456,
};
assert!(cache.get(&key).is_none());
assert!(cache.is_empty());
let result = DiffResult::new();
let hashes = SectionHashes {
components: 0,
dependencies: 0,
licenses: 0,
vulnerabilities: 0,
};
cache.put(key.clone(), result, hashes.clone(), hashes.clone());
assert!(cache.get(&key).is_some());
assert_eq!(cache.len(), 1);
let stats = cache.stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
}
#[test]
fn test_diff_cache_eviction() {
let config = DiffCacheConfig {
max_entries: 3,
ttl: Duration::from_secs(3600),
enable_incremental: true,
};
let cache = DiffCache::with_config(config);
let hashes = SectionHashes {
components: 0,
dependencies: 0,
licenses: 0,
vulnerabilities: 0,
};
for i in 0..5 {
let key = DiffCacheKey {
old_hash: i,
new_hash: i + 100,
};
cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
}
assert_eq!(cache.len(), 3);
}
#[test]
fn test_cache_hit_type() {
assert_eq!(CacheHitType::Full, CacheHitType::Full);
assert_ne!(CacheHitType::Full, CacheHitType::Miss);
}
#[test]
fn test_incremental_diff_engine() {
let engine = DiffEngine::new();
let incremental = IncrementalDiffEngine::new(engine);
let old = make_sbom("old", &["a", "b", "c"]);
let new = make_sbom("new", &["a", "b", "d"]);
let result1 = incremental.diff(&old, &new);
assert_eq!(result1.cache_hit, CacheHitType::Miss);
let result2 = incremental.diff(&old, &new);
assert_eq!(result2.cache_hit, CacheHitType::Full);
let stats = incremental.cache_stats();
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
}
#[test]
fn test_changed_sections_all_changed() {
let all = ChangedSections::all_changed();
assert!(all.components);
assert!(all.dependencies);
assert!(all.licenses);
assert!(all.vulnerabilities);
assert!(all.all());
assert!(all.any());
assert_eq!(all.count(), 4);
}
#[test]
fn test_changed_sections_or_combine() {
let a = ChangedSections {
components: true,
dependencies: false,
licenses: false,
vulnerabilities: false,
};
let b = ChangedSections {
components: false,
dependencies: false,
licenses: true,
vulnerabilities: false,
};
let combined = a.or(&b);
assert!(combined.components);
assert!(!combined.dependencies);
assert!(combined.licenses);
assert!(!combined.vulnerabilities);
assert_eq!(combined.count(), 2);
}
#[test]
fn test_diff_sections_selective_recomputation() {
let engine = DiffEngine::new();
let old = make_sbom("old", &["a", "b", "c"]);
let new = make_sbom("new", &["a", "b", "d"]);
let full_result = engine.diff(&old, &new).expect("diff should succeed");
let sections = ChangedSections {
components: true,
dependencies: false,
licenses: false,
vulnerabilities: false,
};
let selective_result = engine
.diff_sections(&old, &new, §ions, &full_result)
.expect("diff_sections should succeed");
assert_eq!(
selective_result.components.added.len(),
full_result.components.added.len()
);
assert_eq!(
selective_result.components.removed.len(),
full_result.components.removed.len()
);
assert_eq!(
selective_result.components.modified.len(),
full_result.components.modified.len()
);
assert_eq!(
selective_result.dependencies.added.len(),
full_result.dependencies.added.len()
);
assert_eq!(
selective_result.dependencies.removed.len(),
full_result.dependencies.removed.len()
);
}
#[test]
fn test_diff_sections_all_changed_matches_full_diff() {
let engine = DiffEngine::new();
let old = make_sbom("old", &["a", "b", "c"]);
let new = make_sbom("new", &["a", "b", "d"]);
let full_result = engine.diff(&old, &new).expect("diff should succeed");
let sections = ChangedSections::all_changed();
let selective_result = engine
.diff_sections(&old, &new, §ions, &DiffResult::new())
.expect("diff_sections should succeed");
assert_eq!(
selective_result.components.added.len(),
full_result.components.added.len()
);
assert_eq!(
selective_result.components.removed.len(),
full_result.components.removed.len()
);
assert_eq!(
selective_result.vulnerabilities.introduced.len(),
full_result.vulnerabilities.introduced.len()
);
}
#[test]
fn test_incremental_partial_change_detection() {
let engine = DiffEngine::new();
let incremental = IncrementalDiffEngine::new(engine);
let old = make_sbom("old", &["a", "b", "c"]);
let new1 = make_sbom("new1", &["a", "b", "d"]);
let result1 = incremental.diff(&old, &new1);
assert_eq!(result1.cache_hit, CacheHitType::Miss);
let new2 = make_sbom("new2", &["a", "b", "e"]);
let result2 = incremental.diff(&old, &new2);
assert!(
result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
);
assert!(result2.sections_recomputed.any());
}
#[test]
fn test_find_previous_result_empty_cache() {
let engine = DiffEngine::new();
let incremental = IncrementalDiffEngine::new(engine);
assert!(incremental.find_previous_result().is_none());
}
#[test]
fn test_find_previous_result_after_diff() {
let engine = DiffEngine::new();
let incremental = IncrementalDiffEngine::new(engine);
let old = make_sbom("old", &["a", "b"]);
let new = make_sbom("new", &["a", "c"]);
let _ = incremental.diff(&old, &new);
assert!(incremental.find_previous_result().is_some());
}
}