1#![allow(dead_code)]
8#![allow(missing_docs)]
9#![allow(clippy::cast_precision_loss)]
10
11#[derive(Debug, Clone, PartialEq)]
17pub struct CacheEntry {
18 pub path: String,
20 pub size_bytes: u64,
22 pub last_access_ms: u64,
24 pub hit_count: u32,
26}
27
28impl CacheEntry {
29 #[must_use]
31 pub fn new(path: impl Into<String>, size_bytes: u64, now_ms: u64) -> Self {
32 Self {
33 path: path.into(),
34 size_bytes,
35 last_access_ms: now_ms,
36 hit_count: 1,
37 }
38 }
39
40 #[must_use]
46 pub fn is_stale(&self, now_ms: u64, ttl_ms: u64) -> bool {
47 now_ms.saturating_sub(self.last_access_ms) > ttl_ms
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CachePolicy {
58 Lru,
60 Ttl,
62 Lfu,
64}
65
66impl CachePolicy {
67 #[must_use]
69 pub fn description(&self) -> &str {
70 match self {
71 Self::Lru => "Least Recently Used (LRU): evict the entry not accessed longest",
72 Self::Ttl => "Time To Live (TTL): evict entries older than a configured threshold",
73 Self::Lfu => "Least Frequently Used (LFU): evict the entry with the fewest accesses",
74 }
75 }
76}
77
78#[derive(Debug)]
84pub struct ProxyCache {
85 pub entries: Vec<CacheEntry>,
87 pub max_size_bytes: u64,
89 pub used_bytes: u64,
91}
92
93impl ProxyCache {
94 #[must_use]
96 pub fn new(max_size_bytes: u64) -> Self {
97 Self {
98 entries: Vec::new(),
99 max_size_bytes,
100 used_bytes: 0,
101 }
102 }
103
104 pub fn add(&mut self, path: &str, size: u64, now_ms: u64) {
109 if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
111 let old_size = self.entries[pos].size_bytes;
112 self.entries.remove(pos);
113 self.used_bytes = self.used_bytes.saturating_sub(old_size);
114 }
115 self.entries.push(CacheEntry::new(path, size, now_ms));
116 self.used_bytes += size;
117 }
118
119 pub fn touch(&mut self, path: &str, now_ms: u64) -> bool {
123 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
124 entry.last_access_ms = now_ms;
125 entry.hit_count = entry.hit_count.saturating_add(1);
126 true
127 } else {
128 false
129 }
130 }
131
132 pub fn evict_lru(&mut self) -> Option<String> {
136 if self.entries.is_empty() {
137 return None;
138 }
139 let idx = self
141 .entries
142 .iter()
143 .enumerate()
144 .min_by_key(|(_, e)| e.last_access_ms)
145 .map(|(i, _)| i)?;
146
147 let removed = self.entries.remove(idx);
148 self.used_bytes = self.used_bytes.saturating_sub(removed.size_bytes);
149 Some(removed.path)
150 }
151
152 pub fn evict_stale(&mut self, now_ms: u64, ttl_ms: u64) -> Vec<String> {
156 let mut evicted = Vec::new();
157 self.entries.retain(|e| {
158 if e.is_stale(now_ms, ttl_ms) {
159 evicted.push(e.path.clone());
160 false
161 } else {
162 true
163 }
164 });
165 self.used_bytes = self.entries.iter().map(|e| e.size_bytes).sum();
168 evicted
169 }
170
171 #[must_use]
175 pub fn utilization(&self) -> f64 {
176 if self.max_size_bytes == 0 {
177 return 0.0;
178 }
179 (self.used_bytes as f64 / self.max_size_bytes as f64).min(1.0)
180 }
181}
182
183#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_cache_entry_is_stale_fresh() {
193 let entry = CacheEntry::new("p.mp4", 1024, 1_000);
194 assert!(!entry.is_stale(2_000, 5_000));
196 }
197
198 #[test]
199 fn test_cache_entry_is_stale_expired() {
200 let entry = CacheEntry::new("p.mp4", 1024, 0);
201 assert!(entry.is_stale(1_000, 500));
203 }
204
205 #[test]
206 fn test_cache_entry_is_stale_exactly_at_ttl() {
207 let entry = CacheEntry::new("p.mp4", 1024, 0);
208 assert!(!entry.is_stale(500, 500));
210 }
211
212 #[test]
213 fn test_cache_policy_descriptions_non_empty() {
214 assert!(!CachePolicy::Lru.description().is_empty());
215 assert!(!CachePolicy::Ttl.description().is_empty());
216 assert!(!CachePolicy::Lfu.description().is_empty());
217 }
218
219 #[test]
220 fn test_proxy_cache_add_single() {
221 let mut cache = ProxyCache::new(1_000_000);
222 cache.add("a.mp4", 100, 1_000);
223 assert_eq!(cache.entries.len(), 1);
224 assert_eq!(cache.used_bytes, 100);
225 }
226
227 #[test]
228 fn test_proxy_cache_add_replaces_existing() {
229 let mut cache = ProxyCache::new(1_000_000);
230 cache.add("a.mp4", 100, 1_000);
231 cache.add("a.mp4", 200, 2_000);
232 assert_eq!(cache.entries.len(), 1);
233 assert_eq!(cache.used_bytes, 200);
234 }
235
236 #[test]
237 fn test_proxy_cache_touch_updates_access() {
238 let mut cache = ProxyCache::new(1_000_000);
239 cache.add("a.mp4", 100, 1_000);
240 let updated = cache.touch("a.mp4", 5_000);
241 assert!(updated);
242 assert_eq!(cache.entries[0].last_access_ms, 5_000);
243 assert_eq!(cache.entries[0].hit_count, 2);
244 }
245
246 #[test]
247 fn test_proxy_cache_touch_missing_returns_false() {
248 let mut cache = ProxyCache::new(1_000_000);
249 assert!(!cache.touch("missing.mp4", 1_000));
250 }
251
252 #[test]
253 fn test_proxy_cache_evict_lru_empty() {
254 let mut cache = ProxyCache::new(1_000_000);
255 assert!(cache.evict_lru().is_none());
256 }
257
258 #[test]
259 fn test_proxy_cache_evict_lru_removes_oldest() {
260 let mut cache = ProxyCache::new(1_000_000);
261 cache.add("old.mp4", 100, 1_000);
262 cache.add("new.mp4", 100, 9_000);
263 let evicted = cache.evict_lru();
264 assert_eq!(evicted, Some("old.mp4".to_string()));
265 assert_eq!(cache.entries.len(), 1);
266 }
267
268 #[test]
269 fn test_proxy_cache_evict_stale() {
270 let mut cache = ProxyCache::new(1_000_000);
271 cache.add("stale.mp4", 100, 0);
272 cache.add("fresh.mp4", 200, 9_000);
273 let evicted = cache.evict_stale(10_000, 5_000);
274 assert_eq!(evicted.len(), 1);
275 assert_eq!(evicted[0], "stale.mp4");
276 assert_eq!(cache.used_bytes, 200);
277 }
278
279 #[test]
280 fn test_proxy_cache_utilization() {
281 let mut cache = ProxyCache::new(1_000);
282 cache.add("a.mp4", 500, 0);
283 let u = cache.utilization();
284 assert!((u - 0.5).abs() < 1e-9);
285 }
286
287 #[test]
288 fn test_proxy_cache_utilization_zero_max() {
289 let cache = ProxyCache::new(0);
290 assert_eq!(cache.utilization(), 0.0);
291 }
292}
293
294#[derive(Debug, Clone)]
300pub struct DiskCacheStats {
301 pub entry_count: usize,
303 pub used_bytes: u64,
305 pub max_bytes: u64,
307 pub utilization: f64,
309 pub eviction_count: u64,
311 pub insertion_count: u64,
313 pub hit_count: u64,
315 pub miss_count: u64,
317}
318
319#[derive(Debug)]
328pub struct DiskBoundedCache {
329 entries: std::collections::VecDeque<CacheEntry>,
331 max_bytes: u64,
333 used_bytes: u64,
335 eviction_count: u64,
337 insertion_count: u64,
339 hit_count: u64,
341 miss_count: u64,
343}
344
345impl DiskBoundedCache {
346 pub fn new(max_bytes: u64) -> Result<Self, String> {
352 if max_bytes == 0 {
353 return Err("DiskBoundedCache: max_bytes must be > 0".to_string());
354 }
355 Ok(Self {
356 entries: std::collections::VecDeque::new(),
357 max_bytes,
358 used_bytes: 0,
359 eviction_count: 0,
360 insertion_count: 0,
361 hit_count: 0,
362 miss_count: 0,
363 })
364 }
365
366 pub fn insert(&mut self, path: &str, size_bytes: u64, now_ms: u64) -> bool {
375 if size_bytes > self.max_bytes {
376 return false;
377 }
378
379 if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
381 let old_size = self.entries[pos].size_bytes;
382 self.entries.remove(pos);
383 self.used_bytes = self.used_bytes.saturating_sub(old_size);
384 }
385
386 while !self.entries.is_empty() && self.used_bytes + size_bytes > self.max_bytes {
388 if let Some(evicted) = self.entries.pop_front() {
390 self.used_bytes = self.used_bytes.saturating_sub(evicted.size_bytes);
391 self.eviction_count += 1;
392 }
393 }
394
395 self.entries
397 .push_back(CacheEntry::new(path, size_bytes, now_ms));
398 self.used_bytes += size_bytes;
399 self.insertion_count += 1;
400 true
401 }
402
403 pub fn access(&mut self, path: &str, now_ms: u64) -> Option<CacheEntry> {
410 if let Some(pos) = self.entries.iter().position(|e| e.path == path) {
411 if let Some(mut entry) = self.entries.remove(pos) {
413 entry.last_access_ms = now_ms;
414 entry.hit_count = entry.hit_count.saturating_add(1);
415 let clone = entry.clone();
416 self.entries.push_back(entry);
417 self.hit_count += 1;
418 Some(clone)
419 } else {
420 self.miss_count += 1;
422 None
423 }
424 } else {
425 self.miss_count += 1;
426 None
427 }
428 }
429
430 pub fn evict_lru(&mut self) -> Option<String> {
434 self.entries.pop_front().map(|e| {
435 self.used_bytes = self.used_bytes.saturating_sub(e.size_bytes);
436 self.eviction_count += 1;
437 e.path
438 })
439 }
440
441 pub fn clear(&mut self) -> Vec<String> {
443 let paths: Vec<String> = self.entries.iter().map(|e| e.path.clone()).collect();
444 self.entries.clear();
445 self.used_bytes = 0;
446 self.eviction_count += paths.len() as u64;
447 paths
448 }
449
450 pub fn contains(&self, path: &str) -> bool {
452 self.entries.iter().any(|e| e.path == path)
453 }
454
455 pub fn len(&self) -> usize {
457 self.entries.len()
458 }
459
460 pub fn is_empty(&self) -> bool {
462 self.entries.is_empty()
463 }
464
465 pub fn used_bytes(&self) -> u64 {
467 self.used_bytes
468 }
469
470 pub fn max_bytes(&self) -> u64 {
472 self.max_bytes
473 }
474
475 pub fn utilization(&self) -> f64 {
477 (self.used_bytes as f64 / self.max_bytes as f64).min(1.0)
478 }
479
480 pub fn stats(&self) -> DiskCacheStats {
482 DiskCacheStats {
483 entry_count: self.entries.len(),
484 used_bytes: self.used_bytes,
485 max_bytes: self.max_bytes,
486 utilization: self.utilization(),
487 eviction_count: self.eviction_count,
488 insertion_count: self.insertion_count,
489 hit_count: self.hit_count,
490 miss_count: self.miss_count,
491 }
492 }
493}
494
495#[cfg(test)]
496mod disk_bounded_tests {
497 use super::*;
498
499 fn make_cache(max_bytes: u64) -> DiskBoundedCache {
500 DiskBoundedCache::new(max_bytes).expect("valid max_bytes")
501 }
502
503 #[test]
504 fn test_new_rejects_zero_max() {
505 assert!(DiskBoundedCache::new(0).is_err());
506 }
507
508 #[test]
509 fn test_insert_single_entry() {
510 let mut cache = make_cache(1_000);
511 assert!(cache.insert("a.mp4", 100, 1_000));
512 assert_eq!(cache.len(), 1);
513 assert_eq!(cache.used_bytes(), 100);
514 }
515
516 #[test]
517 fn test_insert_over_limit_rejected() {
518 let mut cache = make_cache(500);
519 assert!(!cache.insert("huge.mp4", 1_000, 1_000));
520 assert!(cache.is_empty());
521 }
522
523 #[test]
524 fn test_lru_eviction_on_insert() {
525 let mut cache = make_cache(200);
526 cache.insert("a.mp4", 100, 1_000);
527 cache.insert("b.mp4", 100, 2_000);
528 assert_eq!(cache.len(), 2);
530 cache.insert("c.mp4", 100, 3_000);
532 assert_eq!(cache.len(), 2);
533 assert!(!cache.contains("a.mp4"), "a.mp4 should have been evicted");
534 assert!(cache.contains("b.mp4"));
535 assert!(cache.contains("c.mp4"));
536 assert_eq!(cache.stats().eviction_count, 1);
537 }
538
539 #[test]
540 fn test_access_hit_moves_to_mru() {
541 let mut cache = make_cache(200);
544 cache.insert("a.mp4", 100, 1_000);
545 cache.insert("b.mp4", 100, 2_000);
546 let entry = cache.access("a.mp4", 5_000).expect("hit expected");
548 assert_eq!(entry.path, "a.mp4");
549
550 cache.insert("c.mp4", 100, 6_000);
552 assert!(!cache.contains("b.mp4"), "b.mp4 should be evicted");
553 assert!(cache.contains("a.mp4"), "a.mp4 is MRU, should survive");
554 }
555
556 #[test]
557 fn test_access_miss_increments_counter() {
558 let mut cache = make_cache(1_000);
559 let result = cache.access("nonexistent.mp4", 1_000);
560 assert!(result.is_none());
561 assert_eq!(cache.stats().miss_count, 1);
562 }
563
564 #[test]
565 fn test_hit_count_increments() {
566 let mut cache = make_cache(1_000);
567 cache.insert("a.mp4", 100, 1_000);
568 cache.access("a.mp4", 2_000);
569 cache.access("a.mp4", 3_000);
570 assert_eq!(cache.stats().hit_count, 2);
571 }
572
573 #[test]
574 fn test_update_existing_entry() {
575 let mut cache = make_cache(500);
576 cache.insert("a.mp4", 200, 1_000);
577 cache.insert("a.mp4", 300, 2_000);
579 assert_eq!(cache.len(), 1);
580 assert_eq!(cache.used_bytes(), 300);
581 }
582
583 #[test]
584 fn test_evict_lru_explicit() {
585 let mut cache = make_cache(500);
586 cache.insert("old.mp4", 100, 1_000);
587 cache.insert("new.mp4", 100, 9_000);
588 let evicted = cache.evict_lru();
589 assert_eq!(evicted, Some("old.mp4".to_string()));
590 assert_eq!(cache.len(), 1);
591 assert_eq!(cache.used_bytes(), 100);
592 }
593
594 #[test]
595 fn test_evict_lru_empty_returns_none() {
596 let mut cache = make_cache(500);
597 assert!(cache.evict_lru().is_none());
598 }
599
600 #[test]
601 fn test_clear() {
602 let mut cache = make_cache(1_000);
603 cache.insert("a.mp4", 100, 1_000);
604 cache.insert("b.mp4", 200, 2_000);
605 let cleared = cache.clear();
606 assert_eq!(cleared.len(), 2);
607 assert!(cache.is_empty());
608 assert_eq!(cache.used_bytes(), 0);
609 }
610
611 #[test]
612 fn test_utilization() {
613 let mut cache = make_cache(1_000);
614 cache.insert("a.mp4", 500, 1_000);
615 assert!((cache.utilization() - 0.5).abs() < 1e-9);
616 }
617
618 #[test]
619 fn test_stats_fields() {
620 let mut cache = make_cache(1_000);
621 cache.insert("a.mp4", 100, 1_000);
622 cache.insert("b.mp4", 100, 2_000);
623 cache.access("a.mp4", 3_000);
624 cache.access("missing.mp4", 4_000);
625 let s = cache.stats();
626 assert_eq!(s.entry_count, 2);
627 assert_eq!(s.insertion_count, 2);
628 assert_eq!(s.hit_count, 1);
629 assert_eq!(s.miss_count, 1);
630 }
631
632 #[test]
633 fn test_rapid_create_evict_cycles() {
634 let mut cache = make_cache(500);
636 let mut total_evictions = 0u64;
637 for i in 0..1_000u64 {
638 let path = format!("proxy_{i}.mp4");
639 cache.insert(&path, 100, i * 10);
640 total_evictions = cache.stats().eviction_count;
641 }
642 assert!(cache.used_bytes() <= 500);
644 assert!(
645 total_evictions > 900,
646 "expected many evictions, got {total_evictions}"
647 );
648 }
649
650 #[test]
651 fn test_multiple_evictions_per_insert() {
652 let mut cache = make_cache(1_000);
654 for i in 0..10u64 {
655 cache.insert(&format!("{i}.mp4"), 100, i);
656 }
657 assert_eq!(cache.len(), 10);
658 cache.insert("big.mp4", 600, 100);
660 assert!(cache.used_bytes() <= 1_000);
661 assert!(cache.contains("big.mp4"));
662 }
663}