vtcode_core/models_manager/
cache.rs1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::io::{self, ErrorKind};
9use std::path::Path;
10use std::time::Duration;
11use vtcode_commons::fs::{
12 read_json_file, read_json_file_sync, write_json_file, write_json_file_sync,
13};
14
15use super::model_presets::ModelInfo;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelsCache {
20 pub fetched_at: DateTime<Utc>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub etag: Option<String>,
25 pub provider: String,
27 pub models: Vec<ModelInfo>,
29}
30
31impl ModelsCache {
32 pub fn new(provider: impl Into<String>, models: Vec<ModelInfo>) -> Self {
34 Self {
35 fetched_at: Utc::now(),
36 etag: None,
37 provider: provider.into(),
38 models,
39 }
40 }
41
42 pub fn with_etag(provider: impl Into<String>, models: Vec<ModelInfo>, etag: String) -> Self {
44 Self {
45 fetched_at: Utc::now(),
46 etag: Some(etag),
47 provider: provider.into(),
48 models,
49 }
50 }
51
52 pub fn is_fresh(&self, ttl: Duration) -> bool {
54 if ttl.is_zero() {
55 return false;
56 }
57 let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else {
58 return false;
59 };
60 let age = Utc::now().signed_duration_since(self.fetched_at);
61 age <= ttl_duration
62 }
63
64 pub fn age(&self) -> chrono::Duration {
66 Utc::now().signed_duration_since(self.fetched_at)
67 }
68}
69
70pub async fn load_cache(path: &Path) -> io::Result<Option<ModelsCache>> {
72 match read_json_file(path).await {
73 Ok(cache) => Ok(Some(cache)),
74 Err(err) => match err.downcast_ref::<io::Error>() {
75 Some(io_err) if io_err.kind() == ErrorKind::NotFound => Ok(None),
76 _ => Err(io::Error::other(err.to_string())),
77 },
78 }
79}
80
81pub async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> {
83 write_json_file(path, cache)
84 .await
85 .map_err(|err| io::Error::other(err.to_string()))
86}
87
88pub fn load_cache_sync(path: &Path) -> io::Result<Option<ModelsCache>> {
90 match read_json_file_sync(path) {
91 Ok(cache) => Ok(Some(cache)),
92 Err(err) => match err.downcast_ref::<io::Error>() {
93 Some(io_err) if io_err.kind() == ErrorKind::NotFound => Ok(None),
94 _ => Err(io::Error::other(err.to_string())),
95 },
96 }
97}
98
99pub fn save_cache_sync(path: &Path, cache: &ModelsCache) -> io::Result<()> {
101 write_json_file_sync(path, cache).map_err(|err| io::Error::other(err.to_string()))
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use tempfile::tempdir;
108
109 #[test]
110 fn cache_is_fresh_when_within_ttl() {
111 let cache = ModelsCache::new("test", vec![]);
112 assert!(cache.is_fresh(Duration::from_secs(300)));
113 }
114
115 #[test]
116 fn cache_is_stale_when_ttl_is_zero() {
117 let cache = ModelsCache::new("test", vec![]);
118 assert!(!cache.is_fresh(Duration::ZERO));
119 }
120
121 #[tokio::test]
122 async fn cache_round_trips_through_disk() {
123 let dir = tempdir().expect("create temp dir");
124 let cache_path = dir.path().join("models_cache.json");
125
126 let original = ModelsCache::new("gemini", vec![]);
127 save_cache(&cache_path, &original)
128 .await
129 .expect("save succeeds");
130
131 let loaded = load_cache(&cache_path)
132 .await
133 .expect("load succeeds")
134 .expect("cache exists");
135
136 assert_eq!(loaded.provider, original.provider);
137 assert_eq!(loaded.models.len(), original.models.len());
138 }
139
140 #[tokio::test]
141 async fn load_returns_none_for_missing_file() {
142 let dir = tempdir().expect("create temp dir");
143 let cache_path = dir.path().join("nonexistent.json");
144
145 let result = load_cache(&cache_path).await.expect("load succeeds");
146 assert!(result.is_none());
147 }
148}