1use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::PathBuf;
31use std::time::{Duration, Instant, SystemTime};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CacheConfig {
40 pub max_size_bytes: u64,
42 pub min_free_space: u64,
44 pub max_age_secs: u64,
46 pub auto_cleanup: bool,
48 pub cleanup_threshold: f64,
50 pub cleanup_target: f64,
52}
53
54impl Default for CacheConfig {
55 fn default() -> Self {
56 Self {
57 max_size_bytes: 50 * 1024 * 1024 * 1024, min_free_space: 5 * 1024 * 1024 * 1024, max_age_secs: 30 * 24 * 60 * 60, auto_cleanup: true,
61 cleanup_threshold: 0.90, cleanup_target: 0.70, }
64 }
65}
66
67impl CacheConfig {
68 #[must_use]
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 #[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 #[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 #[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 #[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 #[must_use]
104 pub fn with_auto_cleanup(mut self, enabled: bool) -> Self {
105 self.auto_cleanup = enabled;
106 self
107 }
108
109 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CacheEntry {
137 pub name: String,
139 pub version: String,
141 pub size_bytes: u64,
143 pub last_accessed: SystemTime,
145 pub created_at: SystemTime,
147 pub access_count: u64,
149 pub hash: String,
151 pub path: PathBuf,
153 pub pinned: bool,
155}
156
157impl CacheEntry {
158 #[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 pub fn touch(&mut self) {
183 self.last_accessed = SystemTime::now();
184 self.access_count += 1;
185 }
186
187 pub fn pin(&mut self) {
189 self.pinned = true;
190 }
191
192 pub fn unpin(&mut self) {
194 self.pinned = false;
195 }
196
197 #[must_use]
199 pub fn age(&self) -> Duration {
200 SystemTime::now().duration_since(self.last_accessed).unwrap_or(Duration::ZERO)
201 }
202
203 #[must_use]
205 pub fn size_gb(&self) -> f64 {
206 self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
207 }
208
209 #[must_use]
211 pub fn key(&self) -> String {
212 format!("{}:{}", self.name, self.version)
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct CacheStats {
223 pub total_size_bytes: u64,
225 pub model_count: usize,
227 pub max_size_bytes: u64,
229 pub usage_percent: f64,
231 pub pinned_count: usize,
233 pub pinned_size_bytes: u64,
235 pub oldest_age_secs: u64,
237 pub most_accessed: Option<String>,
239 pub hit_rate: Option<f64>,
241}
242
243impl CacheStats {
244 #[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 #[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 #[must_use]
258 pub fn available_bytes(&self) -> u64 {
259 self.max_size_bytes.saturating_sub(self.total_size_bytes)
260 }
261
262 #[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#[derive(Debug, Clone, Copy)]
275pub struct DownloadProgress {
276 pub total_bytes: u64,
278 pub downloaded_bytes: u64,
280 pub speed_bps: f64,
282 pub eta_secs: f64,
284 pub is_complete: bool,
286 pub started_at: Instant,
288}
289
290impl DownloadProgress {
291 #[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 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 #[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 #[must_use]
333 pub fn speed_human(&self) -> String {
334 format_bytes_per_sec(self.speed_bps)
335 }
336
337 #[must_use]
339 pub fn eta_human(&self) -> String {
340 format_duration(Duration::from_secs_f64(self.eta_secs))
341 }
342
343 #[must_use]
345 pub fn downloaded_human(&self) -> String {
346 format_bytes(self.downloaded_bytes)
347 }
348
349 #[must_use]
351 pub fn total_human(&self) -> String {
352 format_bytes(self.total_bytes)
353 }
354
355 #[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
365pub type ProgressCallback = Box<dyn Fn(&DownloadProgress) + Send + Sync>;
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
374pub enum EvictionPolicy {
375 #[default]
377 LRU,
378 LFU,
380 FIFO,
382 LargestFirst,
384 OldestFirst,
386}
387
388impl EvictionPolicy {
389 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#[derive(Debug)]
407pub struct CacheManager {
408 config: CacheConfig,
410 entries: HashMap<String, CacheEntry>,
412 policy: EvictionPolicy,
414 cache_hits: u64,
416 cache_misses: u64,
418}
419
420impl CacheManager {
421 #[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 #[must_use]
435 pub fn with_policy(mut self, policy: EvictionPolicy) -> Self {
436 self.policy = policy;
437 self
438 }
439
440 #[must_use]
442 pub fn config(&self) -> &CacheConfig {
443 &self.config
444 }
445
446 pub fn add(&mut self, entry: CacheEntry) {
448 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 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 #[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 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 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 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 #[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 #[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 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 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 let mut candidates: Vec<&CacheEntry> =
568 self.entries.values().filter(|e| !e.pinned).collect();
569
570 self.policy.sort_for_eviction(&mut candidates);
572
573 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 for key in to_remove {
588 self.entries.remove(&key);
589 }
590
591 freed
592 }
593
594 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 #[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 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#[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#[must_use]
665pub fn format_bytes_per_sec(bps: f64) -> String {
666 format!("{}/s", format_bytes(bps as u64))
667}
668
669#[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#[cfg(test)]
687mod tests {
688 use super::*;
689
690 #[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 #[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 #[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 #[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 #[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 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 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 assert_eq!(entries[0].name, "large");
897 }
898
899 #[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 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(); assert!(freed >= 300); }
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 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 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 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 #[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 #[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}