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::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/// Serialized snapshot of models and metadata cached on disk.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ModelsCache {
20    /// Timestamp when the cache was last fetched
21    pub fetched_at: DateTime<Utc>,
22    /// ETag for conditional requests (if provider supports it)
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub etag: Option<String>,
25    /// Provider this cache belongs to (e.g., "gemini", "openai")
26    pub provider: String,
27    /// Cached model information
28    pub models: Vec<ModelInfo>,
29}
30
31impl ModelsCache {
32    /// Create a new cache entry
33    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    /// Create a new cache entry with an ETag
43    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    /// Returns `true` when the cache entry has not exceeded the configured TTL.
53    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    /// Get the age of the cache entry
65    pub fn age(&self) -> chrono::Duration {
66        Utc::now().signed_duration_since(self.fetched_at)
67    }
68}
69
70/// Read and deserialize the cache file if it exists.
71pub 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
81/// Persist the cache contents to disk, creating parent directories as needed.
82pub 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
88/// Load cache synchronously (for initialization)
89pub 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
99/// Save cache synchronously
100pub 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}