Skip to main content

sbom_tools/diff/
incremental.rs

1//! Incremental diffing with result caching.
2//!
3//! This module provides caching and incremental computation for SBOM diffs,
4//! dramatically improving performance when comparing related SBOMs (e.g.,
5//! successive builds where only a few components change).
6//!
7//! # How It Works
8//!
9//! 1. **Content Hashing**: Each SBOM section (components, dependencies, licenses,
10//!    vulnerabilities) has a separate content hash.
11//! 2. **Change Detection**: Before recomputing, we check if each section changed.
12//! 3. **Partial Recomputation**: Only sections that changed are recomputed.
13//! 4. **Result Caching**: Full results are cached for exact SBOM pair matches.
14//!
15//! # Performance Gains
16//!
17//! - Exact cache hit: O(1) lookup
18//! - Partial change: Only recompute changed sections (typically 10-50% of work)
19//! - Cold start: Same as regular diff
20
21use crate::diff::{DiffEngine, DiffResult};
22use crate::error::SbomDiffError;
23use crate::model::NormalizedSbom;
24use std::collections::HashMap;
25use std::hash::{Hash, Hasher};
26use std::sync::{Arc, RwLock};
27use std::time::{Duration, Instant};
28
29// ============================================================================
30// Cache Key Types
31// ============================================================================
32
33/// Key for full diff cache lookup.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct DiffCacheKey {
36    /// Hash of the old SBOM
37    pub old_hash: u64,
38    /// Hash of the new SBOM
39    pub new_hash: u64,
40}
41
42impl DiffCacheKey {
43    /// Create a cache key from two SBOMs.
44    #[must_use]
45    pub const fn from_sboms(old: &NormalizedSbom, new: &NormalizedSbom) -> Self {
46        Self {
47            old_hash: old.content_hash,
48            new_hash: new.content_hash,
49        }
50    }
51}
52
53/// Section-level hashes for incremental change detection.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SectionHashes {
56    /// Hash of all components
57    pub components: u64,
58    /// Hash of all dependency edges
59    pub dependencies: u64,
60    /// Hash of all licenses
61    pub licenses: u64,
62    /// Hash of all vulnerabilities
63    pub vulnerabilities: u64,
64}
65
66impl SectionHashes {
67    /// Compute section hashes for an SBOM.
68    #[must_use]
69    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
70        use std::collections::hash_map::DefaultHasher;
71
72        // Component hash
73        let mut hasher = DefaultHasher::new();
74        for (id, comp) in &sbom.components {
75            id.hash(&mut hasher);
76            comp.name.hash(&mut hasher);
77            comp.version.hash(&mut hasher);
78            comp.content_hash.hash(&mut hasher);
79        }
80        let components = hasher.finish();
81
82        // Dependencies hash
83        let mut hasher = DefaultHasher::new();
84        for edge in &sbom.edges {
85            edge.from.hash(&mut hasher);
86            edge.to.hash(&mut hasher);
87            edge.relationship.to_string().hash(&mut hasher);
88        }
89        let dependencies = hasher.finish();
90
91        // Licenses hash
92        let mut hasher = DefaultHasher::new();
93        for (_, comp) in &sbom.components {
94            for lic in &comp.licenses.declared {
95                lic.expression.hash(&mut hasher);
96            }
97        }
98        let licenses = hasher.finish();
99
100        // Vulnerabilities hash
101        let mut hasher = DefaultHasher::new();
102        for (_, comp) in &sbom.components {
103            for vuln in &comp.vulnerabilities {
104                vuln.id.hash(&mut hasher);
105            }
106        }
107        let vulnerabilities = hasher.finish();
108
109        Self {
110            components,
111            dependencies,
112            licenses,
113            vulnerabilities,
114        }
115    }
116
117    /// Check which sections differ between two hash sets.
118    #[must_use]
119    pub const fn changed_sections(&self, other: &Self) -> ChangedSections {
120        ChangedSections {
121            components: self.components != other.components,
122            dependencies: self.dependencies != other.dependencies,
123            licenses: self.licenses != other.licenses,
124            vulnerabilities: self.vulnerabilities != other.vulnerabilities,
125        }
126    }
127}
128
129/// Indicates which sections changed between two SBOMs.
130#[derive(Debug, Clone, Default)]
131pub struct ChangedSections {
132    pub components: bool,
133    pub dependencies: bool,
134    pub licenses: bool,
135    pub vulnerabilities: bool,
136}
137
138impl ChangedSections {
139    /// Create a `ChangedSections` with all sections marked as changed.
140    #[must_use]
141    pub const fn all_changed() -> Self {
142        Self {
143            components: true,
144            dependencies: true,
145            licenses: true,
146            vulnerabilities: true,
147        }
148    }
149
150    /// Check if any section changed.
151    #[must_use]
152    pub const fn any(&self) -> bool {
153        self.components || self.dependencies || self.licenses || self.vulnerabilities
154    }
155
156    /// Check if all sections changed.
157    #[must_use]
158    pub const fn all(&self) -> bool {
159        self.components && self.dependencies && self.licenses && self.vulnerabilities
160    }
161
162    /// Count how many sections changed.
163    #[must_use]
164    pub fn count(&self) -> usize {
165        [
166            self.components,
167            self.dependencies,
168            self.licenses,
169            self.vulnerabilities,
170        ]
171        .iter()
172        .filter(|&&b| b)
173        .count()
174    }
175}
176
177// ============================================================================
178// Cached Entry
179// ============================================================================
180
181/// A cached diff result with metadata.
182#[derive(Debug, Clone)]
183pub struct CachedDiffResult {
184    /// The diff result
185    pub result: Arc<DiffResult>,
186    /// When this was computed
187    pub computed_at: Instant,
188    /// Section hashes from old SBOM
189    pub old_hashes: SectionHashes,
190    /// Section hashes from new SBOM
191    pub new_hashes: SectionHashes,
192    /// Number of times this cache entry was hit
193    pub hit_count: u64,
194}
195
196impl CachedDiffResult {
197    /// Create a new cached result.
198    #[must_use]
199    pub fn new(result: DiffResult, old_hashes: SectionHashes, new_hashes: SectionHashes) -> Self {
200        Self {
201            result: Arc::new(result),
202            computed_at: Instant::now(),
203            old_hashes,
204            new_hashes,
205            hit_count: 0,
206        }
207    }
208
209    /// Check if this entry is still valid (not expired).
210    #[must_use]
211    pub fn is_valid(&self, ttl: Duration) -> bool {
212        self.computed_at.elapsed() < ttl
213    }
214
215    /// Get age of this cache entry.
216    #[must_use]
217    pub fn age(&self) -> Duration {
218        self.computed_at.elapsed()
219    }
220}
221
222// ============================================================================
223// Diff Cache
224// ============================================================================
225
226/// Configuration for the diff cache.
227#[derive(Debug, Clone)]
228pub struct DiffCacheConfig {
229    /// Maximum number of entries to cache
230    pub max_entries: usize,
231    /// Time-to-live for cache entries
232    pub ttl: Duration,
233    /// Enable incremental computation for partial changes
234    pub enable_incremental: bool,
235}
236
237impl Default for DiffCacheConfig {
238    fn default() -> Self {
239        Self {
240            max_entries: 100,
241            ttl: Duration::from_secs(3600), // 1 hour
242            enable_incremental: true,
243        }
244    }
245}
246
247/// Thread-safe cache for diff results.
248///
249/// Supports both full result caching and incremental computation
250/// when only some sections change.
251pub struct DiffCache {
252    /// Full result cache (keyed by SBOM pair hashes)
253    cache: RwLock<HashMap<DiffCacheKey, CachedDiffResult>>,
254    /// Configuration
255    config: DiffCacheConfig,
256    /// Statistics
257    stats: RwLock<CacheStats>,
258}
259
260/// Statistics for cache performance.
261#[derive(Debug, Clone, Default)]
262pub struct CacheStats {
263    /// Total cache lookups
264    pub lookups: u64,
265    /// Exact cache hits
266    pub hits: u64,
267    /// Cache misses
268    pub misses: u64,
269    /// Incremental computations (partial cache hit)
270    pub incremental_hits: u64,
271    /// Entries evicted
272    pub evictions: u64,
273    /// Total computation time saved (estimated)
274    pub time_saved_ms: u64,
275}
276
277impl CacheStats {
278    /// Get the cache hit rate.
279    #[must_use]
280    pub fn hit_rate(&self) -> f64 {
281        if self.lookups == 0 {
282            0.0
283        } else {
284            (self.hits + self.incremental_hits) as f64 / self.lookups as f64
285        }
286    }
287}
288
289impl DiffCache {
290    /// Create a new diff cache with default configuration.
291    #[must_use]
292    pub fn new() -> Self {
293        Self::with_config(DiffCacheConfig::default())
294    }
295
296    /// Create a new diff cache with custom configuration.
297    #[must_use]
298    pub fn with_config(config: DiffCacheConfig) -> Self {
299        Self {
300            cache: RwLock::new(HashMap::new()),
301            config,
302            stats: RwLock::new(CacheStats::default()),
303        }
304    }
305
306    /// Look up a cached result.
307    ///
308    /// Returns `Some` if an exact match is found and still valid.
309    pub fn get(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
310        let result = {
311            let mut cache = self.cache.write().expect("cache lock poisoned");
312            cache.get_mut(key).and_then(|entry| {
313                entry.is_valid(self.config.ttl).then(|| {
314                    entry.hit_count += 1;
315                    Arc::clone(&entry.result)
316                })
317            })
318        };
319
320        let mut stats = self.stats.write().expect("stats lock poisoned");
321        stats.lookups += 1;
322        if let Some(ref result) = result {
323            stats.hits += 1;
324            stats.time_saved_ms += Self::estimate_computation_time(result);
325        } else {
326            stats.misses += 1;
327        }
328        result
329    }
330
331    /// Store a result in the cache.
332    pub fn put(
333        &self,
334        key: DiffCacheKey,
335        result: DiffResult,
336        old_hashes: SectionHashes,
337        new_hashes: SectionHashes,
338    ) {
339        let mut cache = self.cache.write().expect("cache lock poisoned");
340
341        // Evict oldest entries if at capacity
342        while cache.len() >= self.config.max_entries {
343            if let Some(oldest_key) = Self::find_oldest_entry(&cache) {
344                cache.remove(&oldest_key);
345                let mut stats = self.stats.write().expect("stats lock poisoned");
346                stats.evictions += 1;
347            } else {
348                break;
349            }
350        }
351
352        cache.insert(key, CachedDiffResult::new(result, old_hashes, new_hashes));
353    }
354
355    /// Find the oldest cache entry.
356    fn find_oldest_entry(cache: &HashMap<DiffCacheKey, CachedDiffResult>) -> Option<DiffCacheKey> {
357        cache
358            .iter()
359            .max_by_key(|(_, entry)| entry.age())
360            .map(|(key, _)| key.clone())
361    }
362
363    /// Estimate computation time based on result size.
364    fn estimate_computation_time(result: &DiffResult) -> u64 {
365        // Rough estimate: 1ms per 10 components
366        let component_count = result.components.added.len()
367            + result.components.removed.len()
368            + result.components.modified.len();
369        (component_count / 10).max(1) as u64
370    }
371
372    /// Get cache statistics.
373    pub fn stats(&self) -> CacheStats {
374        self.stats.read().expect("stats lock poisoned").clone()
375    }
376
377    /// Clear all cached entries.
378    pub fn clear(&self) {
379        let mut cache = self.cache.write().expect("cache lock poisoned");
380        cache.clear();
381    }
382
383    /// Get the number of cached entries.
384    pub fn len(&self) -> usize {
385        self.cache.read().expect("cache lock poisoned").len()
386    }
387
388    /// Check if the cache is empty.
389    pub fn is_empty(&self) -> bool {
390        self.cache.read().expect("cache lock poisoned").is_empty()
391    }
392}
393
394impl Default for DiffCache {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400// ============================================================================
401// Incremental Diff Engine
402// ============================================================================
403
404/// Metadata about the last diffed pair, used for incremental updates.
405struct LastDiffMeta {
406    /// Cache key of the last diffed pair
407    key: DiffCacheKey,
408    /// Section hashes from the last pair's old SBOM
409    old_hashes: SectionHashes,
410    /// Section hashes from the last pair's new SBOM
411    new_hashes: SectionHashes,
412}
413
414/// A diff engine wrapper that supports incremental computation and caching.
415///
416/// Wraps the standard `DiffEngine` and adds:
417/// - Result caching for repeated comparisons
418/// - Section-level change detection
419/// - Incremental recomputation for partial changes
420pub struct IncrementalDiffEngine {
421    /// The underlying diff engine
422    engine: DiffEngine,
423    /// Result cache
424    cache: DiffCache,
425    /// Track previous computation for incremental updates
426    last_diff: RwLock<Option<LastDiffMeta>>,
427}
428
429impl IncrementalDiffEngine {
430    /// Create a new incremental diff engine.
431    #[must_use]
432    pub fn new(engine: DiffEngine) -> Self {
433        Self {
434            engine,
435            cache: DiffCache::new(),
436            last_diff: RwLock::new(None),
437        }
438    }
439
440    /// Create with custom cache configuration.
441    #[must_use]
442    pub fn with_cache_config(engine: DiffEngine, config: DiffCacheConfig) -> Self {
443        Self {
444            engine,
445            cache: DiffCache::with_config(config),
446            last_diff: RwLock::new(None),
447        }
448    }
449
450    /// Perform a diff, using cache when possible.
451    ///
452    /// Returns the diff result and metadata about cache usage.
453    ///
454    /// # Errors
455    ///
456    /// Returns an error if the underlying diff computation fails.
457    pub fn diff(
458        &self,
459        old: &NormalizedSbom,
460        new: &NormalizedSbom,
461    ) -> Result<IncrementalDiffResult, SbomDiffError> {
462        let start = Instant::now();
463        let cache_key = DiffCacheKey::from_sboms(old, new);
464
465        // Check for exact cache hit
466        if let Some(cached) = self.cache.get(&cache_key) {
467            return Ok(IncrementalDiffResult {
468                result: (*cached).clone(),
469                cache_hit: CacheHitType::Full,
470                sections_recomputed: ChangedSections::default(),
471                computation_time: start.elapsed(),
472            });
473        }
474
475        // Compute section hashes
476        let old_hashes = SectionHashes::from_sbom(old);
477        let new_hashes = SectionHashes::from_sbom(new);
478
479        // Check for incremental opportunity against the last diffed pair
480        let (changed, prev_key) = {
481            let last = self.last_diff.read().expect("last_diff lock poisoned");
482            match &*last {
483                Some(meta) => {
484                    let old_changed = old_hashes != meta.old_hashes;
485                    let new_changed = new_hashes != meta.new_hashes;
486
487                    if !old_changed && !new_changed {
488                        // Nothing changed, but we don't have the result cached
489                        // This shouldn't normally happen, but fall through to full compute
490                        (None, None)
491                    } else {
492                        (
493                            Some(
494                                meta.old_hashes
495                                    .changed_sections(&old_hashes)
496                                    .or(&meta.new_hashes.changed_sections(&new_hashes)),
497                            ),
498                            Some(meta.key.clone()),
499                        )
500                    }
501                }
502                None => (None, None),
503            }
504        };
505
506        // Section-selective or full computation
507        let (result, cache_hit, sections_recomputed) = if let Some(ref changed) = changed
508            && let Some(ref prev_key) = prev_key
509            && !changed.all()
510            && changed.any()
511        {
512            // Change detection is relative to the last diffed pair, so only
513            // that exact pair's cached result is a valid splice base
514            if let Some(prev_result) = self.find_previous_result(prev_key) {
515                match self.engine.diff_sections(old, new, changed, &prev_result) {
516                    Ok(result) => (result, CacheHitType::Partial, changed.clone()),
517                    Err(_) => {
518                        // Fall back to full computation on diff_sections errors
519                        let result = self.engine.diff(old, new)?;
520                        (result, CacheHitType::Miss, ChangedSections::all_changed())
521                    }
522                }
523            } else {
524                // Last pair's entry was evicted or expired — full computation
525                let result = self.engine.diff(old, new)?;
526                (result, CacheHitType::Miss, ChangedSections::all_changed())
527            }
528        } else {
529            // Either no change detection possible, or all sections changed — full computation
530            let result = self.engine.diff(old, new)?;
531            let sections = changed.unwrap_or_else(ChangedSections::all_changed);
532            (result, CacheHitType::Miss, sections)
533        };
534
535        // Track incremental hits in cache stats
536        if cache_hit == CacheHitType::Partial
537            && let Ok(mut stats) = self.cache.stats.write()
538        {
539            stats.incremental_hits += 1;
540        }
541
542        // Cache the result
543        self.cache.put(
544            cache_key.clone(),
545            result.clone(),
546            old_hashes.clone(),
547            new_hashes.clone(),
548        );
549
550        // Update last diff metadata
551        *self.last_diff.write().expect("last_diff lock poisoned") = Some(LastDiffMeta {
552            key: cache_key,
553            old_hashes,
554            new_hashes,
555        });
556
557        Ok(IncrementalDiffResult {
558            result,
559            cache_hit,
560            sections_recomputed,
561            computation_time: start.elapsed(),
562        })
563    }
564
565    /// Find the last diffed pair's cached result to use as a base for
566    /// incremental recomputation.
567    ///
568    /// Section change detection is computed against the last diffed pair, so
569    /// only that exact pair's cached entry is a valid splice base.
570    fn find_previous_result(&self, key: &DiffCacheKey) -> Option<Arc<DiffResult>> {
571        let cache = self.cache.cache.read().ok()?;
572        cache
573            .get(key)
574            .filter(|e| e.is_valid(self.cache.config.ttl))
575            .map(|e| Arc::clone(&e.result))
576    }
577
578    /// Get the underlying engine.
579    pub const fn engine(&self) -> &DiffEngine {
580        &self.engine
581    }
582
583    /// Get cache statistics.
584    pub fn cache_stats(&self) -> CacheStats {
585        self.cache.stats()
586    }
587
588    /// Clear the cache.
589    pub fn clear_cache(&self) {
590        self.cache.clear();
591    }
592}
593
594impl ChangedSections {
595    /// Combine two `ChangedSections` with OR logic.
596    const fn or(&self, other: &Self) -> Self {
597        Self {
598            components: self.components || other.components,
599            dependencies: self.dependencies || other.dependencies,
600            licenses: self.licenses || other.licenses,
601            vulnerabilities: self.vulnerabilities || other.vulnerabilities,
602        }
603    }
604}
605
606/// Type of cache hit achieved.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum CacheHitType {
609    /// Full result was in cache
610    Full,
611    /// Partial cache hit, some sections reused
612    Partial,
613    /// No cache hit, full computation required
614    Miss,
615}
616
617/// Result of an incremental diff operation.
618#[derive(Debug)]
619pub struct IncrementalDiffResult {
620    /// The diff result
621    pub result: DiffResult,
622    /// Type of cache hit
623    pub cache_hit: CacheHitType,
624    /// Which sections were recomputed (false = reused from cache)
625    pub sections_recomputed: ChangedSections,
626    /// Time taken for this operation
627    pub computation_time: Duration,
628}
629
630impl IncrementalDiffResult {
631    /// Get the diff result.
632    pub fn into_result(self) -> DiffResult {
633        self.result
634    }
635
636    /// Check if this was a cache hit.
637    #[must_use]
638    pub fn was_cached(&self) -> bool {
639        self.cache_hit == CacheHitType::Full
640    }
641}
642
643// ============================================================================
644// Tests
645// ============================================================================
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::model::DocumentMetadata;
651
652    fn make_sbom(name: &str, components: &[&str]) -> NormalizedSbom {
653        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
654        for comp_name in components {
655            let comp = crate::model::Component::new(
656                comp_name.to_string(),
657                format!("{}-{}", name, comp_name),
658            );
659            sbom.add_component(comp);
660        }
661        // Ensure unique content hash
662        sbom.content_hash = {
663            use std::collections::hash_map::DefaultHasher;
664            let mut hasher = DefaultHasher::new();
665            name.hash(&mut hasher);
666            for c in components {
667                c.hash(&mut hasher);
668            }
669            hasher.finish()
670        };
671        sbom
672    }
673
674    #[test]
675    fn test_section_hashes() {
676        let sbom1 = make_sbom("test1", &["a", "b", "c"]);
677        let sbom2 = make_sbom("test2", &["a", "b", "c"]);
678        let sbom3 = make_sbom("test3", &["a", "b", "d"]);
679
680        let hash1 = SectionHashes::from_sbom(&sbom1);
681        let hash2 = SectionHashes::from_sbom(&sbom2);
682        let hash3 = SectionHashes::from_sbom(&sbom3);
683
684        // Different SBOMs with same components should have different component hashes
685        // (because canonical IDs differ)
686        assert_ne!(hash1.components, hash2.components);
687
688        // Different components should definitely differ
689        assert_ne!(hash1.components, hash3.components);
690    }
691
692    #[test]
693    fn test_changed_sections() {
694        let hash1 = SectionHashes {
695            components: 100,
696            dependencies: 200,
697            licenses: 300,
698            vulnerabilities: 400,
699        };
700
701        let hash2 = SectionHashes {
702            components: 100,
703            dependencies: 200,
704            licenses: 999, // Changed
705            vulnerabilities: 400,
706        };
707
708        let changed = hash1.changed_sections(&hash2);
709        assert!(!changed.components);
710        assert!(!changed.dependencies);
711        assert!(changed.licenses);
712        assert!(!changed.vulnerabilities);
713        assert_eq!(changed.count(), 1);
714    }
715
716    #[test]
717    fn test_diff_cache_basic() {
718        let cache = DiffCache::new();
719        let key = DiffCacheKey {
720            old_hash: 123,
721            new_hash: 456,
722        };
723
724        // Initially empty
725        assert!(cache.get(&key).is_none());
726        assert!(cache.is_empty());
727
728        // Add a result
729        let result = DiffResult::new();
730        let hashes = SectionHashes {
731            components: 0,
732            dependencies: 0,
733            licenses: 0,
734            vulnerabilities: 0,
735        };
736        cache.put(key.clone(), result, hashes.clone(), hashes.clone());
737
738        // Should be retrievable
739        assert!(cache.get(&key).is_some());
740        assert_eq!(cache.len(), 1);
741
742        // Stats should show 1 hit, 1 miss
743        let stats = cache.stats();
744        assert_eq!(stats.hits, 1);
745        assert_eq!(stats.misses, 1);
746    }
747
748    #[test]
749    fn test_diff_cache_eviction() {
750        let config = DiffCacheConfig {
751            max_entries: 3,
752            ttl: Duration::from_secs(3600),
753            enable_incremental: true,
754        };
755        let cache = DiffCache::with_config(config);
756
757        let hashes = SectionHashes {
758            components: 0,
759            dependencies: 0,
760            licenses: 0,
761            vulnerabilities: 0,
762        };
763
764        // Add 5 entries, should only keep 3
765        for i in 0..5 {
766            let key = DiffCacheKey {
767                old_hash: i,
768                new_hash: i + 100,
769            };
770            cache.put(key, DiffResult::new(), hashes.clone(), hashes.clone());
771        }
772
773        assert_eq!(cache.len(), 3);
774    }
775
776    #[test]
777    fn test_cache_hit_type() {
778        assert_eq!(CacheHitType::Full, CacheHitType::Full);
779        assert_ne!(CacheHitType::Full, CacheHitType::Miss);
780    }
781
782    #[test]
783    fn test_incremental_diff_engine() {
784        let engine = DiffEngine::new();
785        let incremental = IncrementalDiffEngine::new(engine);
786
787        let old = make_sbom("old", &["a", "b", "c"]);
788        let new = make_sbom("new", &["a", "b", "d"]);
789
790        // First diff should be a miss
791        let result1 = incremental.diff(&old, &new).expect("diff should succeed");
792        assert_eq!(result1.cache_hit, CacheHitType::Miss);
793
794        // Same diff should be a hit
795        let result2 = incremental.diff(&old, &new).expect("diff should succeed");
796        assert_eq!(result2.cache_hit, CacheHitType::Full);
797
798        // Stats should reflect this
799        let stats = incremental.cache_stats();
800        assert_eq!(stats.hits, 1);
801        assert_eq!(stats.misses, 1);
802    }
803
804    #[test]
805    fn test_changed_sections_all_changed() {
806        let all = ChangedSections::all_changed();
807        assert!(all.components);
808        assert!(all.dependencies);
809        assert!(all.licenses);
810        assert!(all.vulnerabilities);
811        assert!(all.all());
812        assert!(all.any());
813        assert_eq!(all.count(), 4);
814    }
815
816    #[test]
817    fn test_changed_sections_or_combine() {
818        let a = ChangedSections {
819            components: true,
820            dependencies: false,
821            licenses: false,
822            vulnerabilities: false,
823        };
824        let b = ChangedSections {
825            components: false,
826            dependencies: false,
827            licenses: true,
828            vulnerabilities: false,
829        };
830        let combined = a.or(&b);
831        assert!(combined.components);
832        assert!(!combined.dependencies);
833        assert!(combined.licenses);
834        assert!(!combined.vulnerabilities);
835        assert_eq!(combined.count(), 2);
836    }
837
838    #[test]
839    fn test_diff_sections_selective_recomputation() {
840        // Test that diff_sections on DiffEngine produces a valid result
841        // when only a subset of sections are marked as changed
842        let engine = DiffEngine::new();
843        let old = make_sbom("old", &["a", "b", "c"]);
844        let new = make_sbom("new", &["a", "b", "d"]);
845
846        // Full diff first to get a baseline
847        let full_result = engine.diff(&old, &new).expect("diff should succeed");
848
849        // Now do a section-selective diff recomputing only components
850        let sections = ChangedSections {
851            components: true,
852            dependencies: false,
853            licenses: false,
854            vulnerabilities: false,
855        };
856        let selective_result = engine
857            .diff_sections(&old, &new, &sections, &full_result)
858            .expect("diff_sections should succeed");
859
860        // Components should be freshly computed — same as full diff
861        assert_eq!(
862            selective_result.components.added.len(),
863            full_result.components.added.len()
864        );
865        assert_eq!(
866            selective_result.components.removed.len(),
867            full_result.components.removed.len()
868        );
869        assert_eq!(
870            selective_result.components.modified.len(),
871            full_result.components.modified.len()
872        );
873
874        // Dependencies were not recomputed — should be preserved from cached
875        assert_eq!(
876            selective_result.dependencies.added.len(),
877            full_result.dependencies.added.len()
878        );
879        assert_eq!(
880            selective_result.dependencies.removed.len(),
881            full_result.dependencies.removed.len()
882        );
883    }
884
885    #[test]
886    fn test_diff_sections_all_changed_matches_full_diff() {
887        // When all sections are marked as changed, diff_sections should produce
888        // the same result as a full diff
889        let engine = DiffEngine::new();
890        let old = make_sbom("old", &["a", "b", "c"]);
891        let new = make_sbom("new", &["a", "b", "d"]);
892
893        let full_result = engine.diff(&old, &new).expect("diff should succeed");
894        let sections = ChangedSections::all_changed();
895        let selective_result = engine
896            .diff_sections(&old, &new, &sections, &DiffResult::new())
897            .expect("diff_sections should succeed");
898
899        assert_eq!(
900            selective_result.components.added.len(),
901            full_result.components.added.len()
902        );
903        assert_eq!(
904            selective_result.components.removed.len(),
905            full_result.components.removed.len()
906        );
907        assert_eq!(
908            selective_result.vulnerabilities.introduced.len(),
909            full_result.vulnerabilities.introduced.len()
910        );
911    }
912
913    #[test]
914    fn test_incremental_partial_change_detection() {
915        // Simulate the incremental path: diff two SBOMs, then diff again
916        // with a slightly different new SBOM that shares the same old SBOM.
917        // This tests that the engine detects partial changes and attempts
918        // section-selective diff.
919        let engine = DiffEngine::new();
920        let incremental = IncrementalDiffEngine::new(engine);
921
922        let old = make_sbom("old", &["a", "b", "c"]);
923        let new1 = make_sbom("new1", &["a", "b", "d"]);
924
925        // First diff populates the last-diff metadata
926        let result1 = incremental.diff(&old, &new1).expect("diff should succeed");
927        assert_eq!(result1.cache_hit, CacheHitType::Miss);
928
929        // Second diff with different SBOMs (different content hashes = no exact cache hit)
930        // but the last-diff metadata is now set, so change detection runs
931        let new2 = make_sbom("new2", &["a", "b", "e"]);
932        let result2 = incremental.diff(&old, &new2).expect("diff should succeed");
933
934        // This should either be a Partial hit (if section-selective kicked in)
935        // or a Miss (if all sections changed). Either way, it should produce a valid result.
936        assert!(
937            result2.cache_hit == CacheHitType::Partial || result2.cache_hit == CacheHitType::Miss
938        );
939        // The result should have been computed successfully regardless
940        assert!(result2.sections_recomputed.any());
941    }
942
943    #[test]
944    fn test_find_previous_result_empty_cache() {
945        let engine = DiffEngine::new();
946        let incremental = IncrementalDiffEngine::new(engine);
947        let key = DiffCacheKey {
948            old_hash: 1,
949            new_hash: 2,
950        };
951        // With an empty cache, find_previous_result should return None
952        assert!(incremental.find_previous_result(&key).is_none());
953    }
954
955    #[test]
956    fn test_find_previous_result_after_diff() {
957        let engine = DiffEngine::new();
958        let incremental = IncrementalDiffEngine::new(engine);
959
960        let old = make_sbom("old", &["a", "b"]);
961        let new = make_sbom("new", &["a", "c"]);
962
963        // Populate the cache
964        let _ = incremental.diff(&old, &new).expect("diff should succeed");
965
966        // The diffed pair's key should be retrievable; an unrelated key should not
967        let key = DiffCacheKey::from_sboms(&old, &new);
968        assert!(incremental.find_previous_result(&key).is_some());
969        let other_key = DiffCacheKey {
970            old_hash: key.old_hash.wrapping_add(1),
971            new_hash: key.new_hash,
972        };
973        assert!(incremental.find_previous_result(&other_key).is_none());
974    }
975
976    fn assert_sections_match(actual: &DiffResult, expected: &DiffResult) {
977        assert_eq!(
978            actual.components.added.len(),
979            expected.components.added.len()
980        );
981        assert_eq!(
982            actual.components.removed.len(),
983            expected.components.removed.len()
984        );
985        assert_eq!(
986            actual.components.modified.len(),
987            expected.components.modified.len()
988        );
989        assert_eq!(
990            actual.licenses.new_licenses.len(),
991            expected.licenses.new_licenses.len()
992        );
993        assert_eq!(
994            actual.licenses.removed_licenses.len(),
995            expected.licenses.removed_licenses.len()
996        );
997        assert_eq!(
998            actual.vulnerabilities.introduced.len(),
999            expected.vulnerabilities.introduced.len()
1000        );
1001        assert_eq!(
1002            actual.vulnerabilities.resolved.len(),
1003            expected.vulnerabilities.resolved.len()
1004        );
1005        assert_eq!(
1006            actual.dependencies.added.len(),
1007            expected.dependencies.added.len()
1008        );
1009        assert_eq!(
1010            actual.dependencies.removed.len(),
1011            expected.dependencies.removed.len()
1012        );
1013    }
1014
1015    #[test]
1016    fn test_no_cross_pair_section_splice() {
1017        // Note: DiffEngine::diff has no constructible Err path today, so the
1018        // Result-propagation change is covered at the type level only.
1019        let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1020
1021        let a_old = make_sbom("a-old", &["a", "b", "c"]);
1022        let a_new = make_sbom("a-new", &["a", "b", "d"]);
1023        let b_old = make_sbom("b-old", &["x", "y"]);
1024        let b_new = make_sbom("b-new", &["x", "z", "w"]);
1025
1026        // Pair A first, then pair B through the same engine
1027        let _ = incremental
1028            .diff(&a_old, &a_new)
1029            .expect("diff should succeed");
1030        let b_result = incremental
1031            .diff(&b_old, &b_new)
1032            .expect("diff should succeed");
1033
1034        // Every section of B's result must match a from-scratch full diff —
1035        // no sections spliced in from pair A's cached result
1036        let fresh = DiffEngine::new()
1037            .diff(&b_old, &b_new)
1038            .expect("diff should succeed");
1039        assert_sections_match(&b_result.result, &fresh);
1040    }
1041
1042    #[test]
1043    fn test_partial_splice_uses_last_pair_base() {
1044        let incremental = IncrementalDiffEngine::new(DiffEngine::new());
1045
1046        let s0 = make_sbom("s0", &["a", "b", "c"]);
1047        let s1 = make_sbom("s1", &["a", "b", "d"]);
1048        let s2 = make_sbom("s2", &["a", "b", "e"]);
1049
1050        // s0->s1 first so that s0->s2 only differs in the components section
1051        let _ = incremental.diff(&s0, &s1).expect("diff should succeed");
1052        let result = incremental.diff(&s0, &s2).expect("diff should succeed");
1053        assert_eq!(result.cache_hit, CacheHitType::Partial);
1054
1055        // The spliced result must match a from-scratch full diff of s0->s2
1056        let fresh = DiffEngine::new()
1057            .diff(&s0, &s2)
1058            .expect("diff should succeed");
1059        assert_sections_match(&result.result, &fresh);
1060    }
1061}