Skip to main content

oximedia_cache/
lru_cache.rs

1//! High-performance LRU (Least Recently Used) cache with capacity management.
2//!
3//! This module provides an arena-based doubly-linked-list LRU cache that avoids
4//! heap allocations per node by storing all nodes in a `Vec<Option<LruNode>>`
5//! and threading `prev`/`next` indices through them.  Eviction, insertion, and
6//! lookup are all O(1) amortised.
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// Sentinel index meaning "no node".
12const SENTINEL: usize = usize::MAX;
13
14// ── Internal node ────────────────────────────────────────────────────────────
15
16struct LruNode<K, V> {
17    key: K,
18    value: V,
19    prev: usize,
20    next: usize,
21    /// Approximate byte size of this entry (provided by the caller).
22    size_bytes: usize,
23    /// Number of times this entry has been successfully accessed.
24    access_count: u64,
25    /// Wall-clock time of the most recent access.
26    last_accessed: Instant,
27    /// Optional TTL: the instant at which this entry expires.
28    expires_at: Option<Instant>,
29    /// Whether this entry is pinned (cannot be evicted).
30    pinned: bool,
31}
32
33// ── Public types ─────────────────────────────────────────────────────────────
34
35/// Snapshot of cache statistics.
36#[derive(Debug, Clone)]
37pub struct CacheStats {
38    /// Total number of successful cache lookups.
39    pub hits: u64,
40    /// Total number of failed cache lookups.
41    pub misses: u64,
42    /// Total number of entries evicted to make room for new ones.
43    pub evictions: u64,
44    /// Sum of `size_bytes` for all currently resident entries.
45    pub total_size_bytes: usize,
46    /// Maximum number of entries the cache will hold before evicting.
47    pub capacity: usize,
48    /// Number of entries currently resident in the cache.
49    pub entry_count: usize,
50    /// Number of entries that expired via TTL.
51    pub ttl_expirations: u64,
52    /// Number of pinned entries currently in the cache.
53    pub pinned_count: usize,
54}
55
56/// High-performance LRU cache backed by an arena of `Option<LruNode>` slots.
57///
58/// # Type parameters
59/// * `K` – key type; must implement `Eq + Hash + Clone`.
60/// * `V` – value type.
61pub struct LruCache<K: Eq + std::hash::Hash + Clone, V> {
62    capacity: usize,
63    /// Maps each live key to its slot index inside `slots`.
64    map: HashMap<K, usize>,
65    /// Arena of node slots; `None` slots are free.
66    slots: Vec<Option<LruNode<K, V>>>,
67    /// Index of the most-recently-used node, or `SENTINEL` when empty.
68    head: usize,
69    /// Index of the least-recently-used node, or `SENTINEL` when empty.
70    tail: usize,
71    /// Pool of free slot indices for reuse.
72    free: Vec<usize>,
73    /// Number of live entries currently in the cache.
74    len: usize,
75    // ── stats ──
76    hits: u64,
77    misses: u64,
78    evictions: u64,
79    total_size_bytes: usize,
80    /// Number of entries that expired via TTL lazy eviction.
81    ttl_expirations: u64,
82    /// Default TTL applied to entries inserted without an explicit TTL.
83    default_ttl: Option<Duration>,
84}
85
86// ── Private helpers ───────────────────────────────────────────────────────────
87
88impl<K: Eq + std::hash::Hash + Clone, V> LruCache<K, V> {
89    /// Detach the node at `idx` from the linked list without freeing its slot.
90    fn detach(&mut self, idx: usize) {
91        let (prev, next) = {
92            let node = self.slots[idx].as_ref().expect("detach: slot must be Some");
93            (node.prev, node.next)
94        };
95        if prev != SENTINEL {
96            if let Some(n) = self.slots[prev].as_mut() {
97                n.next = next;
98            }
99        } else {
100            self.head = next;
101        }
102        if next != SENTINEL {
103            if let Some(n) = self.slots[next].as_mut() {
104                n.prev = prev;
105            }
106        } else {
107            self.tail = prev;
108        }
109        if let Some(n) = self.slots[idx].as_mut() {
110            n.prev = SENTINEL;
111            n.next = SENTINEL;
112        }
113    }
114
115    /// Attach the node at `idx` at the MRU head.
116    fn attach_head(&mut self, idx: usize) {
117        let old_head = self.head;
118        if let Some(n) = self.slots[idx].as_mut() {
119            n.prev = SENTINEL;
120            n.next = old_head;
121        }
122        if old_head != SENTINEL {
123            if let Some(n) = self.slots[old_head].as_mut() {
124                n.prev = idx;
125            }
126        } else {
127            // List was empty → this node is also the tail.
128            self.tail = idx;
129        }
130        self.head = idx;
131    }
132
133    /// Acquire a free slot index, recycling from the free list or extending the
134    /// arena.
135    fn alloc_slot(&mut self) -> usize {
136        if let Some(idx) = self.free.pop() {
137            idx
138        } else {
139            let idx = self.slots.len();
140            self.slots.push(None);
141            idx
142        }
143    }
144}
145
146// ── Public impl ───────────────────────────────────────────────────────────────
147
148impl<K: Eq + std::hash::Hash + Clone, V> LruCache<K, V> {
149    /// Create a new `LruCache` with the given entry capacity.
150    pub fn new(capacity: usize) -> Self {
151        Self {
152            capacity,
153            map: HashMap::new(),
154            slots: Vec::new(),
155            head: SENTINEL,
156            tail: SENTINEL,
157            free: Vec::new(),
158            len: 0,
159            hits: 0,
160            misses: 0,
161            evictions: 0,
162            total_size_bytes: 0,
163            ttl_expirations: 0,
164            default_ttl: None,
165        }
166    }
167
168    /// Create a new `LruCache` with a default TTL applied to all entries
169    /// unless overridden by `insert_with_ttl`.
170    pub fn with_default_ttl(capacity: usize, ttl: Duration) -> Self {
171        Self {
172            default_ttl: Some(ttl),
173            ..Self::new(capacity)
174        }
175    }
176
177    /// Look up `key`, move it to the MRU head, and return a shared reference
178    /// to its value.  Records a cache hit/miss in the statistics.
179    ///
180    /// If the entry has a TTL and has expired, it is lazily evicted and `None`
181    /// is returned (counted as a miss).
182    pub fn get(&mut self, key: &K) -> Option<&V> {
183        if let Some(&idx) = self.map.get(key) {
184            // Check TTL expiration lazily on access.
185            let expired = self.slots[idx]
186                .as_ref()
187                .and_then(|n| n.expires_at)
188                .map(|exp| Instant::now() >= exp)
189                .unwrap_or(false);
190            if expired {
191                self.ttl_expirations += 1;
192                self.misses += 1;
193                let key_clone = self.slots[idx].as_ref().map(|n| n.key.clone());
194                if let Some(k) = key_clone {
195                    self.map.remove(&k);
196                }
197                self.detach(idx);
198                if let Some(node) = self.slots[idx].take() {
199                    self.total_size_bytes = self.total_size_bytes.saturating_sub(node.size_bytes);
200                }
201                self.len -= 1;
202                self.free.push(idx);
203                return None;
204            }
205            self.hits += 1;
206            if let Some(node) = self.slots[idx].as_mut() {
207                node.access_count += 1;
208                node.last_accessed = Instant::now();
209            }
210            self.detach(idx);
211            self.attach_head(idx);
212            self.slots[idx].as_ref().map(|n| &n.value)
213        } else {
214            self.misses += 1;
215            None
216        }
217    }
218
219    /// Insert `(key, value)` into the cache.
220    ///
221    /// * If the key already exists, its value is updated and it is promoted to
222    ///   the MRU head.
223    /// * If the cache is at capacity the LRU entry is evicted first.
224    ///
225    /// The default TTL (if configured via `with_default_ttl`) is applied.
226    pub fn insert(&mut self, key: K, value: V, size_bytes: usize) {
227        let expires_at = self.default_ttl.map(|d| Instant::now() + d);
228        self.insert_inner(key, value, size_bytes, expires_at, false);
229    }
230
231    /// Insert `(key, value)` with an explicit TTL duration.
232    ///
233    /// Overrides any default TTL configured on the cache.
234    pub fn insert_with_ttl(&mut self, key: K, value: V, size_bytes: usize, ttl: Duration) {
235        let expires_at = Some(Instant::now() + ttl);
236        self.insert_inner(key, value, size_bytes, expires_at, false);
237    }
238
239    /// Insert a **pinned** entry that will not be evicted by LRU eviction.
240    ///
241    /// Pinned entries remain in the cache until explicitly removed via
242    /// `remove` or `unpin` + subsequent eviction.
243    pub fn insert_pinned(&mut self, key: K, value: V, size_bytes: usize) {
244        let expires_at = self.default_ttl.map(|d| Instant::now() + d);
245        self.insert_inner(key, value, size_bytes, expires_at, true);
246    }
247
248    /// Internal insertion with TTL and pin support.
249    fn insert_inner(
250        &mut self,
251        key: K,
252        value: V,
253        size_bytes: usize,
254        expires_at: Option<Instant>,
255        pinned: bool,
256    ) {
257        if let Some(&idx) = self.map.get(&key) {
258            // Update existing entry.
259            let old_size = self.slots[idx].as_ref().map(|n| n.size_bytes).unwrap_or(0);
260            self.total_size_bytes = self.total_size_bytes.saturating_sub(old_size);
261            if let Some(node) = self.slots[idx].as_mut() {
262                node.value = value;
263                node.size_bytes = size_bytes;
264                node.last_accessed = Instant::now();
265                node.expires_at = expires_at;
266                node.pinned = pinned;
267            }
268            self.total_size_bytes += size_bytes;
269            self.detach(idx);
270            self.attach_head(idx);
271            return;
272        }
273
274        // Evict LRU if at capacity.
275        if self.len == self.capacity {
276            self.evict_lru();
277        }
278
279        let idx = self.alloc_slot();
280        self.slots[idx] = Some(LruNode {
281            key: key.clone(),
282            value,
283            prev: SENTINEL,
284            next: SENTINEL,
285            size_bytes,
286            access_count: 0,
287            last_accessed: Instant::now(),
288            expires_at,
289            pinned,
290        });
291        self.attach_head(idx);
292        self.map.insert(key, idx);
293        self.total_size_bytes += size_bytes;
294        self.len += 1;
295    }
296
297    /// Remove the entry for `key`, returning its value if present.
298    pub fn remove(&mut self, key: &K) -> Option<V> {
299        let idx = self.map.remove(key)?;
300        self.detach(idx);
301        let node = self.slots[idx].take()?;
302        self.total_size_bytes = self.total_size_bytes.saturating_sub(node.size_bytes);
303        self.len -= 1;
304        self.free.push(idx);
305        Some(node.value)
306    }
307
308    /// Returns `true` if the cache contains an entry for `key`.
309    pub fn contains(&self, key: &K) -> bool {
310        self.map.contains_key(key)
311    }
312
313    /// Returns the number of entries currently resident in the cache.
314    pub fn len(&self) -> usize {
315        self.len
316    }
317
318    /// Returns `true` when the cache has no entries.
319    pub fn is_empty(&self) -> bool {
320        self.len == 0
321    }
322
323    /// Return a snapshot of cache statistics.
324    pub fn stats(&self) -> CacheStats {
325        let pinned_count = self
326            .slots
327            .iter()
328            .filter(|s| s.as_ref().map(|n| n.pinned).unwrap_or(false))
329            .count();
330        CacheStats {
331            hits: self.hits,
332            misses: self.misses,
333            evictions: self.evictions,
334            total_size_bytes: self.total_size_bytes,
335            capacity: self.capacity,
336            entry_count: self.len,
337            ttl_expirations: self.ttl_expirations,
338            pinned_count,
339        }
340    }
341
342    /// Return a shared reference to the value for `key` without updating LRU
343    /// order or statistics.
344    pub fn peek(&self, key: &K) -> Option<&V> {
345        self.map
346            .get(key)
347            .and_then(|&idx| self.slots[idx].as_ref())
348            .map(|n| &n.value)
349    }
350
351    /// Manually evict the least-recently-used **unpinned** entry, returning
352    /// `(key, value)`.
353    ///
354    /// Pinned entries are skipped.  Returns `None` if the cache is empty or
355    /// every entry is pinned.
356    pub fn evict_lru(&mut self) -> Option<(K, V)> {
357        if self.tail == SENTINEL {
358            return None;
359        }
360        // Walk from tail (LRU) towards head (MRU) looking for the first
361        // unpinned entry.
362        let mut candidate = self.tail;
363        while candidate != SENTINEL {
364            let is_pinned = self.slots[candidate]
365                .as_ref()
366                .map(|n| n.pinned)
367                .unwrap_or(false);
368            if !is_pinned {
369                break;
370            }
371            candidate = self.slots[candidate]
372                .as_ref()
373                .map(|n| n.prev)
374                .unwrap_or(SENTINEL);
375        }
376        if candidate == SENTINEL {
377            return None;
378        }
379        let key = self.slots[candidate].as_ref()?.key.clone();
380        self.map.remove(&key);
381        self.detach(candidate);
382        let node = self.slots[candidate].take()?;
383        self.total_size_bytes = self.total_size_bytes.saturating_sub(node.size_bytes);
384        self.len -= 1;
385        self.evictions += 1;
386        self.free.push(candidate);
387        Some((key, node.value))
388    }
389
390    // ── TTL helpers ──────────────────────────────────────────────────────────
391
392    /// Set the default TTL for newly inserted entries.
393    pub fn set_default_ttl(&mut self, ttl: Option<Duration>) {
394        self.default_ttl = ttl;
395    }
396
397    /// Eagerly purge all expired entries.  Returns the number of entries
398    /// removed.
399    pub fn purge_expired(&mut self) -> usize {
400        let now = Instant::now();
401        let expired_keys: Vec<K> = self
402            .map
403            .keys()
404            .filter(|k| {
405                self.map
406                    .get(*k)
407                    .and_then(|&idx| self.slots[idx].as_ref().and_then(|n| n.expires_at))
408                    .map(|exp| now >= exp)
409                    .unwrap_or(false)
410            })
411            .cloned()
412            .collect();
413        let count = expired_keys.len();
414        for key in expired_keys {
415            if let Some(idx) = self.map.remove(&key) {
416                self.detach(idx);
417                if let Some(node) = self.slots[idx].take() {
418                    self.total_size_bytes = self.total_size_bytes.saturating_sub(node.size_bytes);
419                }
420                self.len -= 1;
421                self.ttl_expirations += 1;
422                self.free.push(idx);
423            }
424        }
425        count
426    }
427
428    // ── Pinning helpers ──────────────────────────────────────────────────────
429
430    /// Pin an existing entry so it cannot be evicted by LRU eviction.
431    ///
432    /// Returns `true` if the entry was found and pinned.
433    pub fn pin(&mut self, key: &K) -> bool {
434        if let Some(&idx) = self.map.get(key) {
435            if let Some(node) = self.slots[idx].as_mut() {
436                node.pinned = true;
437                return true;
438            }
439        }
440        false
441    }
442
443    /// Unpin an existing entry so it becomes eligible for LRU eviction again.
444    ///
445    /// Returns `true` if the entry was found and unpinned.
446    pub fn unpin(&mut self, key: &K) -> bool {
447        if let Some(&idx) = self.map.get(key) {
448            if let Some(node) = self.slots[idx].as_mut() {
449                node.pinned = false;
450                return true;
451            }
452        }
453        false
454    }
455
456    /// Returns `true` if the entry for `key` is pinned.
457    pub fn is_pinned(&self, key: &K) -> bool {
458        self.map
459            .get(key)
460            .and_then(|&idx| self.slots[idx].as_ref())
461            .map(|n| n.pinned)
462            .unwrap_or(false)
463    }
464
465    // ── Extended TTL helpers ─────────────────────────────────────────────────
466
467    /// Refresh the TTL of an existing entry, resetting its expiration clock.
468    ///
469    /// If the entry has no TTL (neither explicit nor default), this is a no-op
470    /// and returns `false`.  Returns `true` if the TTL was successfully
471    /// refreshed.
472    pub fn refresh_ttl(&mut self, key: &K) -> bool {
473        if let Some(&idx) = self.map.get(key) {
474            if let Some(node) = self.slots[idx].as_mut() {
475                if let Some(old_exp) = node.expires_at {
476                    // Compute the original TTL duration by subtracting the
477                    // insertion/refresh instant from the old expiry.
478                    // Since we only know the last_accessed time, we use the
479                    // default_ttl if available, otherwise estimate from the
480                    // remaining time (conservative: grant the same duration
481                    // the entry was last configured with).
482                    let ttl = if let Some(d) = self.default_ttl {
483                        d
484                    } else {
485                        // Best effort: grant the time between now and old expiry
486                        // if still in the future, otherwise 0.
487                        let now = Instant::now();
488                        if old_exp > now {
489                            old_exp.duration_since(now)
490                        } else {
491                            // Already expired; refresh with a zero-length TTL
492                            // (caller should set a specific TTL instead).
493                            return false;
494                        }
495                    };
496                    node.expires_at = Some(Instant::now() + ttl);
497                    return true;
498                }
499            }
500        }
501        false
502    }
503
504    /// Set an explicit TTL on an existing entry, overriding any previous TTL.
505    ///
506    /// Returns `true` if the entry was found and the TTL was set.
507    pub fn set_entry_ttl(&mut self, key: &K, ttl: Duration) -> bool {
508        if let Some(&idx) = self.map.get(key) {
509            if let Some(node) = self.slots[idx].as_mut() {
510                node.expires_at = Some(Instant::now() + ttl);
511                return true;
512            }
513        }
514        false
515    }
516
517    /// Remove the TTL from an existing entry, making it live indefinitely
518    /// (until evicted by LRU or explicitly removed).
519    ///
520    /// Returns `true` if the entry was found and TTL cleared.
521    pub fn clear_entry_ttl(&mut self, key: &K) -> bool {
522        if let Some(&idx) = self.map.get(key) {
523            if let Some(node) = self.slots[idx].as_mut() {
524                node.expires_at = None;
525                return true;
526            }
527        }
528        false
529    }
530
531    /// Return the remaining TTL for the entry, or `None` if the entry does
532    /// not exist or has no TTL.
533    pub fn remaining_ttl(&self, key: &K) -> Option<Duration> {
534        self.map
535            .get(key)
536            .and_then(|&idx| self.slots[idx].as_ref())
537            .and_then(|n| n.expires_at)
538            .and_then(|exp| {
539                let now = Instant::now();
540                if exp > now {
541                    Some(exp.duration_since(now))
542                } else {
543                    None // already expired
544                }
545            })
546    }
547
548    // ── Extended pinning helpers ─────────────────────────────────────────────
549
550    /// Return the number of pinned entries.
551    pub fn pinned_count(&self) -> usize {
552        self.slots
553            .iter()
554            .filter(|s| s.as_ref().map(|n| n.pinned).unwrap_or(false))
555            .count()
556    }
557
558    /// Unpin all currently pinned entries.  Returns the number of entries
559    /// unpinned.
560    pub fn unpin_all(&mut self) -> usize {
561        let mut count = 0usize;
562        for slot in &mut self.slots {
563            if let Some(node) = slot.as_mut() {
564                if node.pinned {
565                    node.pinned = false;
566                    count += 1;
567                }
568            }
569        }
570        count
571    }
572
573    // ── Capacity management ─────────────────────────────────────────────────
574
575    /// Resize the cache to `new_capacity`, evicting excess entries if
576    /// necessary.
577    ///
578    /// Returns the number of entries evicted.
579    pub fn resize(&mut self, new_capacity: usize) -> usize {
580        let new_cap = new_capacity.max(1);
581        self.capacity = new_cap;
582        let mut evicted = 0usize;
583        while self.len > new_cap {
584            if self.evict_lru().is_some() {
585                evicted += 1;
586            } else {
587                break; // all pinned
588            }
589        }
590        evicted
591    }
592
593    /// Return the current capacity (maximum number of entries).
594    pub fn capacity(&self) -> usize {
595        self.capacity
596    }
597
598    // ── Iteration helpers ───────────────────────────────────────────────────
599
600    /// Return a vector of all keys currently in the cache (in no particular
601    /// order).
602    pub fn keys(&self) -> Vec<K> {
603        self.map.keys().cloned().collect()
604    }
605
606    /// Remove all entries from the cache, resetting statistics.
607    pub fn clear(&mut self) {
608        self.map.clear();
609        self.slots.clear();
610        self.free.clear();
611        self.head = SENTINEL;
612        self.tail = SENTINEL;
613        self.len = 0;
614        self.total_size_bytes = 0;
615    }
616
617    /// Return the access count for a specific entry, or `None` if the key
618    /// does not exist.
619    pub fn access_count(&self, key: &K) -> Option<u64> {
620        self.map
621            .get(key)
622            .and_then(|&idx| self.slots[idx].as_ref())
623            .map(|n| n.access_count)
624    }
625}
626
627// ── Tests ─────────────────────────────────────────────────────────────────────
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    // 1. Basic insertion and retrieval
634    #[test]
635    fn test_insert_and_get() {
636        let mut cache: LruCache<&str, i32> = LruCache::new(4);
637        cache.insert("a", 1, 10);
638        cache.insert("b", 2, 20);
639        assert_eq!(cache.get(&"a"), Some(&1));
640        assert_eq!(cache.get(&"b"), Some(&2));
641    }
642
643    // 2. Miss on absent key
644    #[test]
645    fn test_miss_on_absent_key() {
646        let mut cache: LruCache<&str, i32> = LruCache::new(4);
647        cache.insert("a", 1, 10);
648        assert_eq!(cache.get(&"z"), None);
649    }
650
651    // 3. LRU eviction when capacity is exceeded
652    #[test]
653    fn test_lru_eviction() {
654        let mut cache: LruCache<i32, &str> = LruCache::new(3);
655        cache.insert(1, "one", 1);
656        cache.insert(2, "two", 1);
657        cache.insert(3, "three", 1);
658        // Access key 1 → moves to MRU head; key 2 becomes LRU tail
659        cache.get(&1);
660        // Insert key 4 → should evict key 2 (LRU)
661        cache.insert(4, "four", 1);
662        assert!(!cache.contains(&2), "key 2 should have been evicted");
663        assert!(cache.contains(&1));
664        assert!(cache.contains(&3));
665        assert!(cache.contains(&4));
666    }
667
668    // 4. len and is_empty
669    #[test]
670    fn test_len_and_is_empty() {
671        let mut cache: LruCache<u32, u32> = LruCache::new(5);
672        assert!(cache.is_empty());
673        cache.insert(1, 100, 8);
674        cache.insert(2, 200, 8);
675        assert_eq!(cache.len(), 2);
676        assert!(!cache.is_empty());
677    }
678
679    // 5. remove
680    #[test]
681    fn test_remove() {
682        let mut cache: LruCache<&str, u64> = LruCache::new(4);
683        cache.insert("x", 42, 8);
684        let removed = cache.remove(&"x");
685        assert_eq!(removed, Some(42));
686        assert!(!cache.contains(&"x"));
687        assert_eq!(cache.len(), 0);
688    }
689
690    // 6. peek does not affect LRU order
691    #[test]
692    fn test_peek_no_side_effects() {
693        let mut cache: LruCache<i32, i32> = LruCache::new(3);
694        cache.insert(1, 10, 1);
695        cache.insert(2, 20, 1);
696        cache.insert(3, 30, 1);
697        // peek at the LRU tail (key 1) — should not promote it
698        let _ = cache.peek(&1);
699        // Insert a 4th entry → should still evict key 1 (still LRU tail)
700        cache.insert(4, 40, 1);
701        assert!(!cache.contains(&1));
702    }
703
704    // 7. Manual evict_lru
705    #[test]
706    fn test_evict_lru_manual() {
707        let mut cache: LruCache<&str, i32> = LruCache::new(4);
708        cache.insert("a", 1, 1);
709        cache.insert("b", 2, 1);
710        cache.insert("c", 3, 1);
711        let evicted = cache.evict_lru();
712        assert!(evicted.is_some());
713        let (k, _v) = evicted.expect("eviction should succeed");
714        assert_eq!(
715            k, "a",
716            "oldest-inserted key should be evicted when nothing was accessed"
717        );
718    }
719
720    // 8. Hit / miss counters
721    #[test]
722    fn test_stats_hit_miss() {
723        let mut cache: LruCache<i32, i32> = LruCache::new(4);
724        cache.insert(1, 10, 8);
725        cache.get(&1);
726        cache.get(&1);
727        cache.get(&99); // miss
728        let s = cache.stats();
729        assert_eq!(s.hits, 2);
730        assert_eq!(s.misses, 1);
731    }
732
733    // 9. Eviction counter
734    #[test]
735    fn test_stats_evictions() {
736        let mut cache: LruCache<i32, i32> = LruCache::new(2);
737        cache.insert(1, 1, 1);
738        cache.insert(2, 2, 1);
739        cache.insert(3, 3, 1); // evicts 1
740        cache.insert(4, 4, 1); // evicts 2
741        assert_eq!(cache.stats().evictions, 2);
742    }
743
744    // 10. total_size_bytes tracking
745    #[test]
746    fn test_total_size_bytes() {
747        let mut cache: LruCache<i32, i32> = LruCache::new(10);
748        cache.insert(1, 1, 100);
749        cache.insert(2, 2, 200);
750        assert_eq!(cache.stats().total_size_bytes, 300);
751        cache.remove(&1);
752        assert_eq!(cache.stats().total_size_bytes, 200);
753    }
754
755    // 11. Update existing key preserves size tracking
756    #[test]
757    fn test_update_existing_key() {
758        let mut cache: LruCache<i32, i32> = LruCache::new(4);
759        cache.insert(1, 10, 100);
760        cache.insert(1, 20, 50); // update
761        assert_eq!(cache.get(&1), Some(&20));
762        assert_eq!(cache.stats().total_size_bytes, 50);
763        assert_eq!(cache.len(), 1);
764    }
765
766    // 12. contains
767    #[test]
768    fn test_contains() {
769        let mut cache: LruCache<&str, i32> = LruCache::new(4);
770        cache.insert("hello", 1, 5);
771        assert!(cache.contains(&"hello"));
772        assert!(!cache.contains(&"world"));
773    }
774
775    // 13. evict_lru on empty cache returns None
776    #[test]
777    fn test_evict_lru_empty() {
778        let mut cache: LruCache<i32, i32> = LruCache::new(4);
779        assert_eq!(cache.evict_lru(), None);
780    }
781
782    // 14. Capacity reported in stats
783    #[test]
784    fn test_stats_capacity() {
785        let cache: LruCache<i32, i32> = LruCache::new(7);
786        assert_eq!(cache.stats().capacity, 7);
787        assert_eq!(cache.stats().entry_count, 0);
788    }
789
790    // 15. Large sequential workload – evictions keep len at capacity
791    #[test]
792    fn test_large_sequential_workload() {
793        let cap = 10usize;
794        let mut cache: LruCache<usize, usize> = LruCache::new(cap);
795        for i in 0..100 {
796            cache.insert(i, i * 2, 1);
797        }
798        assert_eq!(cache.len(), cap);
799        // The last `cap` keys should all be present.
800        for i in (100 - cap)..100 {
801            assert!(cache.contains(&i), "key {i} should be present");
802        }
803    }
804
805    // ── TTL tests ────────────────────────────────────────────────────────────
806
807    // 16. TTL expiration on get (lazy eviction)
808    #[test]
809    fn test_ttl_expired_entry_returns_none() {
810        let mut cache: LruCache<&str, i32> = LruCache::new(4);
811        // Insert with a TTL of 0ms (already expired on next access).
812        cache.insert_with_ttl("ephemeral", 42, 8, Duration::from_millis(0));
813        // Small sleep to ensure expiry.
814        std::thread::sleep(Duration::from_millis(2));
815        assert_eq!(
816            cache.get(&"ephemeral"),
817            None,
818            "expired entry should be gone"
819        );
820        assert_eq!(cache.len(), 0);
821        assert_eq!(cache.stats().ttl_expirations, 1);
822    }
823
824    // 17. Non-expired TTL entry is still accessible
825    #[test]
826    fn test_ttl_non_expired_entry() {
827        let mut cache: LruCache<&str, i32> = LruCache::new(4);
828        cache.insert_with_ttl("long_lived", 99, 8, Duration::from_secs(3600));
829        assert_eq!(cache.get(&"long_lived"), Some(&99));
830    }
831
832    // 18. Default TTL applied to all entries
833    #[test]
834    fn test_default_ttl() {
835        let mut cache: LruCache<&str, i32> =
836            LruCache::with_default_ttl(4, Duration::from_millis(0));
837        cache.insert("a", 1, 8);
838        std::thread::sleep(Duration::from_millis(2));
839        assert_eq!(cache.get(&"a"), None, "default TTL should expire entry");
840    }
841
842    // 19. purge_expired clears expired entries eagerly
843    #[test]
844    fn test_purge_expired() {
845        let mut cache: LruCache<&str, i32> = LruCache::new(4);
846        cache.insert_with_ttl("x", 1, 8, Duration::from_millis(0));
847        cache.insert("y", 2, 8); // no TTL
848        std::thread::sleep(Duration::from_millis(2));
849        let purged = cache.purge_expired();
850        assert_eq!(purged, 1);
851        assert_eq!(cache.len(), 1);
852        assert!(cache.contains(&"y"));
853    }
854
855    // 20. set_default_ttl can change the default
856    #[test]
857    fn test_set_default_ttl() {
858        let mut cache: LruCache<&str, i32> = LruCache::new(4);
859        cache.set_default_ttl(Some(Duration::from_secs(3600)));
860        cache.insert("a", 1, 8);
861        assert_eq!(cache.get(&"a"), Some(&1));
862        cache.set_default_ttl(None);
863        cache.insert("b", 2, 8);
864        assert_eq!(cache.get(&"b"), Some(&2));
865    }
866
867    // ── Pinning tests ────────────────────────────────────────────────────────
868
869    // 21. Pinned entry survives eviction
870    #[test]
871    fn test_pinned_entry_survives_eviction() {
872        let mut cache: LruCache<i32, &str> = LruCache::new(3);
873        cache.insert_pinned(1, "pinned", 1);
874        cache.insert(2, "two", 1);
875        cache.insert(3, "three", 1);
876        // Key 1 is LRU but pinned → eviction should skip it and evict key 2.
877        cache.insert(4, "four", 1);
878        assert!(cache.contains(&1), "pinned entry should survive");
879        assert!(!cache.contains(&2), "unpinned LRU should be evicted");
880    }
881
882    // 22. insert_pinned sets pinned flag
883    #[test]
884    fn test_insert_pinned() {
885        let mut cache: LruCache<&str, i32> = LruCache::new(4);
886        cache.insert_pinned("critical", 99, 8);
887        assert!(cache.is_pinned(&"critical"));
888        assert_eq!(cache.stats().pinned_count, 1);
889    }
890
891    // 23. pin / unpin existing entries
892    #[test]
893    fn test_pin_and_unpin() {
894        let mut cache: LruCache<&str, i32> = LruCache::new(4);
895        cache.insert("x", 1, 8);
896        assert!(!cache.is_pinned(&"x"));
897        assert!(cache.pin(&"x"));
898        assert!(cache.is_pinned(&"x"));
899        assert!(cache.unpin(&"x"));
900        assert!(!cache.is_pinned(&"x"));
901    }
902
903    // 24. pin absent key returns false
904    #[test]
905    fn test_pin_absent() {
906        let mut cache: LruCache<&str, i32> = LruCache::new(4);
907        assert!(!cache.pin(&"ghost"));
908    }
909
910    // 25. All entries pinned → evict_lru returns None
911    #[test]
912    fn test_all_pinned_evict_returns_none() {
913        let mut cache: LruCache<i32, i32> = LruCache::new(3);
914        cache.insert_pinned(1, 10, 1);
915        cache.insert_pinned(2, 20, 1);
916        cache.insert_pinned(3, 30, 1);
917        assert_eq!(cache.evict_lru(), None);
918    }
919
920    // 26. Pinned entry can still be explicitly removed
921    #[test]
922    fn test_pinned_entry_can_be_removed() {
923        let mut cache: LruCache<&str, i32> = LruCache::new(4);
924        cache.insert_pinned("keep", 42, 8);
925        let removed = cache.remove(&"keep");
926        assert_eq!(removed, Some(42));
927        assert_eq!(cache.len(), 0);
928    }
929
930    // ── Extended TTL tests ──────────────────────────────────────────────────
931
932    // 27. refresh_ttl extends the expiration
933    #[test]
934    fn test_refresh_ttl() {
935        let mut cache: LruCache<&str, i32> =
936            LruCache::with_default_ttl(4, Duration::from_secs(3600));
937        cache.insert("a", 1, 8);
938        assert!(cache.refresh_ttl(&"a"));
939        // Entry should still be accessible.
940        assert_eq!(cache.get(&"a"), Some(&1));
941    }
942
943    // 28. refresh_ttl returns false for absent key
944    #[test]
945    fn test_refresh_ttl_absent() {
946        let mut cache: LruCache<&str, i32> = LruCache::new(4);
947        assert!(!cache.refresh_ttl(&"ghost"));
948    }
949
950    // 29. refresh_ttl returns false for entry without TTL
951    #[test]
952    fn test_refresh_ttl_no_ttl() {
953        let mut cache: LruCache<&str, i32> = LruCache::new(4);
954        cache.insert("a", 1, 8);
955        assert!(!cache.refresh_ttl(&"a"));
956    }
957
958    // 30. set_entry_ttl overrides existing TTL
959    #[test]
960    fn test_set_entry_ttl() {
961        let mut cache: LruCache<&str, i32> = LruCache::new(4);
962        cache.insert("a", 1, 8);
963        assert!(cache.set_entry_ttl(&"a", Duration::from_millis(0)));
964        std::thread::sleep(Duration::from_millis(2));
965        assert_eq!(cache.get(&"a"), None, "entry should expire with new TTL");
966    }
967
968    // 31. set_entry_ttl on absent key returns false
969    #[test]
970    fn test_set_entry_ttl_absent() {
971        let mut cache: LruCache<&str, i32> = LruCache::new(4);
972        assert!(!cache.set_entry_ttl(&"nope", Duration::from_secs(60)));
973    }
974
975    // 32. clear_entry_ttl removes TTL
976    #[test]
977    fn test_clear_entry_ttl() {
978        let mut cache: LruCache<&str, i32> =
979            LruCache::with_default_ttl(4, Duration::from_millis(1));
980        cache.insert("a", 1, 8);
981        assert!(cache.clear_entry_ttl(&"a"));
982        std::thread::sleep(Duration::from_millis(5));
983        // Should still be accessible since TTL was cleared.
984        assert_eq!(cache.get(&"a"), Some(&1));
985    }
986
987    // 33. remaining_ttl returns correct duration
988    #[test]
989    fn test_remaining_ttl() {
990        let mut cache: LruCache<&str, i32> = LruCache::new(4);
991        cache.insert_with_ttl("a", 1, 8, Duration::from_secs(3600));
992        let remaining = cache.remaining_ttl(&"a");
993        assert!(remaining.is_some());
994        let r = remaining.expect("should have remaining TTL");
995        assert!(r.as_secs() > 3590, "remaining TTL should be close to 3600s");
996    }
997
998    // 34. remaining_ttl returns None for expired entry
999    #[test]
1000    fn test_remaining_ttl_expired() {
1001        let mut cache: LruCache<&str, i32> = LruCache::new(4);
1002        cache.insert_with_ttl("a", 1, 8, Duration::from_millis(0));
1003        std::thread::sleep(Duration::from_millis(2));
1004        assert!(cache.remaining_ttl(&"a").is_none());
1005    }
1006
1007    // 35. remaining_ttl returns None for entry without TTL
1008    #[test]
1009    fn test_remaining_ttl_no_ttl() {
1010        let mut cache: LruCache<&str, i32> = LruCache::new(4);
1011        cache.insert("a", 1, 8);
1012        assert!(cache.remaining_ttl(&"a").is_none());
1013    }
1014
1015    // ── Extended pinning tests ──────────────────────────────────────────────
1016
1017    // 36. pinned_count tracks correctly
1018    #[test]
1019    fn test_pinned_count() {
1020        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1021        cache.insert_pinned(1, 10, 1);
1022        cache.insert_pinned(2, 20, 1);
1023        cache.insert(3, 30, 1);
1024        assert_eq!(cache.pinned_count(), 2);
1025    }
1026
1027    // 37. unpin_all clears all pins
1028    #[test]
1029    fn test_unpin_all() {
1030        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1031        cache.insert_pinned(1, 10, 1);
1032        cache.insert_pinned(2, 20, 1);
1033        cache.insert_pinned(3, 30, 1);
1034        let unpinned = cache.unpin_all();
1035        assert_eq!(unpinned, 3);
1036        assert_eq!(cache.pinned_count(), 0);
1037    }
1038
1039    // 38. unpin_all on cache with no pins returns 0
1040    #[test]
1041    fn test_unpin_all_none_pinned() {
1042        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1043        cache.insert(1, 10, 1);
1044        assert_eq!(cache.unpin_all(), 0);
1045    }
1046
1047    // 39. After unpin_all, entries can be evicted
1048    #[test]
1049    fn test_unpin_all_allows_eviction() {
1050        let mut cache: LruCache<i32, i32> = LruCache::new(3);
1051        cache.insert_pinned(1, 10, 1);
1052        cache.insert_pinned(2, 20, 1);
1053        cache.insert_pinned(3, 30, 1);
1054        assert_eq!(cache.evict_lru(), None); // all pinned
1055        cache.unpin_all();
1056        assert!(cache.evict_lru().is_some());
1057    }
1058
1059    // ── Capacity management tests ───────────────────────────────────────────
1060
1061    // 40. resize shrinks cache
1062    #[test]
1063    fn test_resize_shrink() {
1064        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1065        for i in 0..10 {
1066            cache.insert(i, i * 10, 1);
1067        }
1068        let evicted = cache.resize(5);
1069        assert_eq!(evicted, 5);
1070        assert_eq!(cache.len(), 5);
1071        assert_eq!(cache.capacity(), 5);
1072    }
1073
1074    // 41. resize grows cache (no evictions)
1075    #[test]
1076    fn test_resize_grow() {
1077        let mut cache: LruCache<i32, i32> = LruCache::new(5);
1078        for i in 0..5 {
1079            cache.insert(i, i, 1);
1080        }
1081        let evicted = cache.resize(20);
1082        assert_eq!(evicted, 0);
1083        assert_eq!(cache.len(), 5);
1084        assert_eq!(cache.capacity(), 20);
1085    }
1086
1087    // 42. resize respects pinned entries
1088    #[test]
1089    fn test_resize_with_pinned() {
1090        let mut cache: LruCache<i32, i32> = LruCache::new(5);
1091        cache.insert_pinned(1, 10, 1);
1092        cache.insert(2, 20, 1);
1093        cache.insert(3, 30, 1);
1094        cache.insert(4, 40, 1);
1095        cache.insert(5, 50, 1);
1096        let evicted = cache.resize(2);
1097        // Should evict unpinned entries but not pinned
1098        assert!(evicted >= 3);
1099        assert!(cache.contains(&1), "pinned entry should survive");
1100    }
1101
1102    // ── keys / clear / access_count tests ───────────────────────────────────
1103
1104    // 43. keys returns all keys
1105    #[test]
1106    fn test_keys() {
1107        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1108        cache.insert(1, 10, 1);
1109        cache.insert(2, 20, 1);
1110        cache.insert(3, 30, 1);
1111        let mut keys = cache.keys();
1112        keys.sort();
1113        assert_eq!(keys, vec![1, 2, 3]);
1114    }
1115
1116    // 44. clear resets the cache
1117    #[test]
1118    fn test_clear() {
1119        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1120        cache.insert(1, 10, 100);
1121        cache.insert(2, 20, 200);
1122        cache.get(&1);
1123        cache.clear();
1124        assert!(cache.is_empty());
1125        assert_eq!(cache.stats().total_size_bytes, 0);
1126        assert_eq!(cache.len(), 0);
1127    }
1128
1129    // 45. access_count returns correct count
1130    #[test]
1131    fn test_access_count() {
1132        let mut cache: LruCache<&str, i32> = LruCache::new(4);
1133        cache.insert("a", 1, 8);
1134        cache.get(&"a");
1135        cache.get(&"a");
1136        cache.get(&"a");
1137        assert_eq!(cache.access_count(&"a"), Some(3));
1138    }
1139
1140    // 46. access_count returns None for absent key
1141    #[test]
1142    fn test_access_count_absent() {
1143        let cache: LruCache<&str, i32> = LruCache::new(4);
1144        assert_eq!(cache.access_count(&"ghost"), None);
1145    }
1146
1147    // 47. TTL expiration increments ttl_expirations stat
1148    #[test]
1149    fn test_ttl_stats_counter() {
1150        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1151        cache.insert_with_ttl(1, 10, 8, Duration::from_millis(0));
1152        cache.insert_with_ttl(2, 20, 8, Duration::from_millis(0));
1153        std::thread::sleep(Duration::from_millis(5));
1154        cache.get(&1); // triggers expiration
1155        cache.get(&2); // triggers expiration
1156        assert_eq!(cache.stats().ttl_expirations, 2);
1157    }
1158
1159    // 48. pinned entry with TTL still expires
1160    #[test]
1161    fn test_pinned_entry_with_ttl_expires() {
1162        let mut cache: LruCache<&str, i32> = LruCache::new(4);
1163        cache.insert_with_ttl("pinned_ttl", 42, 8, Duration::from_millis(0));
1164        cache.pin(&"pinned_ttl");
1165        std::thread::sleep(Duration::from_millis(5));
1166        // TTL expiration is checked on get, regardless of pin status
1167        assert_eq!(cache.get(&"pinned_ttl"), None);
1168    }
1169
1170    // 49. Large workload with mixed TTL and pinning
1171    #[test]
1172    fn test_mixed_ttl_and_pinning_workload() {
1173        let mut cache: LruCache<i32, i32> = LruCache::new(20);
1174        // Insert pinned entries
1175        for i in 0..5 {
1176            cache.insert_pinned(i, i * 100, 10);
1177        }
1178        // Insert TTL entries
1179        for i in 5..15 {
1180            cache.insert_with_ttl(i, i * 100, 10, Duration::from_secs(3600));
1181        }
1182        // Insert normal entries
1183        for i in 15..20 {
1184            cache.insert(i, i * 100, 10);
1185        }
1186        assert_eq!(cache.len(), 20);
1187        assert_eq!(cache.pinned_count(), 5);
1188
1189        // Overflow: insert 10 more to trigger evictions
1190        for i in 100..110 {
1191            cache.insert(i, i, 10);
1192        }
1193        // Pinned entries should survive
1194        for i in 0..5 {
1195            assert!(cache.contains(&i), "pinned entry {i} should survive");
1196        }
1197    }
1198
1199    // 50. purge_expired with mixed entries
1200    #[test]
1201    fn test_purge_expired_mixed() {
1202        let mut cache: LruCache<i32, i32> = LruCache::new(10);
1203        cache.insert_with_ttl(1, 10, 8, Duration::from_millis(0)); // will expire
1204        cache.insert_with_ttl(2, 20, 8, Duration::from_secs(3600)); // won't expire
1205        cache.insert(3, 30, 8); // no TTL
1206        std::thread::sleep(Duration::from_millis(5));
1207        let purged = cache.purge_expired();
1208        assert_eq!(purged, 1);
1209        assert_eq!(cache.len(), 2);
1210        assert!(cache.contains(&2));
1211        assert!(cache.contains(&3));
1212    }
1213}