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::VtCodePaths;
12use vtcode_commons::fs::{read_private_file_no_follow, with_private_file_lock};
13
14use super::model_presets::ModelInfo;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ModelsCache {
19 pub fetched_at: DateTime<Utc>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub etag: Option<String>,
24 pub provider: String,
26 pub models: Vec<ModelInfo>,
28}
29
30impl ModelsCache {
31 pub fn new(provider: impl Into<String>, models: Vec<ModelInfo>) -> Self {
33 Self {
34 fetched_at: Utc::now(),
35 etag: None,
36 provider: provider.into(),
37 models,
38 }
39 }
40
41 pub fn with_etag(provider: impl Into<String>, models: Vec<ModelInfo>, etag: String) -> Self {
43 Self {
44 fetched_at: Utc::now(),
45 etag: Some(etag),
46 provider: provider.into(),
47 models,
48 }
49 }
50
51 pub fn is_fresh(&self, ttl: Duration) -> bool {
53 if ttl.is_zero() {
54 return false;
55 }
56 let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else {
57 return false;
58 };
59 let age = Utc::now().signed_duration_since(self.fetched_at);
60 age <= ttl_duration
61 }
62
63 pub fn age(&self) -> chrono::Duration {
65 Utc::now().signed_duration_since(self.fetched_at)
66 }
67}
68
69pub async fn load_cache(path: &Path) -> io::Result<Option<ModelsCache>> {
71 match read_private_file_no_follow(path).await {
72 Ok(contents) => serde_json::from_slice(&contents)
73 .map(Some)
74 .map_err(|err| io::Error::new(ErrorKind::InvalidData, err)),
75 Err(err) => match err.downcast_ref::<io::Error>() {
76 Some(io_err) if io_err.kind() == ErrorKind::NotFound => Ok(None),
77 _ => Err(io::Error::other(err.to_string())),
78 },
79 }
80}
81
82pub async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> {
84 let serialized = serde_json::to_vec_pretty(cache).map_err(|err| io::Error::other(err.to_string()))?;
85 let lock_path = path.to_path_buf();
86 let destination = path.to_path_buf();
87 with_private_file_lock(&lock_path, move || VtCodePaths::write_private_file_atomic(&destination, &serialized))
88 .await
89 .map_err(|err| io::Error::other(err.to_string()))
90}
91
92pub async fn save_cache_if_absent(path: &Path, cache: &ModelsCache) -> io::Result<bool> {
94 let serialized = serde_json::to_vec_pretty(cache).map_err(|err| io::Error::other(err.to_string()))?;
95 let lock_path = path.to_path_buf();
96 let destination = path.to_path_buf();
97 with_private_file_lock(&lock_path, move || {
98 VtCodePaths::write_private_file_atomic_if_absent(&destination, &serialized)
99 })
100 .await
101 .map_err(|err| io::Error::other(err.to_string()))
102}
103
104pub fn load_cache_sync(path: &Path) -> io::Result<Option<ModelsCache>> {
106 match VtCodePaths::read_file_no_follow(path) {
107 Ok(contents) => serde_json::from_slice(&contents)
108 .map(Some)
109 .map_err(|err| io::Error::new(ErrorKind::InvalidData, err)),
110 Err(err) => match err.downcast_ref::<io::Error>() {
111 Some(io_err) if io_err.kind() == ErrorKind::NotFound => Ok(None),
112 _ => Err(io::Error::other(err.to_string())),
113 },
114 }
115}
116
117pub fn save_cache_sync(path: &Path, cache: &ModelsCache) -> io::Result<()> {
119 let serialized = serde_json::to_vec_pretty(cache).map_err(|err| io::Error::other(err.to_string()))?;
120 VtCodePaths::with_private_file_lock(path, || VtCodePaths::write_private_file_atomic(path, &serialized))
121 .map_err(|err| io::Error::other(err.to_string()))
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use tempfile::tempdir;
128
129 #[test]
130 fn cache_is_fresh_when_within_ttl() {
131 let cache = ModelsCache::new("test", vec![]);
132 assert!(cache.is_fresh(Duration::from_secs(300)));
133 }
134
135 #[test]
136 fn cache_is_stale_when_ttl_is_zero() {
137 let cache = ModelsCache::new("test", vec![]);
138 assert!(!cache.is_fresh(Duration::ZERO));
139 }
140
141 #[tokio::test]
142 async fn cache_round_trips_through_disk() {
143 let dir = tempdir().expect("create temp dir");
144 let cache_path = dir.path().join("models_cache.json");
145
146 let original = ModelsCache::new("gemini", vec![]);
147 save_cache(&cache_path, &original).await.expect("save succeeds");
148
149 let loaded = load_cache(&cache_path).await.expect("load succeeds").expect("cache exists");
150
151 assert_eq!(loaded.provider, original.provider);
152 assert_eq!(loaded.models.len(), original.models.len());
153 }
154
155 #[tokio::test]
156 async fn load_returns_none_for_missing_file() {
157 let dir = tempdir().expect("create temp dir");
158 let cache_path = dir.path().join("nonexistent.json");
159
160 let result = load_cache(&cache_path).await.expect("load succeeds");
161 assert!(result.is_none());
162 }
163}