Skip to main content

vtcode_core/models_manager/
cache.rs

1//! Models cache for persisting model metadata across sessions.
2//!
3//! This module provides TTL-based caching for model information,
4//! following the pattern from OpenAI Codex's models_manager.
5
6use 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/// Serialized snapshot of models and metadata cached on disk.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ModelsCache {
19    /// Timestamp when the cache was last fetched
20    pub fetched_at: DateTime<Utc>,
21    /// ETag for conditional requests (if provider supports it)
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub etag: Option<String>,
24    /// Provider this cache belongs to (e.g., "gemini", "openai")
25    pub provider: String,
26    /// Cached model information
27    pub models: Vec<ModelInfo>,
28}
29
30impl ModelsCache {
31    /// Create a new cache entry
32    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    /// Create a new cache entry with an ETag
42    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    /// Returns `true` when the cache entry has not exceeded the configured TTL.
52    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    /// Get the age of the cache entry
64    pub fn age(&self) -> chrono::Duration {
65        Utc::now().signed_duration_since(self.fetched_at)
66    }
67}
68
69/// Read and deserialize the cache file if it exists.
70pub 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
82/// Persist the cache contents to disk, creating parent directories as needed.
83pub 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
92/// Publish a cache only when its canonical file is still absent.
93pub 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
104/// Load cache synchronously (for initialization)
105pub 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
117/// Save cache synchronously
118pub 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}