Skip to main content

pacha/
cache.rs

1//! Cache Management and Download Progress
2//!
3//! Manages the local model cache with cleanup, statistics, and download progress.
4//!
5//! ## Features
6//!
7//! - Cache size tracking and limits
8//! - LRU eviction for space management
9//! - Download progress callbacks
10//! - Cache statistics and health
11//!
12//! ## Example
13//!
14//! ```rust,ignore
15//! use pacha::cache::{CacheManager, CacheConfig};
16//!
17//! let config = CacheConfig::new()
18//!     .with_max_size_gb(50.0)
19//!     .with_auto_cleanup(true);
20//!
21//! let cache = CacheManager::new(config, registry);
22//!
23//! // Clean up old models
24//! let freed = cache.cleanup()?;
25//! println!("Freed {} bytes", freed);
26//! ```
27
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::PathBuf;
31use std::time::{Duration, Instant, SystemTime};
32
33// ============================================================================
34// CACHE-001: Configuration
35// ============================================================================
36
37/// Cache configuration
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CacheConfig {
40    /// Maximum cache size in bytes (0 = unlimited)
41    pub max_size_bytes: u64,
42    /// Minimum free space to maintain (in bytes)
43    pub min_free_space: u64,
44    /// Maximum age for unused models (in seconds, 0 = unlimited)
45    pub max_age_secs: u64,
46    /// Enable automatic cleanup
47    pub auto_cleanup: bool,
48    /// Cleanup threshold (trigger when usage exceeds this percentage)
49    pub cleanup_threshold: f64,
50    /// Target usage after cleanup (percentage)
51    pub cleanup_target: f64,
52}
53
54impl Default for CacheConfig {
55    fn default() -> Self {
56        Self {
57            max_size_bytes: 50 * 1024 * 1024 * 1024, // 50 GB
58            min_free_space: 5 * 1024 * 1024 * 1024,  // 5 GB
59            max_age_secs: 30 * 24 * 60 * 60,         // 30 days
60            auto_cleanup: true,
61            cleanup_threshold: 0.90, // 90%
62            cleanup_target: 0.70,    // 70%
63        }
64    }
65}
66
67impl CacheConfig {
68    /// Create a new cache configuration
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Set maximum cache size in GB
75    #[must_use]
76    pub fn with_max_size_gb(mut self, gb: f64) -> Self {
77        self.max_size_bytes = (gb * 1024.0 * 1024.0 * 1024.0) as u64;
78        self
79    }
80
81    /// Set maximum cache size in bytes
82    #[must_use]
83    pub fn with_max_size_bytes(mut self, bytes: u64) -> Self {
84        self.max_size_bytes = bytes;
85        self
86    }
87
88    /// Set minimum free space in GB
89    #[must_use]
90    pub fn with_min_free_space_gb(mut self, gb: f64) -> Self {
91        self.min_free_space = (gb * 1024.0 * 1024.0 * 1024.0) as u64;
92        self
93    }
94
95    /// Set maximum age for unused models in days
96    #[must_use]
97    pub fn with_max_age_days(mut self, days: u64) -> Self {
98        self.max_age_secs = days * 24 * 60 * 60;
99        self
100    }
101
102    /// Enable/disable auto cleanup
103    #[must_use]
104    pub fn with_auto_cleanup(mut self, enabled: bool) -> Self {
105        self.auto_cleanup = enabled;
106        self
107    }
108
109    /// Set cleanup threshold (percentage)
110    #[must_use]
111    pub fn with_cleanup_threshold(mut self, threshold: f64) -> Self {
112        self.cleanup_threshold = threshold.clamp(0.0, 1.0);
113        self
114    }
115
116    /// Set cleanup target (percentage)
117    #[must_use]
118    pub fn with_cleanup_target(mut self, target: f64) -> Self {
119        self.cleanup_target = target.clamp(0.0, 1.0);
120        self
121    }
122
123    /// Get max size in GB
124    #[must_use]
125    pub fn max_size_gb(&self) -> f64 {
126        self.max_size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
127    }
128}
129
130// ============================================================================
131// CACHE-002: Cache Entry
132// ============================================================================
133
134/// A cached model entry
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CacheEntry {
137    /// Model name
138    pub name: String,
139    /// Model version
140    pub version: String,
141    /// Size in bytes
142    pub size_bytes: u64,
143    /// Last accessed timestamp
144    pub last_accessed: SystemTime,
145    /// Created timestamp
146    pub created_at: SystemTime,
147    /// Access count
148    pub access_count: u64,
149    /// Content hash
150    pub hash: String,
151    /// File path in cache
152    pub path: PathBuf,
153    /// Whether this entry is pinned (won't be evicted)
154    pub pinned: bool,
155}
156
157impl CacheEntry {
158    /// Create a new cache entry
159    #[must_use]
160    pub fn new(
161        name: impl Into<String>,
162        version: impl Into<String>,
163        size_bytes: u64,
164        hash: impl Into<String>,
165        path: PathBuf,
166    ) -> Self {
167        let now = SystemTime::now();
168        Self {
169            name: name.into(),
170            version: version.into(),
171            size_bytes,
172            last_accessed: now,
173            created_at: now,
174            access_count: 0,
175            hash: hash.into(),
176            path,
177            pinned: false,
178        }
179    }
180
181    /// Mark as accessed
182    pub fn touch(&mut self) {
183        self.last_accessed = SystemTime::now();
184        self.access_count += 1;
185    }
186
187    /// Pin this entry (prevent eviction)
188    pub fn pin(&mut self) {
189        self.pinned = true;
190    }
191
192    /// Unpin this entry
193    pub fn unpin(&mut self) {
194        self.pinned = false;
195    }
196
197    /// Get age since last access
198    #[must_use]
199    pub fn age(&self) -> Duration {
200        SystemTime::now().duration_since(self.last_accessed).unwrap_or(Duration::ZERO)
201    }
202
203    /// Get size in GB
204    #[must_use]
205    pub fn size_gb(&self) -> f64 {
206        self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
207    }
208
209    /// Get unique key
210    #[must_use]
211    pub fn key(&self) -> String {
212        format!("{}:{}", self.name, self.version)
213    }
214}
215
216// ============================================================================
217// CACHE-003: Cache Statistics
218// ============================================================================
219
220/// Cache statistics
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct CacheStats {
223    /// Total cache size in bytes
224    pub total_size_bytes: u64,
225    /// Number of cached models
226    pub model_count: usize,
227    /// Maximum configured size
228    pub max_size_bytes: u64,
229    /// Usage percentage
230    pub usage_percent: f64,
231    /// Number of pinned models
232    pub pinned_count: usize,
233    /// Total pinned size
234    pub pinned_size_bytes: u64,
235    /// Oldest entry age (seconds)
236    pub oldest_age_secs: u64,
237    /// Most accessed model
238    pub most_accessed: Option<String>,
239    /// Hit rate (if tracking enabled)
240    pub hit_rate: Option<f64>,
241}
242
243impl CacheStats {
244    /// Get total size in GB
245    #[must_use]
246    pub fn total_size_gb(&self) -> f64 {
247        self.total_size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
248    }
249
250    /// Get max size in GB
251    #[must_use]
252    pub fn max_size_gb(&self) -> f64 {
253        self.max_size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
254    }
255
256    /// Get available space in bytes
257    #[must_use]
258    pub fn available_bytes(&self) -> u64 {
259        self.max_size_bytes.saturating_sub(self.total_size_bytes)
260    }
261
262    /// Get available space in GB
263    #[must_use]
264    pub fn available_gb(&self) -> f64 {
265        self.available_bytes() as f64 / (1024.0 * 1024.0 * 1024.0)
266    }
267}
268
269// ============================================================================
270// CACHE-004: Download Progress
271// ============================================================================
272
273/// Download progress information
274#[derive(Debug, Clone, Copy)]
275pub struct DownloadProgress {
276    /// Total bytes to download
277    pub total_bytes: u64,
278    /// Bytes downloaded so far
279    pub downloaded_bytes: u64,
280    /// Download speed (bytes per second)
281    pub speed_bps: f64,
282    /// Estimated time remaining (seconds)
283    pub eta_secs: f64,
284    /// Whether download is complete
285    pub is_complete: bool,
286    /// Start time
287    pub started_at: Instant,
288}
289
290impl DownloadProgress {
291    /// Create new progress tracker
292    #[must_use]
293    pub fn new(total_bytes: u64) -> Self {
294        Self {
295            total_bytes,
296            downloaded_bytes: 0,
297            speed_bps: 0.0,
298            eta_secs: 0.0,
299            is_complete: false,
300            started_at: Instant::now(),
301        }
302    }
303
304    /// Update progress
305    pub fn update(&mut self, downloaded_bytes: u64) {
306        self.downloaded_bytes = downloaded_bytes;
307        let elapsed = self.started_at.elapsed().as_secs_f64();
308
309        if elapsed > 0.0 {
310            self.speed_bps = downloaded_bytes as f64 / elapsed;
311        }
312
313        if self.speed_bps > 0.0 {
314            let remaining = self.total_bytes.saturating_sub(downloaded_bytes);
315            self.eta_secs = remaining as f64 / self.speed_bps;
316        }
317
318        self.is_complete = downloaded_bytes >= self.total_bytes;
319    }
320
321    /// Get completion percentage
322    #[must_use]
323    pub fn percent(&self) -> f64 {
324        if self.total_bytes == 0 {
325            100.0
326        } else {
327            (self.downloaded_bytes as f64 / self.total_bytes as f64) * 100.0
328        }
329    }
330
331    /// Get human-readable speed
332    #[must_use]
333    pub fn speed_human(&self) -> String {
334        format_bytes_per_sec(self.speed_bps)
335    }
336
337    /// Get human-readable ETA
338    #[must_use]
339    pub fn eta_human(&self) -> String {
340        format_duration(Duration::from_secs_f64(self.eta_secs))
341    }
342
343    /// Get human-readable downloaded size
344    #[must_use]
345    pub fn downloaded_human(&self) -> String {
346        format_bytes(self.downloaded_bytes)
347    }
348
349    /// Get human-readable total size
350    #[must_use]
351    pub fn total_human(&self) -> String {
352        format_bytes(self.total_bytes)
353    }
354
355    /// Format progress bar (width characters)
356    #[must_use]
357    pub fn progress_bar(&self, width: usize) -> String {
358        let filled = (self.percent() / 100.0 * width as f64) as usize;
359        let empty = width.saturating_sub(filled);
360
361        format!("[{}{}] {:5.1}%", "█".repeat(filled), "░".repeat(empty), self.percent())
362    }
363}
364
365/// Progress callback type
366pub type ProgressCallback = Box<dyn Fn(&DownloadProgress) + Send + Sync>;
367
368// ============================================================================
369// CACHE-005: Eviction Policy
370// ============================================================================
371
372/// Eviction policy for cache cleanup
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
374pub enum EvictionPolicy {
375    /// Least Recently Used
376    #[default]
377    LRU,
378    /// Least Frequently Used
379    LFU,
380    /// First In First Out
381    FIFO,
382    /// Largest First
383    LargestFirst,
384    /// Oldest First (by creation time)
385    OldestFirst,
386}
387
388impl EvictionPolicy {
389    /// Sort entries by eviction priority (lowest priority first)
390    pub fn sort_for_eviction<'a>(&self, entries: &mut [&'a CacheEntry]) {
391        match self {
392            Self::LRU => entries.sort_by(|a, b| a.last_accessed.cmp(&b.last_accessed)),
393            Self::LFU => entries.sort_by(|a, b| a.access_count.cmp(&b.access_count)),
394            Self::FIFO => entries.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
395            Self::LargestFirst => entries.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)),
396            Self::OldestFirst => entries.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
397        }
398    }
399}
400
401// ============================================================================
402// CACHE-006: Cache Manager
403// ============================================================================
404
405/// Cache manager for model storage
406#[derive(Debug)]
407pub struct CacheManager {
408    /// Configuration
409    config: CacheConfig,
410    /// Cached entries
411    entries: HashMap<String, CacheEntry>,
412    /// Eviction policy
413    policy: EvictionPolicy,
414    /// Cache hits counter
415    cache_hits: u64,
416    /// Cache misses counter
417    cache_misses: u64,
418}
419
420impl CacheManager {
421    /// Create a new cache manager
422    #[must_use]
423    pub fn new(config: CacheConfig) -> Self {
424        Self {
425            config,
426            entries: HashMap::new(),
427            policy: EvictionPolicy::LRU,
428            cache_hits: 0,
429            cache_misses: 0,
430        }
431    }
432
433    /// Set eviction policy
434    #[must_use]
435    pub fn with_policy(mut self, policy: EvictionPolicy) -> Self {
436        self.policy = policy;
437        self
438    }
439
440    /// Get configuration
441    #[must_use]
442    pub fn config(&self) -> &CacheConfig {
443        &self.config
444    }
445
446    /// Add an entry to the cache
447    pub fn add(&mut self, entry: CacheEntry) {
448        // Check if we need to make space
449        if self.config.auto_cleanup && self.needs_cleanup() {
450            let _ = self.cleanup_to_target();
451        }
452
453        self.entries.insert(entry.key(), entry);
454    }
455
456    /// Get an entry from the cache
457    pub fn get(&mut self, name: &str, version: &str) -> Option<&CacheEntry> {
458        let key = format!("{name}:{version}");
459        if let Some(entry) = self.entries.get_mut(&key) {
460            entry.touch();
461            self.cache_hits += 1;
462            Some(entry)
463        } else {
464            self.cache_misses += 1;
465            None
466        }
467    }
468
469    /// Check if an entry exists
470    #[must_use]
471    pub fn contains(&self, name: &str, version: &str) -> bool {
472        let key = format!("{name}:{version}");
473        self.entries.contains_key(&key)
474    }
475
476    /// Remove an entry
477    pub fn remove(&mut self, name: &str, version: &str) -> Option<CacheEntry> {
478        let key = format!("{name}:{version}");
479        self.entries.remove(&key)
480    }
481
482    /// Pin an entry (prevent eviction)
483    pub fn pin(&mut self, name: &str, version: &str) -> bool {
484        let key = format!("{name}:{version}");
485        if let Some(entry) = self.entries.get_mut(&key) {
486            entry.pin();
487            true
488        } else {
489            false
490        }
491    }
492
493    /// Unpin an entry
494    pub fn unpin(&mut self, name: &str, version: &str) -> bool {
495        let key = format!("{name}:{version}");
496        if let Some(entry) = self.entries.get_mut(&key) {
497            entry.unpin();
498            true
499        } else {
500            false
501        }
502    }
503
504    /// Get cache statistics
505    #[must_use]
506    pub fn stats(&self) -> CacheStats {
507        let total_size_bytes: u64 = self.entries.values().map(|e| e.size_bytes).sum();
508        let pinned_entries: Vec<_> = self.entries.values().filter(|e| e.pinned).collect();
509        let pinned_size_bytes: u64 = pinned_entries.iter().map(|e| e.size_bytes).sum();
510
511        let oldest_age = self.entries.values().map(|e| e.age().as_secs()).max().unwrap_or(0);
512
513        let most_accessed = self.entries.values().max_by_key(|e| e.access_count).map(|e| e.key());
514
515        let usage_percent = if self.config.max_size_bytes > 0 {
516            total_size_bytes as f64 / self.config.max_size_bytes as f64
517        } else {
518            0.0
519        };
520
521        let total_requests = self.cache_hits + self.cache_misses;
522        let hit_rate = if total_requests > 0 {
523            Some(self.cache_hits as f64 / total_requests as f64)
524        } else {
525            None
526        };
527
528        CacheStats {
529            total_size_bytes,
530            model_count: self.entries.len(),
531            max_size_bytes: self.config.max_size_bytes,
532            usage_percent,
533            pinned_count: pinned_entries.len(),
534            pinned_size_bytes,
535            oldest_age_secs: oldest_age,
536            most_accessed,
537            hit_rate,
538        }
539    }
540
541    /// Check if cleanup is needed
542    #[must_use]
543    pub fn needs_cleanup(&self) -> bool {
544        let stats = self.stats();
545        stats.usage_percent >= self.config.cleanup_threshold
546    }
547
548    /// Cleanup old/unused entries to reach target
549    ///
550    /// Returns bytes freed
551    pub fn cleanup_to_target(&mut self) -> u64 {
552        let target_bytes = (self.config.max_size_bytes as f64 * self.config.cleanup_target) as u64;
553        self.cleanup_to_size(target_bytes)
554    }
555
556    /// Cleanup to reach a specific size
557    ///
558    /// Returns bytes freed
559    pub fn cleanup_to_size(&mut self, target_bytes: u64) -> u64 {
560        let mut current_size: u64 = self.entries.values().map(|e| e.size_bytes).sum();
561
562        if current_size <= target_bytes {
563            return 0;
564        }
565
566        // Get eviction candidates (non-pinned entries)
567        let mut candidates: Vec<&CacheEntry> =
568            self.entries.values().filter(|e| !e.pinned).collect();
569
570        // Sort by eviction priority
571        self.policy.sort_for_eviction(&mut candidates);
572
573        // Collect keys to remove
574        let mut to_remove = Vec::new();
575        let mut freed = 0u64;
576
577        for entry in candidates {
578            if current_size <= target_bytes {
579                break;
580            }
581            to_remove.push(entry.key());
582            current_size -= entry.size_bytes;
583            freed += entry.size_bytes;
584        }
585
586        // Remove entries
587        for key in to_remove {
588            self.entries.remove(&key);
589        }
590
591        freed
592    }
593
594    /// Remove entries older than max age
595    ///
596    /// Returns bytes freed
597    pub fn cleanup_old_entries(&mut self) -> u64 {
598        if self.config.max_age_secs == 0 {
599            return 0;
600        }
601
602        let max_age = Duration::from_secs(self.config.max_age_secs);
603        let to_remove: Vec<String> = self
604            .entries
605            .values()
606            .filter(|e| !e.pinned && e.age() > max_age)
607            .map(|e| e.key())
608            .collect();
609
610        let mut freed = 0u64;
611        for key in to_remove {
612            if let Some(entry) = self.entries.remove(&key) {
613                freed += entry.size_bytes;
614            }
615        }
616
617        freed
618    }
619
620    /// List all entries
621    #[must_use]
622    pub fn list(&self) -> Vec<&CacheEntry> {
623        let mut entries: Vec<_> = self.entries.values().collect();
624        entries.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
625        entries
626    }
627
628    /// Clear the entire cache
629    ///
630    /// Returns bytes freed
631    pub fn clear(&mut self) -> u64 {
632        let freed: u64 = self.entries.values().map(|e| e.size_bytes).sum();
633        self.entries.clear();
634        freed
635    }
636}
637
638// ============================================================================
639// Helper Functions
640// ============================================================================
641
642/// Format bytes as human-readable string
643#[must_use]
644pub fn format_bytes(bytes: u64) -> String {
645    const KB: u64 = 1024;
646    const MB: u64 = KB * 1024;
647    const GB: u64 = MB * 1024;
648    const TB: u64 = GB * 1024;
649
650    if bytes >= TB {
651        format!("{:.2} TB", bytes as f64 / TB as f64)
652    } else if bytes >= GB {
653        format!("{:.2} GB", bytes as f64 / GB as f64)
654    } else if bytes >= MB {
655        format!("{:.2} MB", bytes as f64 / MB as f64)
656    } else if bytes >= KB {
657        format!("{:.2} KB", bytes as f64 / KB as f64)
658    } else {
659        format!("{bytes} B")
660    }
661}
662
663/// Format bytes per second as human-readable string
664#[must_use]
665pub fn format_bytes_per_sec(bps: f64) -> String {
666    format!("{}/s", format_bytes(bps as u64))
667}
668
669/// Format duration as human-readable string
670#[must_use]
671pub fn format_duration(duration: Duration) -> String {
672    let secs = duration.as_secs();
673    if secs >= 3600 {
674        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
675    } else if secs >= 60 {
676        format!("{}m {}s", secs / 60, secs % 60)
677    } else {
678        format!("{secs}s")
679    }
680}
681
682// ============================================================================
683// Tests
684// ============================================================================
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    // ========================================================================
691    // CACHE-001: Config Tests
692    // ========================================================================
693
694    #[test]
695    fn test_cache_config_default() {
696        let config = CacheConfig::default();
697        assert_eq!(config.max_size_bytes, 50 * 1024 * 1024 * 1024);
698        assert!(config.auto_cleanup);
699    }
700
701    #[test]
702    fn test_cache_config_builder() {
703        let config = CacheConfig::new()
704            .with_max_size_gb(100.0)
705            .with_min_free_space_gb(10.0)
706            .with_max_age_days(60)
707            .with_auto_cleanup(false)
708            .with_cleanup_threshold(0.80)
709            .with_cleanup_target(0.50);
710
711        assert!((config.max_size_gb() - 100.0).abs() < 0.1);
712        assert!(!config.auto_cleanup);
713        assert!((config.cleanup_threshold - 0.80).abs() < f64::EPSILON);
714        assert!((config.cleanup_target - 0.50).abs() < f64::EPSILON);
715    }
716
717    #[test]
718    fn test_cache_config_clamp() {
719        let config = CacheConfig::new().with_cleanup_threshold(1.5).with_cleanup_target(-0.5);
720
721        assert!((config.cleanup_threshold - 1.0).abs() < f64::EPSILON);
722        assert!((config.cleanup_target - 0.0).abs() < f64::EPSILON);
723    }
724
725    // ========================================================================
726    // CACHE-002: Entry Tests
727    // ========================================================================
728
729    #[test]
730    fn test_cache_entry_new() {
731        let entry = CacheEntry::new(
732            "llama3",
733            "8b",
734            4_000_000_000,
735            "hash123",
736            PathBuf::from("/cache/llama3"),
737        );
738
739        assert_eq!(entry.name, "llama3");
740        assert_eq!(entry.version, "8b");
741        assert_eq!(entry.size_bytes, 4_000_000_000);
742        assert_eq!(entry.access_count, 0);
743        assert!(!entry.pinned);
744    }
745
746    #[test]
747    fn test_cache_entry_touch() {
748        let mut entry = CacheEntry::new("test", "1.0", 1000, "hash", PathBuf::new());
749        let initial_access = entry.last_accessed;
750
751        std::thread::sleep(Duration::from_millis(10));
752        entry.touch();
753
754        assert!(entry.last_accessed > initial_access);
755        assert_eq!(entry.access_count, 1);
756    }
757
758    #[test]
759    fn test_cache_entry_pin_unpin() {
760        let mut entry = CacheEntry::new("test", "1.0", 1000, "hash", PathBuf::new());
761
762        assert!(!entry.pinned);
763        entry.pin();
764        assert!(entry.pinned);
765        entry.unpin();
766        assert!(!entry.pinned);
767    }
768
769    #[test]
770    fn test_cache_entry_size_gb() {
771        let entry = CacheEntry::new("test", "1.0", 4 * 1024 * 1024 * 1024, "hash", PathBuf::new());
772        assert!((entry.size_gb() - 4.0).abs() < 0.01);
773    }
774
775    #[test]
776    fn test_cache_entry_key() {
777        let entry = CacheEntry::new("model", "v1.0", 1000, "hash", PathBuf::new());
778        assert_eq!(entry.key(), "model:v1.0");
779    }
780
781    // ========================================================================
782    // CACHE-003: Stats Tests
783    // ========================================================================
784
785    #[test]
786    fn test_cache_stats_sizes() {
787        let stats = CacheStats {
788            total_size_bytes: 10 * 1024 * 1024 * 1024,
789            model_count: 5,
790            max_size_bytes: 50 * 1024 * 1024 * 1024,
791            usage_percent: 0.20,
792            pinned_count: 1,
793            pinned_size_bytes: 2 * 1024 * 1024 * 1024,
794            oldest_age_secs: 3600,
795            most_accessed: Some("llama3:8b".to_string()),
796            hit_rate: Some(0.95),
797        };
798
799        assert!((stats.total_size_gb() - 10.0).abs() < 0.1);
800        assert!((stats.max_size_gb() - 50.0).abs() < 0.1);
801        assert!((stats.available_gb() - 40.0).abs() < 0.1);
802    }
803
804    // ========================================================================
805    // CACHE-004: Progress Tests
806    // ========================================================================
807
808    #[test]
809    fn test_download_progress_new() {
810        let progress = DownloadProgress::new(1000);
811        assert_eq!(progress.total_bytes, 1000);
812        assert_eq!(progress.downloaded_bytes, 0);
813        assert!(!progress.is_complete);
814    }
815
816    #[test]
817    fn test_download_progress_update() {
818        let mut progress = DownloadProgress::new(1000);
819        progress.update(500);
820
821        assert_eq!(progress.downloaded_bytes, 500);
822        assert!((progress.percent() - 50.0).abs() < 0.1);
823        assert!(!progress.is_complete);
824    }
825
826    #[test]
827    fn test_download_progress_complete() {
828        let mut progress = DownloadProgress::new(1000);
829        progress.update(1000);
830
831        assert!(progress.is_complete);
832        assert!((progress.percent() - 100.0).abs() < 0.1);
833    }
834
835    #[test]
836    fn test_download_progress_bar() {
837        let mut progress = DownloadProgress::new(100);
838        progress.update(50);
839
840        let bar = progress.progress_bar(20);
841        assert!(bar.contains("50.0%"));
842        assert!(bar.contains("█"));
843        assert!(bar.contains("░"));
844    }
845
846    #[test]
847    fn test_download_progress_zero_total() {
848        let progress = DownloadProgress::new(0);
849        assert!((progress.percent() - 100.0).abs() < 0.1);
850    }
851
852    // ========================================================================
853    // CACHE-005: Eviction Tests
854    // ========================================================================
855
856    #[test]
857    fn test_eviction_policy_lru() {
858        let now = SystemTime::now();
859        let old_time = now - Duration::from_secs(3600);
860
861        let mut entry1 = CacheEntry::new("old", "1.0", 100, "h1", PathBuf::new());
862        entry1.last_accessed = old_time;
863
864        let entry2 = CacheEntry::new("new", "1.0", 100, "h2", PathBuf::new());
865
866        let mut entries: Vec<&CacheEntry> = vec![&entry2, &entry1];
867        EvictionPolicy::LRU.sort_for_eviction(&mut entries);
868
869        // Oldest should be first (lowest priority)
870        assert_eq!(entries[0].name, "old");
871    }
872
873    #[test]
874    fn test_eviction_policy_lfu() {
875        let mut entry1 = CacheEntry::new("popular", "1.0", 100, "h1", PathBuf::new());
876        entry1.access_count = 100;
877
878        let entry2 = CacheEntry::new("unpopular", "1.0", 100, "h2", PathBuf::new());
879
880        let mut entries: Vec<&CacheEntry> = vec![&entry1, &entry2];
881        EvictionPolicy::LFU.sort_for_eviction(&mut entries);
882
883        // Least accessed should be first
884        assert_eq!(entries[0].name, "unpopular");
885    }
886
887    #[test]
888    fn test_eviction_policy_largest() {
889        let entry1 = CacheEntry::new("small", "1.0", 100, "h1", PathBuf::new());
890        let entry2 = CacheEntry::new("large", "1.0", 1000, "h2", PathBuf::new());
891
892        let mut entries: Vec<&CacheEntry> = vec![&entry1, &entry2];
893        EvictionPolicy::LargestFirst.sort_for_eviction(&mut entries);
894
895        // Largest should be first
896        assert_eq!(entries[0].name, "large");
897    }
898
899    // ========================================================================
900    // CACHE-006: Manager Tests
901    // ========================================================================
902
903    #[test]
904    fn test_cache_manager_new() {
905        let config = CacheConfig::new().with_max_size_gb(10.0);
906        let manager = CacheManager::new(config);
907
908        assert_eq!(manager.stats().model_count, 0);
909    }
910
911    #[test]
912    fn test_cache_manager_add_get() {
913        let config = CacheConfig::new().with_auto_cleanup(false);
914        let mut manager = CacheManager::new(config);
915
916        let entry = CacheEntry::new("model", "1.0", 1000, "hash", PathBuf::new());
917        manager.add(entry);
918
919        assert!(manager.contains("model", "1.0"));
920        assert!(manager.get("model", "1.0").is_some());
921    }
922
923    #[test]
924    fn test_cache_manager_remove() {
925        let config = CacheConfig::new().with_auto_cleanup(false);
926        let mut manager = CacheManager::new(config);
927
928        let entry = CacheEntry::new("model", "1.0", 1000, "hash", PathBuf::new());
929        manager.add(entry);
930
931        let removed = manager.remove("model", "1.0");
932        assert!(removed.is_some());
933        assert!(!manager.contains("model", "1.0"));
934    }
935
936    #[test]
937    fn test_cache_manager_pin() {
938        let config = CacheConfig::new().with_auto_cleanup(false);
939        let mut manager = CacheManager::new(config);
940
941        let entry = CacheEntry::new("model", "1.0", 1000, "hash", PathBuf::new());
942        manager.add(entry);
943
944        assert!(manager.pin("model", "1.0"));
945        assert!(!manager.pin("nonexistent", "1.0"));
946
947        let stats = manager.stats();
948        assert_eq!(stats.pinned_count, 1);
949    }
950
951    #[test]
952    fn test_cache_manager_cleanup() {
953        let config = CacheConfig::new()
954            .with_max_size_bytes(1000)
955            .with_auto_cleanup(false)
956            .with_cleanup_target(0.5);
957        let mut manager = CacheManager::new(config);
958
959        // Add entries totaling 800 bytes
960        for i in 0..8 {
961            let entry =
962                CacheEntry::new(format!("model{i}"), "1.0", 100, format!("h{i}"), PathBuf::new());
963            manager.add(entry);
964        }
965
966        let freed = manager.cleanup_to_target(); // Target is 500 bytes
967        assert!(freed >= 300); // Should free at least 300 bytes
968    }
969
970    #[test]
971    fn test_cache_manager_cleanup_respects_pinned() {
972        let config = CacheConfig::new()
973            .with_max_size_bytes(200)
974            .with_auto_cleanup(false)
975            .with_cleanup_target(0.5);
976        let mut manager = CacheManager::new(config);
977
978        // Add two entries
979        let entry1 = CacheEntry::new("pinned", "1.0", 100, "h1", PathBuf::new());
980        let entry2 = CacheEntry::new("unpinned", "1.0", 100, "h2", PathBuf::new());
981
982        manager.add(entry1);
983        manager.add(entry2);
984        manager.pin("pinned", "1.0");
985
986        manager.cleanup_to_target();
987
988        // Pinned entry should remain
989        assert!(manager.contains("pinned", "1.0"));
990    }
991
992    #[test]
993    fn test_cache_manager_stats() {
994        let config = CacheConfig::new().with_max_size_bytes(1000).with_auto_cleanup(false);
995        let mut manager = CacheManager::new(config);
996
997        let entry = CacheEntry::new("model", "1.0", 500, "hash", PathBuf::new());
998        manager.add(entry);
999
1000        let stats = manager.stats();
1001        assert_eq!(stats.model_count, 1);
1002        assert_eq!(stats.total_size_bytes, 500);
1003        assert!((stats.usage_percent - 0.5).abs() < 0.01);
1004    }
1005
1006    #[test]
1007    fn test_cache_manager_hit_rate() {
1008        let config = CacheConfig::new().with_auto_cleanup(false);
1009        let mut manager = CacheManager::new(config);
1010
1011        let entry = CacheEntry::new("model", "1.0", 100, "hash", PathBuf::new());
1012        manager.add(entry);
1013
1014        // 2 hits, 1 miss
1015        manager.get("model", "1.0");
1016        manager.get("model", "1.0");
1017        manager.get("nonexistent", "1.0");
1018
1019        let stats = manager.stats();
1020        assert!((stats.hit_rate.unwrap() - 0.666).abs() < 0.01);
1021    }
1022
1023    #[test]
1024    fn test_cache_manager_clear() {
1025        let config = CacheConfig::new().with_auto_cleanup(false);
1026        let mut manager = CacheManager::new(config);
1027
1028        manager.add(CacheEntry::new("m1", "1.0", 100, "h1", PathBuf::new()));
1029        manager.add(CacheEntry::new("m2", "1.0", 200, "h2", PathBuf::new()));
1030
1031        let freed = manager.clear();
1032        assert_eq!(freed, 300);
1033        assert!(manager.stats().model_count == 0);
1034    }
1035
1036    // ========================================================================
1037    // Helper Tests
1038    // ========================================================================
1039
1040    #[test]
1041    fn test_format_bytes() {
1042        assert_eq!(format_bytes(500), "500 B");
1043        assert_eq!(format_bytes(1024), "1.00 KB");
1044        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
1045        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
1046        assert_eq!(format_bytes(1024 * 1024 * 1024 * 1024), "1.00 TB");
1047    }
1048
1049    #[test]
1050    fn test_format_duration() {
1051        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
1052        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
1053        assert_eq!(format_duration(Duration::from_secs(3700)), "1h 1m");
1054    }
1055
1056    // ========================================================================
1057    // Serialization Tests
1058    // ========================================================================
1059
1060    #[test]
1061    fn test_cache_config_serialization() {
1062        let config = CacheConfig::new().with_max_size_gb(100.0);
1063        let json = serde_json::to_string(&config).unwrap();
1064        let parsed: CacheConfig = serde_json::from_str(&json).unwrap();
1065        assert_eq!(parsed.max_size_bytes, config.max_size_bytes);
1066    }
1067
1068    #[test]
1069    fn test_cache_entry_serialization() {
1070        let entry = CacheEntry::new("model", "1.0", 1000, "hash", PathBuf::from("/cache"));
1071        let json = serde_json::to_string(&entry).unwrap();
1072        let parsed: CacheEntry = serde_json::from_str(&json).unwrap();
1073        assert_eq!(parsed.name, "model");
1074    }
1075
1076    #[test]
1077    fn test_eviction_policy_serialization() {
1078        let policy = EvictionPolicy::LRU;
1079        let json = serde_json::to_string(&policy).unwrap();
1080        let parsed: EvictionPolicy = serde_json::from_str(&json).unwrap();
1081        assert_eq!(parsed, EvictionPolicy::LRU);
1082    }
1083}