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