Skip to main content

ipfrs_storage/
content_addressable_cache.rs

1//! Content-Addressable Cache with LRU eviction and TTL support.
2//!
3//! Provides a high-performance, in-memory cache keyed by content-derived CIDs (FNV-1a).
4//! Supports multiple eviction policies, TTL expiration, tag-based grouping, and rich stats.
5
6use std::collections::HashMap;
7
8// ─── FNV-1a helpers ──────────────────────────────────────────────────────────
9
10/// Compute FNV-1a 64-bit hash over `data`.
11fn fnv1a_64(data: &[u8]) -> u64 {
12    let mut h: u64 = 14_695_981_039_346_656_037;
13    for &b in data {
14        h ^= b as u64;
15        h = h.wrapping_mul(1_099_511_628_211);
16    }
17    h
18}
19
20/// Derive a hex-encoded CID string from raw bytes.
21fn compute_cid(data: &[u8]) -> String {
22    format!("{:016x}", fnv1a_64(data))
23}
24
25// ─── Monotonic timestamp ─────────────────────────────────────────────────────
26
27/// Return microseconds since an arbitrary epoch (uses `std::time::UNIX_EPOCH`).
28fn now_us() -> u64 {
29    std::time::SystemTime::now()
30        .duration_since(std::time::UNIX_EPOCH)
31        .map(|d| d.as_micros() as u64)
32        .unwrap_or(0)
33}
34
35// ─── Public Types ─────────────────────────────────────────────────────────────
36
37/// A single entry stored in the cache.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CacheEntry {
40    /// Content-derived identifier.
41    pub cid: String,
42    /// Raw payload.
43    pub data: Vec<u8>,
44    /// Microsecond timestamp at insertion time.
45    pub inserted_at: u64,
46    /// Microsecond timestamp of most-recent access.
47    pub last_accessed: u64,
48    /// Total number of times this entry has been retrieved.
49    pub access_count: u64,
50    /// Optional TTL in microseconds (measured from `inserted_at`).
51    pub ttl_us: Option<u64>,
52    /// Payload size in bytes (mirrors `data.len()`).
53    pub size_bytes: usize,
54    /// Arbitrary string tags for group-based operations.
55    pub tags: Vec<String>,
56}
57
58impl CacheEntry {
59    /// Return `true` when the entry has passed its TTL deadline.
60    pub fn is_expired(&self, now: u64) -> bool {
61        match self.ttl_us {
62            Some(ttl) => now.saturating_sub(self.inserted_at) >= ttl,
63            None => false,
64        }
65    }
66
67    /// Remaining TTL in microseconds, or `u64::MAX` for entries without TTL.
68    pub fn remaining_ttl_us(&self, now: u64) -> u64 {
69        match self.ttl_us {
70            Some(ttl) => {
71                let elapsed = now.saturating_sub(self.inserted_at);
72                ttl.saturating_sub(elapsed)
73            }
74            None => u64::MAX,
75        }
76    }
77}
78
79/// Eviction policy controlling which entry is removed when the cache is full.
80#[derive(Debug, Clone)]
81pub enum EvictionPolicy {
82    /// Least-Recently-Used: evict the entry accessed furthest in the past.
83    Lru,
84    /// Least-Frequently-Used: evict the entry with the lowest `access_count`.
85    Lfu,
86    /// TTL-first: evict the entry whose TTL expires soonest.
87    TtlFirst,
88    /// Size-weighted: evict the largest entry.
89    SizeWeighted,
90    /// Tagged: evict entries bearing `tag` first, then fall back to LRU.
91    Tagged(String),
92}
93
94/// Configuration for [`ContentAddressableCache`].
95#[derive(Debug, Clone)]
96pub struct CacheConfig {
97    /// Maximum number of entries.
98    pub max_entries: usize,
99    /// Maximum total payload bytes.
100    pub max_bytes: usize,
101    /// Default TTL applied when an entry is inserted without an explicit TTL.
102    pub default_ttl_us: Option<u64>,
103    /// Eviction policy used when the cache is over capacity.
104    pub policy: EvictionPolicy,
105    /// When `true` the cache maintains hit/miss/eviction counters.
106    pub enable_stats: bool,
107}
108
109impl Default for CacheConfig {
110    fn default() -> Self {
111        Self {
112            max_entries: 1024,
113            max_bytes: 64 * 1024 * 1024, // 64 MiB
114            default_ttl_us: None,
115            policy: EvictionPolicy::Lru,
116            enable_stats: true,
117        }
118    }
119}
120
121/// Snapshot of cache performance counters.
122#[derive(Debug, Clone, Default)]
123pub struct CacheStats {
124    pub hits: u64,
125    pub misses: u64,
126    pub evictions: u64,
127    pub insertions: u64,
128    pub expirations: u64,
129    pub current_entries: usize,
130    pub current_bytes: usize,
131    /// Ratio of hits to total lookups; `0.0` when no lookups have occurred.
132    pub hit_rate: f64,
133}
134
135/// Errors that can arise during cache operations.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum CacheError {
138    /// The provided CID does not match the CID derived from the data.
139    CidMismatch { expected: String, got: String },
140    /// The entry's payload exceeds the configured `max_bytes` limit.
141    EntryTooLarge(usize),
142    /// The cache is at capacity and eviction could not free enough space.
143    CacheAtCapacity,
144    /// No entry exists for the requested CID.
145    EntryNotFound,
146    /// The entry exists but its TTL has expired (it has been removed).
147    TtlExpired,
148}
149
150impl std::fmt::Display for CacheError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            CacheError::CidMismatch { expected, got } => {
154                write!(f, "CID mismatch: expected {expected}, got {got}")
155            }
156            CacheError::EntryTooLarge(sz) => write!(f, "entry too large: {sz} bytes"),
157            CacheError::CacheAtCapacity => write!(f, "cache at capacity"),
158            CacheError::EntryNotFound => write!(f, "entry not found"),
159            CacheError::TtlExpired => write!(f, "entry TTL expired"),
160        }
161    }
162}
163
164impl std::error::Error for CacheError {}
165
166// ─── LRU linked-list node ──────────────────────────────────────────────────
167
168/// A node in the index-based doubly-linked LRU list.
169#[derive(Debug, Clone)]
170pub struct LruNode {
171    pub cid: String,
172    pub prev: Option<usize>,
173    pub next: Option<usize>,
174}
175
176// ─── Core cache struct ────────────────────────────────────────────────────────
177
178/// High-performance content-addressable cache.
179///
180/// Keyed by FNV-1a-derived CIDs; supports LRU, LFU, TTL-first, size-weighted,
181/// and tag-targeted eviction policies. Thread-safety must be provided by the caller.
182pub struct ContentAddressableCache {
183    config: CacheConfig,
184
185    /// The actual cached data, keyed by CID.
186    entries: HashMap<String, CacheEntry>,
187
188    /// Maps CID → slot index inside `lru_arena`.
189    lru_index: HashMap<String, usize>,
190
191    /// Arena of optional LRU nodes; freed slots are kept as `None` for reuse.
192    lru_arena: Vec<Option<LruNode>>,
193
194    /// Free-list of arena indices available for reuse.
195    lru_free: Vec<usize>,
196
197    /// Most-recently-used node (head of the list).
198    lru_head: Option<usize>,
199
200    /// Least-recently-used node (tail of the list).
201    lru_tail: Option<usize>,
202
203    /// Running byte total of all entry payloads.
204    total_bytes: usize,
205
206    // ── Stats ──────────────────────────────────────────────────────────
207    hits: u64,
208    misses: u64,
209    evictions: u64,
210    insertions: u64,
211    expirations: u64,
212}
213
214impl ContentAddressableCache {
215    // ── Construction ──────────────────────────────────────────────────
216
217    /// Create a new cache with the given configuration.
218    pub fn new(config: CacheConfig) -> Self {
219        let cap = config.max_entries;
220        Self {
221            config,
222            entries: HashMap::with_capacity(cap),
223            lru_index: HashMap::with_capacity(cap),
224            lru_arena: Vec::with_capacity(cap),
225            lru_free: Vec::new(),
226            lru_head: None,
227            lru_tail: None,
228            total_bytes: 0,
229            hits: 0,
230            misses: 0,
231            evictions: 0,
232            insertions: 0,
233            expirations: 0,
234        }
235    }
236
237    // ── Public API ────────────────────────────────────────────────────
238
239    /// Insert `data` into the cache.
240    ///
241    /// Computes the CID automatically; applies `ttl_us` (or the configured
242    /// default) and the given `tags`. Returns the derived CID on success.
243    pub fn insert(
244        &mut self,
245        data: Vec<u8>,
246        ttl_us: Option<u64>,
247        tags: Vec<String>,
248    ) -> Result<String, CacheError> {
249        let cid = compute_cid(&data);
250        self.insert_with_cid(cid.clone(), data, ttl_us, tags)?;
251        Ok(cid)
252    }
253
254    /// Insert `data` under a caller-supplied `cid`.
255    ///
256    /// Verifies that the CID matches the data; returns
257    /// [`CacheError::CidMismatch`] otherwise.
258    pub fn insert_with_cid(
259        &mut self,
260        cid: String,
261        data: Vec<u8>,
262        ttl_us: Option<u64>,
263        tags: Vec<String>,
264    ) -> Result<(), CacheError> {
265        // Verify CID.
266        let expected = compute_cid(&data);
267        if expected != cid {
268            return Err(CacheError::CidMismatch { expected, got: cid });
269        }
270
271        let size = data.len();
272        if size > self.config.max_bytes {
273            return Err(CacheError::EntryTooLarge(size));
274        }
275
276        // If the entry already exists just update it in-place.
277        if self.entries.contains_key(&cid) {
278            let now = now_us();
279            let entry = self
280                .entries
281                .get_mut(&cid)
282                .ok_or(CacheError::EntryNotFound)?;
283            self.total_bytes -= entry.size_bytes;
284            entry.data = data;
285            entry.inserted_at = now;
286            entry.last_accessed = now;
287            entry.ttl_us = ttl_us.or(self.config.default_ttl_us);
288            entry.tags = tags;
289            entry.size_bytes = size;
290            entry.access_count = 0;
291            self.total_bytes += size;
292            self.lru_touch(&cid.clone());
293            return Ok(());
294        }
295
296        // Evict expired entries first, then evict by policy until there is room.
297        self.expire_ttl_internal();
298        self.evict_to_fit_internal(size)?;
299
300        let now = now_us();
301        let effective_ttl = ttl_us.or(self.config.default_ttl_us);
302        let entry = CacheEntry {
303            cid: cid.clone(),
304            data,
305            inserted_at: now,
306            last_accessed: now,
307            access_count: 0,
308            ttl_us: effective_ttl,
309            size_bytes: size,
310            tags,
311        };
312
313        self.total_bytes += size;
314        self.entries.insert(cid.clone(), entry);
315        self.lru_push_front(&cid);
316
317        if self.config.enable_stats {
318            self.insertions += 1;
319        }
320        Ok(())
321    }
322
323    /// Retrieve the data payload for `cid`.
324    ///
325    /// Updates LRU position and increments `access_count`. Returns
326    /// [`CacheError::TtlExpired`] (and removes the entry) if the entry has
327    /// expired.
328    pub fn get(&mut self, cid: &str) -> Result<&[u8], CacheError> {
329        let now = now_us();
330
331        // Check existence & expiry before borrowing mutably.
332        let expired = self.entries.get(cid).map(|e| e.is_expired(now));
333        match expired {
334            None => {
335                if self.config.enable_stats {
336                    self.misses += 1;
337                }
338                return Err(CacheError::EntryNotFound);
339            }
340            Some(true) => {
341                self.remove_internal(cid);
342                if self.config.enable_stats {
343                    self.expirations += 1;
344                    self.misses += 1;
345                }
346                return Err(CacheError::TtlExpired);
347            }
348            Some(false) => {}
349        }
350
351        // Update metadata.
352        if let Some(entry) = self.entries.get_mut(cid) {
353            entry.last_accessed = now;
354            entry.access_count += 1;
355        }
356        self.lru_touch(cid);
357
358        if self.config.enable_stats {
359            self.hits += 1;
360        }
361
362        self.entries
363            .get(cid)
364            .map(|e| e.data.as_slice())
365            .ok_or(CacheError::EntryNotFound)
366    }
367
368    /// Retrieve the full [`CacheEntry`] for `cid` (read-only, does NOT update
369    /// LRU or access counts).
370    pub fn get_entry(&self, cid: &str) -> Result<&CacheEntry, CacheError> {
371        self.entries.get(cid).ok_or(CacheError::EntryNotFound)
372    }
373
374    /// Return `true` when an entry for `cid` exists and has not expired.
375    /// Does **not** update LRU position.
376    pub fn contains(&self, cid: &str) -> bool {
377        match self.entries.get(cid) {
378            Some(e) => !e.is_expired(now_us()),
379            None => false,
380        }
381    }
382
383    /// Remove and return the entry for `cid`.
384    pub fn remove(&mut self, cid: &str) -> Result<CacheEntry, CacheError> {
385        match self.remove_internal(cid) {
386            Some(e) => Ok(e),
387            None => Err(CacheError::EntryNotFound),
388        }
389    }
390
391    /// Remove all entries bearing `tag` and return their CIDs.
392    pub fn remove_by_tag(&mut self, tag: &str) -> Vec<String> {
393        let cids: Vec<String> = self
394            .entries
395            .values()
396            .filter(|e| e.tags.iter().any(|t| t == tag))
397            .map(|e| e.cid.clone())
398            .collect();
399
400        for cid in &cids {
401            self.remove_internal(cid);
402        }
403        cids
404    }
405
406    /// Scan the cache and remove all TTL-expired entries.
407    ///
408    /// Returns the CIDs that were removed.
409    pub fn expire_ttl(&mut self) -> Vec<String> {
410        self.expire_ttl_internal()
411    }
412
413    /// Evict entries per policy until `needed_bytes` additional bytes can be
414    /// accommodated without exceeding `max_bytes`.
415    ///
416    /// Returns the CIDs evicted.
417    pub fn evict_to_fit(&mut self, needed_bytes: usize) -> Vec<String> {
418        let mut evicted = Vec::new();
419        while self.total_bytes + needed_bytes > self.config.max_bytes
420            || self.entries.len() >= self.config.max_entries
421        {
422            if self.entries.is_empty() {
423                break;
424            }
425            if let Some(cid) = self.pick_eviction_candidate() {
426                self.remove_internal(&cid);
427                if self.config.enable_stats {
428                    self.evictions += 1;
429                }
430                evicted.push(cid);
431            } else {
432                break;
433            }
434        }
435        evicted
436    }
437
438    /// Return a snapshot of current cache statistics.
439    pub fn stats(&self) -> CacheStats {
440        let total_lookups = self.hits + self.misses;
441        let hit_rate = if total_lookups > 0 {
442            self.hits as f64 / total_lookups as f64
443        } else {
444            0.0
445        };
446        CacheStats {
447            hits: self.hits,
448            misses: self.misses,
449            evictions: self.evictions,
450            insertions: self.insertions,
451            expirations: self.expirations,
452            current_entries: self.entries.len(),
453            current_bytes: self.total_bytes,
454            hit_rate,
455        }
456    }
457
458    /// Remove all entries and return how many were cleared.
459    pub fn clear(&mut self) -> usize {
460        let count = self.entries.len();
461        self.entries.clear();
462        self.lru_index.clear();
463        self.lru_arena.clear();
464        self.lru_free.clear();
465        self.lru_head = None;
466        self.lru_tail = None;
467        self.total_bytes = 0;
468        count
469    }
470
471    /// Return the CIDs of all currently-cached entries.
472    pub fn cids(&self) -> Vec<String> {
473        self.entries.keys().cloned().collect()
474    }
475
476    // ── Internal helpers ──────────────────────────────────────────────
477
478    fn expire_ttl_internal(&mut self) -> Vec<String> {
479        let now = now_us();
480        let expired: Vec<String> = self
481            .entries
482            .values()
483            .filter(|e| e.is_expired(now))
484            .map(|e| e.cid.clone())
485            .collect();
486
487        for cid in &expired {
488            self.remove_internal(cid);
489            if self.config.enable_stats {
490                self.expirations += 1;
491            }
492        }
493        expired
494    }
495
496    fn evict_to_fit_internal(&mut self, needed_bytes: usize) -> Result<(), CacheError> {
497        let max_attempts = self.entries.len() + 1;
498        let mut attempts = 0;
499
500        while (self.total_bytes + needed_bytes > self.config.max_bytes
501            || self.entries.len() >= self.config.max_entries)
502            && !self.entries.is_empty()
503        {
504            if attempts >= max_attempts {
505                return Err(CacheError::CacheAtCapacity);
506            }
507            attempts += 1;
508
509            if let Some(cid) = self.pick_eviction_candidate() {
510                self.remove_internal(&cid);
511                if self.config.enable_stats {
512                    self.evictions += 1;
513                }
514            } else {
515                return Err(CacheError::CacheAtCapacity);
516            }
517        }
518
519        if needed_bytes > self.config.max_bytes {
520            return Err(CacheError::EntryTooLarge(needed_bytes));
521        }
522        Ok(())
523    }
524
525    /// Choose the CID of the best eviction candidate according to the policy.
526    fn pick_eviction_candidate(&self) -> Option<String> {
527        if self.entries.is_empty() {
528            return None;
529        }
530
531        match &self.config.policy {
532            EvictionPolicy::Lru => {
533                // The LRU tail is the least-recently-used entry.
534                self.lru_tail
535                    .and_then(|idx| self.lru_arena.get(idx))
536                    .and_then(|node| node.as_ref())
537                    .map(|n| n.cid.clone())
538            }
539            EvictionPolicy::Lfu => self
540                .entries
541                .values()
542                .min_by_key(|e| e.access_count)
543                .map(|e| e.cid.clone()),
544            EvictionPolicy::TtlFirst => {
545                let now = now_us();
546                self.entries
547                    .values()
548                    .min_by_key(|e| e.remaining_ttl_us(now))
549                    .map(|e| e.cid.clone())
550            }
551            EvictionPolicy::SizeWeighted => self
552                .entries
553                .values()
554                .max_by_key(|e| e.size_bytes)
555                .map(|e| e.cid.clone()),
556            EvictionPolicy::Tagged(tag) => {
557                // Prefer tagged entries; fall back to LRU tail.
558                let tagged = self
559                    .entries
560                    .values()
561                    .find(|e| e.tags.iter().any(|t| t == tag))
562                    .map(|e| e.cid.clone());
563
564                tagged.or_else(|| {
565                    self.lru_tail
566                        .and_then(|idx| self.lru_arena.get(idx))
567                        .and_then(|node| node.as_ref())
568                        .map(|n| n.cid.clone())
569                })
570            }
571        }
572    }
573
574    /// Remove an entry by CID, returning it if it existed.
575    fn remove_internal(&mut self, cid: &str) -> Option<CacheEntry> {
576        let entry = self.entries.remove(cid)?;
577        self.total_bytes -= entry.size_bytes;
578        self.lru_remove(cid);
579        Some(entry)
580    }
581
582    // ── LRU linked-list operations ────────────────────────────────────
583
584    /// Allocate a new arena slot (or reuse a freed one) and return its index.
585    fn lru_alloc(&mut self, node: LruNode) -> usize {
586        if let Some(idx) = self.lru_free.pop() {
587            self.lru_arena[idx] = Some(node);
588            idx
589        } else {
590            let idx = self.lru_arena.len();
591            self.lru_arena.push(Some(node));
592            idx
593        }
594    }
595
596    /// Insert a new CID at the head (most-recently-used position).
597    fn lru_push_front(&mut self, cid: &str) {
598        let node = LruNode {
599            cid: cid.to_owned(),
600            prev: None,
601            next: self.lru_head,
602        };
603        let idx = self.lru_alloc(node);
604
605        if let Some(old_head) = self.lru_head {
606            if let Some(Some(n)) = self.lru_arena.get_mut(old_head) {
607                n.prev = Some(idx);
608            }
609        }
610
611        self.lru_head = Some(idx);
612        if self.lru_tail.is_none() {
613            self.lru_tail = Some(idx);
614        }
615
616        self.lru_index.insert(cid.to_owned(), idx);
617    }
618
619    /// Move an existing entry to the head (access touch).
620    fn lru_touch(&mut self, cid: &str) {
621        // Detach from current position.
622        let idx = match self.lru_index.get(cid).copied() {
623            Some(i) => i,
624            None => return,
625        };
626
627        let (prev, next) = {
628            match self.lru_arena.get(idx).and_then(|n| n.as_ref()) {
629                Some(n) => (n.prev, n.next),
630                None => return,
631            }
632        };
633
634        // Already at head — nothing to do.
635        if prev.is_none() {
636            return;
637        }
638
639        // Splice out.
640        if let Some(p) = prev {
641            if let Some(Some(pn)) = self.lru_arena.get_mut(p) {
642                pn.next = next;
643            }
644        }
645        if let Some(nx) = next {
646            if let Some(Some(nn)) = self.lru_arena.get_mut(nx) {
647                nn.prev = prev;
648            }
649        } else {
650            // Was tail.
651            self.lru_tail = prev;
652        }
653
654        // Re-link at head.
655        if let Some(Some(n)) = self.lru_arena.get_mut(idx) {
656            n.prev = None;
657            n.next = self.lru_head;
658        }
659        if let Some(old_head) = self.lru_head {
660            if let Some(Some(h)) = self.lru_arena.get_mut(old_head) {
661                h.prev = Some(idx);
662            }
663        }
664        self.lru_head = Some(idx);
665    }
666
667    /// Remove the LRU node associated with `cid`.
668    fn lru_remove(&mut self, cid: &str) {
669        let idx = match self.lru_index.remove(cid) {
670            Some(i) => i,
671            None => return,
672        };
673
674        let (prev, next) = {
675            match self.lru_arena.get(idx).and_then(|n| n.as_ref()) {
676                Some(n) => (n.prev, n.next),
677                None => return,
678            }
679        };
680
681        if let Some(p) = prev {
682            if let Some(Some(pn)) = self.lru_arena.get_mut(p) {
683                pn.next = next;
684            }
685        } else {
686            self.lru_head = next;
687        }
688
689        if let Some(nx) = next {
690            if let Some(Some(nn)) = self.lru_arena.get_mut(nx) {
691                nn.prev = prev;
692            }
693        } else {
694            self.lru_tail = prev;
695        }
696
697        self.lru_arena[idx] = None;
698        self.lru_free.push(idx);
699    }
700}
701
702// ─── Tests ────────────────────────────────────────────────────────────────────
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    // ── Inline xorshift64 PRNG for test data (no `rand` crate) ───────────────
709
710    struct Xorshift64 {
711        state: u64,
712    }
713
714    impl Xorshift64 {
715        fn new(seed: u64) -> Self {
716            Self {
717                state: if seed == 0 { 1 } else { seed },
718            }
719        }
720
721        fn next(&mut self) -> u64 {
722            self.state ^= self.state << 13;
723            self.state ^= self.state >> 7;
724            self.state ^= self.state << 17;
725            self.state
726        }
727
728        fn bytes(&mut self, len: usize) -> Vec<u8> {
729            let mut out = Vec::with_capacity(len);
730            while out.len() < len {
731                let v = self.next().to_le_bytes();
732                for &b in &v {
733                    if out.len() < len {
734                        out.push(b);
735                    }
736                }
737            }
738            out
739        }
740    }
741
742    fn default_cache() -> ContentAddressableCache {
743        ContentAddressableCache::new(CacheConfig {
744            max_entries: 64,
745            max_bytes: 1024 * 1024,
746            ..Default::default()
747        })
748    }
749
750    // ── 1: Basic insert / get ─────────────────────────────────────────────────
751
752    #[test]
753    fn test_insert_returns_valid_cid() {
754        let mut c = default_cache();
755        let data = b"hello world".to_vec();
756        let cid = c.insert(data.clone(), None, vec![]).unwrap();
757        assert_eq!(cid.len(), 16);
758        assert_eq!(cid, compute_cid(&data));
759    }
760
761    #[test]
762    fn test_get_returns_correct_data() {
763        let mut c = default_cache();
764        let data = b"test data".to_vec();
765        let cid = c.insert(data.clone(), None, vec![]).unwrap();
766        assert_eq!(c.get(&cid).unwrap(), data.as_slice());
767    }
768
769    #[test]
770    fn test_get_missing_returns_err() {
771        let mut c = default_cache();
772        assert_eq!(c.get("deadbeef00000000"), Err(CacheError::EntryNotFound));
773    }
774
775    #[test]
776    fn test_contains_true_after_insert() {
777        let mut c = default_cache();
778        let cid = c.insert(b"abc".to_vec(), None, vec![]).unwrap();
779        assert!(c.contains(&cid));
780    }
781
782    #[test]
783    fn test_contains_false_for_missing() {
784        let c = default_cache();
785        assert!(!c.contains("0000000000000000"));
786    }
787
788    // ── 2: insert_with_cid ───────────────────────────────────────────────────
789
790    #[test]
791    fn test_insert_with_cid_success() {
792        let mut c = default_cache();
793        let data = b"verified".to_vec();
794        let cid = compute_cid(&data);
795        c.insert_with_cid(cid.clone(), data, None, vec![]).unwrap();
796        assert!(c.contains(&cid));
797    }
798
799    #[test]
800    fn test_insert_with_cid_mismatch_errors() {
801        let mut c = default_cache();
802        let data = b"real data".to_vec();
803        let bad_cid = "aaaaaaaaaaaaaaaa".to_string();
804        let res = c.insert_with_cid(bad_cid.clone(), data.clone(), None, vec![]);
805        assert!(matches!(
806            res,
807            Err(CacheError::CidMismatch { got, .. }) if got == bad_cid
808        ));
809    }
810
811    // ── 3: remove ────────────────────────────────────────────────────────────
812
813    #[test]
814    fn test_remove_existing_entry() {
815        let mut c = default_cache();
816        let cid = c.insert(b"remove me".to_vec(), None, vec![]).unwrap();
817        let entry = c.remove(&cid).unwrap();
818        assert_eq!(entry.cid, cid);
819        assert!(!c.contains(&cid));
820    }
821
822    #[test]
823    fn test_remove_missing_returns_err() {
824        let mut c = default_cache();
825        assert_eq!(c.remove("0000000000000001"), Err(CacheError::EntryNotFound));
826    }
827
828    // ── 4: get_entry ─────────────────────────────────────────────────────────
829
830    #[test]
831    fn test_get_entry_returns_metadata() {
832        let mut c = default_cache();
833        let data = b"entry meta".to_vec();
834        let cid = c
835            .insert(data.clone(), Some(999_999), vec!["tag1".into()])
836            .unwrap();
837        let entry = c.get_entry(&cid).unwrap();
838        assert_eq!(entry.size_bytes, data.len());
839        assert_eq!(entry.ttl_us, Some(999_999));
840        assert!(entry.tags.contains(&"tag1".to_string()));
841    }
842
843    // ── 5: Access count increments ───────────────────────────────────────────
844
845    #[test]
846    fn test_access_count_increments_on_get() {
847        let mut c = default_cache();
848        let cid = c.insert(b"counted".to_vec(), None, vec![]).unwrap();
849        c.get(&cid).unwrap();
850        c.get(&cid).unwrap();
851        assert_eq!(c.get_entry(&cid).unwrap().access_count, 2);
852    }
853
854    // ── 6: LRU ordering ──────────────────────────────────────────────────────
855
856    #[test]
857    fn test_lru_evicts_least_recently_used() {
858        // max_entries = 3; insert a, b, c (c is MRU); then insert d → a evicted
859        let mut c = ContentAddressableCache::new(CacheConfig {
860            max_entries: 3,
861            max_bytes: 1024 * 1024,
862            policy: EvictionPolicy::Lru,
863            ..Default::default()
864        });
865
866        let a = c.insert(b"aaaa".to_vec(), None, vec![]).unwrap();
867        let b = c.insert(b"bbbb".to_vec(), None, vec![]).unwrap();
868        let cc = c.insert(b"cccc".to_vec(), None, vec![]).unwrap();
869
870        // Touch b so order is c (MRU) → b → a (LRU).
871        // Actually insertion order: a inserted first (tail), c most recent (head).
872        // Access b to move it to front: order becomes b → c → a (LRU tail = a).
873        c.get(&b).unwrap();
874
875        // Insert d → should evict a (LRU tail).
876        let d = c.insert(b"dddd".to_vec(), None, vec![]).unwrap();
877        assert!(!c.contains(&a), "a should have been evicted");
878        assert!(c.contains(&b));
879        assert!(c.contains(&cc));
880        assert!(c.contains(&d));
881    }
882
883    #[test]
884    fn test_lru_touch_moves_to_front() {
885        let mut c = ContentAddressableCache::new(CacheConfig {
886            max_entries: 3,
887            max_bytes: 1024 * 1024,
888            policy: EvictionPolicy::Lru,
889            ..Default::default()
890        });
891
892        let a = c.insert(b"aaaa".to_vec(), None, vec![]).unwrap();
893        let _b = c.insert(b"bbbb".to_vec(), None, vec![]).unwrap();
894        let _cc = c.insert(b"cccc".to_vec(), None, vec![]).unwrap();
895
896        // Touch a to move it to MRU.
897        c.get(&a).unwrap();
898
899        // Insert d — should NOT evict a.
900        c.insert(b"dddd".to_vec(), None, vec![]).unwrap();
901        assert!(c.contains(&a));
902    }
903
904    // ── 7: LFU eviction ──────────────────────────────────────────────────────
905
906    #[test]
907    fn test_lfu_evicts_least_frequently_used() {
908        let mut c = ContentAddressableCache::new(CacheConfig {
909            max_entries: 3,
910            max_bytes: 1024 * 1024,
911            policy: EvictionPolicy::Lfu,
912            ..Default::default()
913        });
914
915        let a = c.insert(b"aaaa".to_vec(), None, vec![]).unwrap();
916        let b = c.insert(b"bbbb".to_vec(), None, vec![]).unwrap();
917        let cc = c.insert(b"cccc".to_vec(), None, vec![]).unwrap();
918
919        // Access b and c multiple times so a stays at count 0.
920        c.get(&b).unwrap();
921        c.get(&b).unwrap();
922        c.get(&cc).unwrap();
923
924        // Insert d → a (access_count 0) should be evicted.
925        let d = c.insert(b"dddd".to_vec(), None, vec![]).unwrap();
926        assert!(!c.contains(&a));
927        assert!(c.contains(&b));
928        assert!(c.contains(&cc));
929        assert!(c.contains(&d));
930    }
931
932    // ── 8: TTL expiration ────────────────────────────────────────────────────
933
934    #[test]
935    fn test_ttl_expired_entry_removed_on_get() {
936        let mut c = default_cache();
937        // TTL of 1 microsecond — will have expired by the next syscall.
938        let cid = c.insert(b"expire me".to_vec(), Some(1), vec![]).unwrap();
939        std::thread::sleep(std::time::Duration::from_millis(5));
940        assert_eq!(c.get(&cid), Err(CacheError::TtlExpired));
941        assert!(!c.contains(&cid));
942    }
943
944    #[test]
945    fn test_expire_ttl_removes_expired_entries() {
946        let mut c = default_cache();
947        // Use a TTL long enough to survive the insert but short enough to expire after sleep.
948        let cid1 = c.insert(b"exp1".to_vec(), Some(10_000), vec![]).unwrap();
949        let cid2 = c.insert(b"keep".to_vec(), None, vec![]).unwrap();
950        std::thread::sleep(std::time::Duration::from_millis(20));
951        let removed = c.expire_ttl();
952        assert!(removed.contains(&cid1));
953        assert!(!removed.contains(&cid2));
954        assert!(!c.contains(&cid1));
955        assert!(c.contains(&cid2));
956    }
957
958    #[test]
959    fn test_contains_returns_false_for_expired() {
960        let mut c = default_cache();
961        let cid = c.insert(b"exp_check".to_vec(), Some(1), vec![]).unwrap();
962        std::thread::sleep(std::time::Duration::from_millis(5));
963        assert!(!c.contains(&cid));
964    }
965
966    #[test]
967    fn test_default_ttl_applied() {
968        let mut c = ContentAddressableCache::new(CacheConfig {
969            default_ttl_us: Some(1),
970            ..Default::default()
971        });
972        let cid = c.insert(b"default_ttl".to_vec(), None, vec![]).unwrap();
973        let entry = c.get_entry(&cid).unwrap();
974        assert_eq!(entry.ttl_us, Some(1));
975    }
976
977    // ── 9: TTLFirst eviction ─────────────────────────────────────────────────
978
979    #[test]
980    fn test_ttlfirst_evicts_soonest_expiring() {
981        let mut c = ContentAddressableCache::new(CacheConfig {
982            max_entries: 3,
983            max_bytes: 1024 * 1024,
984            policy: EvictionPolicy::TtlFirst,
985            ..Default::default()
986        });
987
988        // short_ttl will expire first.
989        let short = c.insert(b"short".to_vec(), Some(10), vec![]).unwrap();
990        let _mid = c
991            .insert(b"middl".to_vec(), Some(100_000_000), vec![])
992            .unwrap();
993        let _long = c.insert(b"longg".to_vec(), None, vec![]).unwrap();
994
995        // Insert d → short should be evicted (soonest TTL).
996        c.insert(b"ddddd".to_vec(), None, vec![]).unwrap();
997        assert!(!c.contains(&short));
998    }
999
1000    // ── 10: SizeWeighted eviction ────────────────────────────────────────────
1001
1002    #[test]
1003    fn test_size_weighted_evicts_largest() {
1004        let mut c = ContentAddressableCache::new(CacheConfig {
1005            max_entries: 3,
1006            max_bytes: 1024 * 1024,
1007            policy: EvictionPolicy::SizeWeighted,
1008            ..Default::default()
1009        });
1010
1011        let small = c.insert(b"s".to_vec(), None, vec![]).unwrap();
1012        let large = c.insert(vec![0u8; 512], None, vec![]).unwrap();
1013        let _medium = c.insert(vec![0u8; 128], None, vec![]).unwrap();
1014
1015        c.insert(b"new".to_vec(), None, vec![]).unwrap();
1016        assert!(!c.contains(&large), "largest should be evicted");
1017        assert!(c.contains(&small));
1018    }
1019
1020    // ── 11: Tagged eviction ──────────────────────────────────────────────────
1021
1022    #[test]
1023    fn test_tagged_evicts_tagged_entries_first() {
1024        let mut c = ContentAddressableCache::new(CacheConfig {
1025            max_entries: 3,
1026            max_bytes: 1024 * 1024,
1027            policy: EvictionPolicy::Tagged("evict_me".to_string()),
1028            ..Default::default()
1029        });
1030
1031        let tagged = c
1032            .insert(b"tagged_entry".to_vec(), None, vec!["evict_me".into()])
1033            .unwrap();
1034        let _untagged1 = c.insert(b"untagged_a".to_vec(), None, vec![]).unwrap();
1035        let _untagged2 = c.insert(b"untagged_b".to_vec(), None, vec![]).unwrap();
1036
1037        c.insert(b"trigger_evict".to_vec(), None, vec![]).unwrap();
1038        assert!(!c.contains(&tagged));
1039    }
1040
1041    #[test]
1042    fn test_tagged_falls_back_to_lru_when_no_tagged_entry() {
1043        let mut c = ContentAddressableCache::new(CacheConfig {
1044            max_entries: 3,
1045            max_bytes: 1024 * 1024,
1046            policy: EvictionPolicy::Tagged("missing_tag".to_string()),
1047            ..Default::default()
1048        });
1049
1050        let a = c.insert(b"aaaaaa".to_vec(), None, vec![]).unwrap();
1051        let _b = c.insert(b"bbbbbb".to_vec(), None, vec![]).unwrap();
1052        let _cc = c.insert(b"cccccc".to_vec(), None, vec![]).unwrap();
1053
1054        c.insert(b"dddddd".to_vec(), None, vec![]).unwrap();
1055        // LRU tail (a) should be evicted as fallback.
1056        assert!(!c.contains(&a));
1057    }
1058
1059    // ── 12: remove_by_tag ────────────────────────────────────────────────────
1060
1061    #[test]
1062    fn test_remove_by_tag_removes_all_tagged() {
1063        let mut c = default_cache();
1064        let t1 = c
1065            .insert(b"t1data".to_vec(), None, vec!["group".into()])
1066            .unwrap();
1067        let t2 = c
1068            .insert(b"t2data".to_vec(), None, vec!["group".into()])
1069            .unwrap();
1070        let keep = c
1071            .insert(b"keepdt".to_vec(), None, vec!["other".into()])
1072            .unwrap();
1073
1074        let removed = c.remove_by_tag("group");
1075        assert!(removed.contains(&t1));
1076        assert!(removed.contains(&t2));
1077        assert!(!c.contains(&t1));
1078        assert!(!c.contains(&t2));
1079        assert!(c.contains(&keep));
1080    }
1081
1082    #[test]
1083    fn test_remove_by_tag_returns_empty_when_no_match() {
1084        let mut c = default_cache();
1085        c.insert(b"data".to_vec(), None, vec!["other".into()])
1086            .unwrap();
1087        assert!(c.remove_by_tag("nonexistent").is_empty());
1088    }
1089
1090    // ── 13: Capacity enforcement ─────────────────────────────────────────────
1091
1092    #[test]
1093    fn test_entry_count_never_exceeds_max() {
1094        let mut c = ContentAddressableCache::new(CacheConfig {
1095            max_entries: 10,
1096            max_bytes: 1024 * 1024,
1097            ..Default::default()
1098        });
1099        let mut rng = Xorshift64::new(42);
1100        for _ in 0..20 {
1101            let data = rng.bytes(16);
1102            let _ = c.insert(data, None, vec![]);
1103        }
1104        assert!(c.stats().current_entries <= 10);
1105    }
1106
1107    #[test]
1108    fn test_byte_limit_never_exceeded() {
1109        let limit = 1024_usize;
1110        let mut c = ContentAddressableCache::new(CacheConfig {
1111            max_entries: 1024,
1112            max_bytes: limit,
1113            ..Default::default()
1114        });
1115        let mut rng = Xorshift64::new(99);
1116        for _ in 0..50 {
1117            let data = rng.bytes(64);
1118            let _ = c.insert(data, None, vec![]);
1119        }
1120        assert!(c.stats().current_bytes <= limit);
1121    }
1122
1123    #[test]
1124    fn test_entry_too_large_rejected() {
1125        let mut c = ContentAddressableCache::new(CacheConfig {
1126            max_entries: 64,
1127            max_bytes: 100,
1128            ..Default::default()
1129        });
1130        let res = c.insert(vec![0u8; 200], None, vec![]);
1131        assert_eq!(res, Err(CacheError::EntryTooLarge(200)));
1132    }
1133
1134    // ── 14: Stats accuracy ───────────────────────────────────────────────────
1135
1136    #[test]
1137    fn test_stats_hits_and_misses() {
1138        let mut c = default_cache();
1139        let cid = c.insert(b"stats_test".to_vec(), None, vec![]).unwrap();
1140        c.get(&cid).unwrap();
1141        c.get(&cid).unwrap();
1142        let _ = c.get("000000000000cafe");
1143        let s = c.stats();
1144        assert_eq!(s.hits, 2);
1145        assert_eq!(s.misses, 1);
1146    }
1147
1148    #[test]
1149    fn test_stats_hit_rate() {
1150        let mut c = default_cache();
1151        let cid = c.insert(b"hr".to_vec(), None, vec![]).unwrap();
1152        c.get(&cid).unwrap(); // hit
1153        let _ = c.get("nope00000000cafe"); // miss
1154        let s = c.stats();
1155        assert!((s.hit_rate - 0.5).abs() < f64::EPSILON);
1156    }
1157
1158    #[test]
1159    fn test_stats_insertions_count() {
1160        let mut c = default_cache();
1161        c.insert(b"a".to_vec(), None, vec![]).unwrap();
1162        c.insert(b"b".to_vec(), None, vec![]).unwrap();
1163        c.insert(b"c".to_vec(), None, vec![]).unwrap();
1164        assert_eq!(c.stats().insertions, 3);
1165    }
1166
1167    #[test]
1168    fn test_stats_eviction_count() {
1169        let mut c = ContentAddressableCache::new(CacheConfig {
1170            max_entries: 2,
1171            max_bytes: 1024 * 1024,
1172            ..Default::default()
1173        });
1174        c.insert(b"first_".to_vec(), None, vec![]).unwrap();
1175        c.insert(b"second".to_vec(), None, vec![]).unwrap();
1176        c.insert(b"third_".to_vec(), None, vec![]).unwrap(); // triggers eviction
1177        assert!(c.stats().evictions >= 1);
1178    }
1179
1180    #[test]
1181    fn test_stats_current_bytes() {
1182        let mut c = default_cache();
1183        let data = vec![0u8; 128];
1184        c.insert(data, None, vec![]).unwrap();
1185        assert_eq!(c.stats().current_bytes, 128);
1186    }
1187
1188    #[test]
1189    fn test_stats_current_entries() {
1190        let mut c = default_cache();
1191        c.insert(b"e1".to_vec(), None, vec![]).unwrap();
1192        c.insert(b"e2".to_vec(), None, vec![]).unwrap();
1193        assert_eq!(c.stats().current_entries, 2);
1194    }
1195
1196    #[test]
1197    fn test_stats_expirations_count() {
1198        let mut c = default_cache();
1199        c.insert(b"will_expire".to_vec(), Some(1), vec![]).unwrap();
1200        std::thread::sleep(std::time::Duration::from_millis(5));
1201        c.expire_ttl();
1202        assert_eq!(c.stats().expirations, 1);
1203    }
1204
1205    // ── 15: clear ────────────────────────────────────────────────────────────
1206
1207    #[test]
1208    fn test_clear_removes_all() {
1209        let mut c = default_cache();
1210        c.insert(b"x".to_vec(), None, vec![]).unwrap();
1211        c.insert(b"y".to_vec(), None, vec![]).unwrap();
1212        let n = c.clear();
1213        assert_eq!(n, 2);
1214        assert_eq!(c.stats().current_entries, 0);
1215        assert_eq!(c.stats().current_bytes, 0);
1216    }
1217
1218    // ── 16: cids() ───────────────────────────────────────────────────────────
1219
1220    #[test]
1221    fn test_cids_returns_all_current_cids() {
1222        let mut c = default_cache();
1223        let cid1 = c.insert(b"cid_test_1".to_vec(), None, vec![]).unwrap();
1224        let cid2 = c.insert(b"cid_test_2".to_vec(), None, vec![]).unwrap();
1225        let cids = c.cids();
1226        assert!(cids.contains(&cid1));
1227        assert!(cids.contains(&cid2));
1228        assert_eq!(cids.len(), 2);
1229    }
1230
1231    // ── 17: evict_to_fit ─────────────────────────────────────────────────────
1232
1233    #[test]
1234    fn test_evict_to_fit_makes_room() {
1235        let limit = 256_usize;
1236        let mut c = ContentAddressableCache::new(CacheConfig {
1237            max_entries: 64,
1238            max_bytes: limit,
1239            ..Default::default()
1240        });
1241        // Fill to capacity with 32-byte payloads.
1242        let mut rng = Xorshift64::new(77);
1243        for _ in 0..8 {
1244            let data = rng.bytes(32);
1245            c.insert(data, None, vec![]).unwrap();
1246        }
1247        assert!(c.stats().current_bytes <= limit);
1248        // Now request 32 more bytes.
1249        let evicted = c.evict_to_fit(32);
1250        assert!(!evicted.is_empty() || c.stats().current_bytes + 32 <= limit);
1251    }
1252
1253    // ── 18: Duplicate insert updates in place ────────────────────────────────
1254
1255    #[test]
1256    fn test_duplicate_insert_updates_entry() {
1257        let mut c = default_cache();
1258        let data = b"same data".to_vec();
1259        let cid = c
1260            .insert(data.clone(), Some(1_000_000), vec!["old".into()])
1261            .unwrap();
1262        // Re-insert same data with different TTL and tags.
1263        c.insert(data.clone(), Some(9_999_999), vec!["new".into()])
1264            .unwrap();
1265        let entry = c.get_entry(&cid).unwrap();
1266        assert_eq!(entry.ttl_us, Some(9_999_999));
1267        assert!(entry.tags.contains(&"new".to_string()));
1268        // Entry count should still be 1.
1269        assert_eq!(c.stats().current_entries, 1);
1270    }
1271
1272    // ── 19: CID determinism ──────────────────────────────────────────────────
1273
1274    #[test]
1275    fn test_cid_is_deterministic() {
1276        let data = b"deterministic payload";
1277        let c1 = compute_cid(data);
1278        let c2 = compute_cid(data);
1279        assert_eq!(c1, c2);
1280    }
1281
1282    #[test]
1283    fn test_different_data_different_cid() {
1284        let c1 = compute_cid(b"apple");
1285        let c2 = compute_cid(b"orange");
1286        assert_ne!(c1, c2);
1287    }
1288
1289    // ── 20: LRU list integrity under many operations ─────────────────────────
1290
1291    #[test]
1292    fn test_lru_integrity_many_inserts() {
1293        let mut c = ContentAddressableCache::new(CacheConfig {
1294            max_entries: 8,
1295            max_bytes: 1024 * 1024,
1296            policy: EvictionPolicy::Lru,
1297            ..Default::default()
1298        });
1299        let mut rng = Xorshift64::new(1234);
1300        let mut inserted = Vec::new();
1301        for _ in 0..20 {
1302            let data = rng.bytes(16);
1303            if let Ok(cid) = c.insert(data, None, vec![]) {
1304                inserted.push(cid);
1305            }
1306        }
1307        assert!(c.stats().current_entries <= 8);
1308    }
1309
1310    // ── 21: Zero-byte payload ────────────────────────────────────────────────
1311
1312    #[test]
1313    fn test_zero_byte_insert_and_get() {
1314        let mut c = default_cache();
1315        let cid = c.insert(vec![], None, vec![]).unwrap();
1316        assert_eq!(c.get(&cid).unwrap(), &[] as &[u8]);
1317    }
1318
1319    // ── 22: Large single entry at max_bytes limit ─────────────────────────────
1320
1321    #[test]
1322    fn test_insert_exactly_at_byte_limit() {
1323        let limit = 512;
1324        let mut c = ContentAddressableCache::new(CacheConfig {
1325            max_entries: 64,
1326            max_bytes: limit,
1327            ..Default::default()
1328        });
1329        let data = vec![0u8; limit];
1330        c.insert(data, None, vec![]).unwrap();
1331        assert_eq!(c.stats().current_bytes, limit);
1332    }
1333
1334    // ── 23: Tag with multiple tagged entries ─────────────────────────────────
1335
1336    #[test]
1337    fn test_entry_can_have_multiple_tags() {
1338        let mut c = default_cache();
1339        let cid = c
1340            .insert(
1341                b"multi_tag".to_vec(),
1342                None,
1343                vec!["a".into(), "b".into(), "c".into()],
1344            )
1345            .unwrap();
1346        let entry = c.get_entry(&cid).unwrap();
1347        assert_eq!(entry.tags.len(), 3);
1348    }
1349
1350    // ── 24: Stats disabled ───────────────────────────────────────────────────
1351
1352    #[test]
1353    fn test_stats_disabled_no_counters() {
1354        let mut c = ContentAddressableCache::new(CacheConfig {
1355            enable_stats: false,
1356            ..Default::default()
1357        });
1358        let cid = c.insert(b"no_stats".to_vec(), None, vec![]).unwrap();
1359        c.get(&cid).unwrap();
1360        let s = c.stats();
1361        // Stats counters remain at default (0) since disabled.
1362        assert_eq!(s.hits, 0);
1363        assert_eq!(s.insertions, 0);
1364    }
1365
1366    // ── 25: LRU order after remove ───────────────────────────────────────────
1367
1368    #[test]
1369    fn test_lru_order_after_remove() {
1370        let mut c = ContentAddressableCache::new(CacheConfig {
1371            max_entries: 4,
1372            max_bytes: 1024 * 1024,
1373            policy: EvictionPolicy::Lru,
1374            ..Default::default()
1375        });
1376
1377        let a = c.insert(b"aaaaa_lru".to_vec(), None, vec![]).unwrap();
1378        let b = c.insert(b"bbbbb_lru".to_vec(), None, vec![]).unwrap();
1379        let cc = c.insert(b"ccccc_lru".to_vec(), None, vec![]).unwrap();
1380
1381        // Remove the MRU (c) and the tail (a).
1382        c.remove(&cc).unwrap();
1383        c.remove(&a).unwrap();
1384
1385        // Only b remains; insert 4 more — b should survive until the 4th.
1386        c.insert(b"d_lru_test".to_vec(), None, vec![]).unwrap();
1387        c.insert(b"e_lru_test".to_vec(), None, vec![]).unwrap();
1388        c.insert(b"f_lru_test".to_vec(), None, vec![]).unwrap();
1389        // At max_entries = 4 with b + d + e + f, inserting g evicts b (LRU tail).
1390        c.insert(b"g_lru_test".to_vec(), None, vec![]).unwrap();
1391        assert!(!c.contains(&b));
1392    }
1393
1394    // ── 26: Mixed TTL and non-TTL entries ────────────────────────────────────
1395
1396    #[test]
1397    fn test_mixed_ttl_and_no_ttl() {
1398        let mut c = default_cache();
1399        // Insert both entries first, then let TTL expire.
1400        let exp = c
1401            .insert(b"expiring_x".to_vec(), Some(10_000), vec![])
1402            .unwrap();
1403        let keep = c.insert(b"permanent_y".to_vec(), None, vec![]).unwrap();
1404        std::thread::sleep(std::time::Duration::from_millis(20));
1405        c.expire_ttl();
1406        assert!(!c.contains(&exp));
1407        assert!(c.contains(&keep));
1408    }
1409
1410    // ── 27: get miss updates miss counter ───────────────────────────────────
1411
1412    #[test]
1413    fn test_miss_counter_increments() {
1414        let mut c = default_cache();
1415        let _ = c.get("missing_cid_cafe01");
1416        let _ = c.get("missing_cid_cafe02");
1417        assert_eq!(c.stats().misses, 2);
1418    }
1419
1420    // ── 28: expiration triggers miss counter ─────────────────────────────────
1421
1422    #[test]
1423    fn test_expired_get_counts_as_miss() {
1424        let mut c = default_cache();
1425        let cid = c.insert(b"short_live".to_vec(), Some(1), vec![]).unwrap();
1426        std::thread::sleep(std::time::Duration::from_millis(5));
1427        let _ = c.get(&cid);
1428        assert_eq!(c.stats().misses, 1);
1429    }
1430
1431    // ── 29: evict_to_fit with zero needed ────────────────────────────────────
1432
1433    #[test]
1434    fn test_evict_to_fit_zero_needed() {
1435        let mut c = default_cache();
1436        c.insert(b"zero_fit".to_vec(), None, vec![]).unwrap();
1437        let evicted = c.evict_to_fit(0);
1438        assert!(evicted.is_empty());
1439    }
1440
1441    // ── 30: CID format is 16 hex chars ────────────────────────────────────────
1442
1443    #[test]
1444    fn test_cid_is_16_hex_chars() {
1445        let mut rng = Xorshift64::new(55);
1446        let mut c = default_cache();
1447        for _ in 0..10 {
1448            let data = rng.bytes(32);
1449            let cid = c.insert(data, None, vec![]).unwrap();
1450            assert_eq!(cid.len(), 16, "CID should be 16 hex chars");
1451            assert!(cid.chars().all(|ch| ch.is_ascii_hexdigit()));
1452        }
1453    }
1454
1455    // ── 31: Multiple tags on remove_by_tag ───────────────────────────────────
1456
1457    #[test]
1458    fn test_remove_by_tag_multi_tag_entries() {
1459        let mut c = default_cache();
1460        let cid = c
1461            .insert(
1462                b"multi_tagged_e".to_vec(),
1463                None,
1464                vec!["x".into(), "y".into()],
1465            )
1466            .unwrap();
1467        let removed = c.remove_by_tag("x");
1468        assert!(removed.contains(&cid));
1469        let removed2 = c.remove_by_tag("y");
1470        assert!(removed2.is_empty()); // already removed
1471    }
1472
1473    // ── 32: Clear resets byte count ──────────────────────────────────────────
1474
1475    #[test]
1476    fn test_clear_resets_byte_count() {
1477        let mut c = default_cache();
1478        c.insert(vec![0u8; 256], None, vec![]).unwrap();
1479        c.clear();
1480        assert_eq!(c.stats().current_bytes, 0);
1481    }
1482
1483    // ── 33: get_entry on missing CID ─────────────────────────────────────────
1484
1485    #[test]
1486    fn test_get_entry_missing_returns_err() {
1487        let c = default_cache();
1488        assert_eq!(
1489            c.get_entry("cafecafecafecafe"),
1490            Err(CacheError::EntryNotFound)
1491        );
1492    }
1493
1494    // ── 34: insert after clear works ─────────────────────────────────────────
1495
1496    #[test]
1497    fn test_insert_after_clear() {
1498        let mut c = default_cache();
1499        c.insert(b"before".to_vec(), None, vec![]).unwrap();
1500        c.clear();
1501        let cid = c.insert(b"after ".to_vec(), None, vec![]).unwrap();
1502        assert!(c.contains(&cid));
1503    }
1504
1505    // ── 35: LFU with all equal counts falls back to any entry ────────────────
1506
1507    #[test]
1508    fn test_lfu_equal_counts_evicts_one() {
1509        let mut c = ContentAddressableCache::new(CacheConfig {
1510            max_entries: 2,
1511            max_bytes: 1024 * 1024,
1512            policy: EvictionPolicy::Lfu,
1513            ..Default::default()
1514        });
1515        c.insert(b"lfu_a".to_vec(), None, vec![]).unwrap();
1516        c.insert(b"lfu_b".to_vec(), None, vec![]).unwrap();
1517        // Insert third — one of the two equal-count entries gets evicted.
1518        c.insert(b"lfu_c".to_vec(), None, vec![]).unwrap();
1519        assert_eq!(c.stats().current_entries, 2);
1520    }
1521
1522    // ── 36: bytes freed when entry removed ───────────────────────────────────
1523
1524    #[test]
1525    fn test_bytes_freed_on_remove() {
1526        let mut c = default_cache();
1527        let cid = c.insert(vec![0u8; 100], None, vec![]).unwrap();
1528        assert_eq!(c.stats().current_bytes, 100);
1529        c.remove(&cid).unwrap();
1530        assert_eq!(c.stats().current_bytes, 0);
1531    }
1532
1533    // ── 37: entries freed on expire_ttl ──────────────────────────────────────
1534
1535    #[test]
1536    fn test_bytes_freed_on_expire() {
1537        let mut c = default_cache();
1538        c.insert(vec![0u8; 200], Some(1), vec![]).unwrap();
1539        std::thread::sleep(std::time::Duration::from_millis(5));
1540        c.expire_ttl();
1541        assert_eq!(c.stats().current_bytes, 0);
1542    }
1543
1544    // ── 38: size-weighted with equal sizes falls back to any ─────────────────
1545
1546    #[test]
1547    fn test_size_weighted_equal_sizes() {
1548        let mut c = ContentAddressableCache::new(CacheConfig {
1549            max_entries: 2,
1550            max_bytes: 1024 * 1024,
1551            policy: EvictionPolicy::SizeWeighted,
1552            ..Default::default()
1553        });
1554        c.insert(b"sw_aa".to_vec(), None, vec![]).unwrap();
1555        c.insert(b"sw_bb".to_vec(), None, vec![]).unwrap();
1556        c.insert(b"sw_cc".to_vec(), None, vec![]).unwrap();
1557        assert_eq!(c.stats().current_entries, 2);
1558    }
1559
1560    // ── 39: Byte-limit enforcement with exact fit ─────────────────────────────
1561
1562    #[test]
1563    fn test_byte_limit_exact_fit() {
1564        let limit = 100_usize;
1565        let mut c = ContentAddressableCache::new(CacheConfig {
1566            max_entries: 64,
1567            max_bytes: limit,
1568            ..Default::default()
1569        });
1570        // Insert two 50-byte entries.
1571        c.insert(vec![1u8; 50], None, vec![]).unwrap();
1572        c.insert(vec![2u8; 50], None, vec![]).unwrap();
1573        assert_eq!(c.stats().current_bytes, 100);
1574        // Insert one more 50-byte entry — one existing entry gets evicted.
1575        c.insert(vec![3u8; 50], None, vec![]).unwrap();
1576        assert!(c.stats().current_bytes <= 100);
1577    }
1578
1579    // ── 40: last_accessed updates on get ─────────────────────────────────────
1580
1581    #[test]
1582    fn test_last_accessed_updates_on_get() {
1583        let mut c = default_cache();
1584        let cid = c.insert(b"access_ts".to_vec(), None, vec![]).unwrap();
1585        let before = c.get_entry(&cid).unwrap().last_accessed;
1586        std::thread::sleep(std::time::Duration::from_millis(2));
1587        c.get(&cid).unwrap();
1588        let after = c.get_entry(&cid).unwrap().last_accessed;
1589        assert!(after >= before);
1590    }
1591
1592    // ── 41: inserted_at is set on insert ─────────────────────────────────────
1593
1594    #[test]
1595    fn test_inserted_at_is_set() {
1596        let mut c = default_cache();
1597        let cid = c.insert(b"timestamp".to_vec(), None, vec![]).unwrap();
1598        let entry = c.get_entry(&cid).unwrap();
1599        assert!(entry.inserted_at > 0);
1600    }
1601
1602    // ── 42: Expire TTL on empty cache ────────────────────────────────────────
1603
1604    #[test]
1605    fn test_expire_ttl_empty_cache() {
1606        let mut c = default_cache();
1607        let removed = c.expire_ttl();
1608        assert!(removed.is_empty());
1609    }
1610
1611    // ── 43: remove_by_tag on empty cache ─────────────────────────────────────
1612
1613    #[test]
1614    fn test_remove_by_tag_empty_cache() {
1615        let mut c = default_cache();
1616        assert!(c.remove_by_tag("any_tag").is_empty());
1617    }
1618
1619    // ── 44: TTLFirst with all no-TTL entries falls back to max u64 ────────────
1620
1621    #[test]
1622    fn test_ttl_first_no_ttl_entries() {
1623        let mut c = ContentAddressableCache::new(CacheConfig {
1624            max_entries: 2,
1625            max_bytes: 1024 * 1024,
1626            policy: EvictionPolicy::TtlFirst,
1627            ..Default::default()
1628        });
1629        c.insert(b"no_ttl_x".to_vec(), None, vec![]).unwrap();
1630        c.insert(b"no_ttl_y".to_vec(), None, vec![]).unwrap();
1631        c.insert(b"no_ttl_z".to_vec(), None, vec![]).unwrap();
1632        // Should evict one entry (the one with max remaining TTL = u64::MAX, any of them).
1633        assert_eq!(c.stats().current_entries, 2);
1634    }
1635
1636    // ── 45: CacheError display messages ──────────────────────────────────────
1637
1638    #[test]
1639    fn test_cache_error_display() {
1640        let e = CacheError::EntryNotFound;
1641        assert!(!e.to_string().is_empty());
1642        let e2 = CacheError::EntryTooLarge(999);
1643        assert!(e2.to_string().contains("999"));
1644        let e3 = CacheError::CidMismatch {
1645            expected: "aaa".into(),
1646            got: "bbb".into(),
1647        };
1648        assert!(e3.to_string().contains("aaa"));
1649    }
1650
1651    // ── 46: CacheStats default hit_rate is 0.0 ────────────────────────────────
1652
1653    #[test]
1654    fn test_stats_hit_rate_zero_when_no_lookups() {
1655        let c = default_cache();
1656        assert_eq!(c.stats().hit_rate, 0.0);
1657    }
1658
1659    // ── 47: Arena slot reuse after remove ────────────────────────────────────
1660
1661    #[test]
1662    fn test_lru_arena_slot_reused() {
1663        let mut c = ContentAddressableCache::new(CacheConfig {
1664            max_entries: 4,
1665            max_bytes: 1024 * 1024,
1666            ..Default::default()
1667        });
1668        let a = c.insert(b"arena_a".to_vec(), None, vec![]).unwrap();
1669        c.insert(b"arena_b".to_vec(), None, vec![]).unwrap();
1670        c.remove(&a).unwrap();
1671        // After removing a, its arena slot should be in the free list.
1672        assert!(!c.lru_free.is_empty());
1673        // Inserting another entry should reuse the freed slot.
1674        c.insert(b"arena_c".to_vec(), None, vec![]).unwrap();
1675        assert!(c.lru_free.is_empty() || c.lru_arena.len() <= 3);
1676    }
1677
1678    // ── 48: Stress test with random operations ────────────────────────────────
1679
1680    #[test]
1681    fn test_stress_random_operations() {
1682        let mut c = ContentAddressableCache::new(CacheConfig {
1683            max_entries: 32,
1684            max_bytes: 4096,
1685            policy: EvictionPolicy::Lru,
1686            ..Default::default()
1687        });
1688        let mut rng = Xorshift64::new(999);
1689        let mut cids: Vec<String> = Vec::new();
1690
1691        for _ in 0..200 {
1692            let op = rng.next() % 4;
1693            match op {
1694                0 => {
1695                    // Insert.
1696                    let sz = ((rng.next() % 128) + 1) as usize;
1697                    let data = rng.bytes(sz);
1698                    if let Ok(cid) = c.insert(data, None, vec![]) {
1699                        cids.push(cid);
1700                    }
1701                }
1702                1 => {
1703                    // Get.
1704                    if !cids.is_empty() {
1705                        let idx = (rng.next() as usize) % cids.len();
1706                        let _ = c.get(&cids[idx].clone());
1707                    }
1708                }
1709                2 => {
1710                    // Remove.
1711                    if !cids.is_empty() {
1712                        let idx = (rng.next() as usize) % cids.len();
1713                        let cid = cids.swap_remove(idx);
1714                        let _ = c.remove(&cid);
1715                    }
1716                }
1717                _ => {
1718                    c.expire_ttl();
1719                }
1720            }
1721        }
1722        // Invariant: bytes and entries within bounds.
1723        let s = c.stats();
1724        assert!(s.current_entries <= 32);
1725        assert!(s.current_bytes <= 4096);
1726    }
1727
1728    // ── 49: insert same CID twice preserves single entry ─────────────────────
1729
1730    #[test]
1731    fn test_duplicate_cid_single_entry() {
1732        let mut c = default_cache();
1733        let data = b"unique_content".to_vec();
1734        c.insert(data.clone(), None, vec![]).unwrap();
1735        c.insert(data, None, vec![]).unwrap();
1736        assert_eq!(c.stats().current_entries, 1);
1737    }
1738
1739    // ── 50: Entry size in stats matches data len ──────────────────────────────
1740
1741    #[test]
1742    fn test_entry_size_bytes_matches_data_len() {
1743        let mut c = default_cache();
1744        let data = vec![42u8; 77];
1745        let cid = c.insert(data.clone(), None, vec![]).unwrap();
1746        let entry = c.get_entry(&cid).unwrap();
1747        assert_eq!(entry.size_bytes, data.len());
1748        assert_eq!(c.stats().current_bytes, 77);
1749    }
1750
1751    // ── 51: LRU head/tail consistency after single entry ────────────────────
1752
1753    #[test]
1754    fn test_lru_single_entry_head_equals_tail() {
1755        let mut c = default_cache();
1756        c.insert(b"only_one_entry".to_vec(), None, vec![]).unwrap();
1757        assert!(c.lru_head.is_some());
1758        assert!(c.lru_tail.is_some());
1759        assert_eq!(c.lru_head, c.lru_tail);
1760    }
1761
1762    // ── 52: LRU head/tail cleared after all entries removed ─────────────────
1763
1764    #[test]
1765    fn test_lru_empty_after_clear() {
1766        let mut c = default_cache();
1767        c.insert(b"entry_to_clear".to_vec(), None, vec![]).unwrap();
1768        c.clear();
1769        assert!(c.lru_head.is_none());
1770        assert!(c.lru_tail.is_none());
1771    }
1772
1773    // ── 53: hit_rate is 1.0 when all lookups are hits ─────────────────────────
1774
1775    #[test]
1776    fn test_hit_rate_all_hits() {
1777        let mut c = default_cache();
1778        let cid = c.insert(b"all_hits".to_vec(), None, vec![]).unwrap();
1779        c.get(&cid).unwrap();
1780        c.get(&cid).unwrap();
1781        c.get(&cid).unwrap();
1782        assert!((c.stats().hit_rate - 1.0).abs() < f64::EPSILON);
1783    }
1784
1785    // ── 54: Large number of entries all retrievable ───────────────────────────
1786
1787    #[test]
1788    fn test_many_entries_all_retrievable() {
1789        let mut c = ContentAddressableCache::new(CacheConfig {
1790            max_entries: 512,
1791            max_bytes: 64 * 1024 * 1024,
1792            ..Default::default()
1793        });
1794        let mut rng = Xorshift64::new(7777);
1795        let mut kv: Vec<(String, Vec<u8>)> = Vec::new();
1796        for _ in 0..256 {
1797            let data = rng.bytes(64);
1798            if let Ok(cid) = c.insert(data.clone(), None, vec![]) {
1799                kv.push((cid, data));
1800            }
1801        }
1802        for (cid, data) in &kv {
1803            if c.contains(cid) {
1804                assert_eq!(c.get(cid).unwrap(), data.as_slice());
1805            }
1806        }
1807    }
1808}