1use anyhow::{Context, Result, anyhow};
7use semver::Version;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::sync::{OnceLock, RwLock};
11use vtcode_commons::VtCodePaths;
12
13static UPDATE_CONFIG_CACHE: OnceLock<RwLock<Option<UpdateConfig>>> = OnceLock::new();
14
15#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
17#[serde(rename_all = "lowercase")]
18pub enum ReleaseChannel {
19 #[default]
21 Stable,
22 Beta,
24 Nightly,
26 #[serde(other)]
28 Unknown,
29}
30
31impl std::fmt::Display for ReleaseChannel {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::Stable => write!(f, "stable"),
35 Self::Beta => write!(f, "beta"),
36 Self::Nightly => write!(f, "nightly"),
37 Self::Unknown => write!(f, "unknown"),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize, Default)]
44pub struct VersionPin {
45 version: Option<Version>,
47 pub reason: Option<String>,
49 #[serde(default)]
51 auto_unpin: bool,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct UpdateConfig {
57 #[serde(default)]
59 pub channel: ReleaseChannel,
60
61 #[serde(default)]
63 pub pin: Option<VersionPin>,
64
65 #[serde(default = "default_check_interval")]
67 pub check_interval_hours: u64,
68
69 #[serde(default = "default_download_timeout")]
71 pub download_timeout_secs: u64,
72}
73
74impl Default for UpdateConfig {
75 fn default() -> Self {
76 Self {
77 channel: ReleaseChannel::Stable,
78 pin: None,
79 check_interval_hours: default_check_interval(),
80 download_timeout_secs: default_download_timeout(),
81 }
82 }
83}
84
85fn default_check_interval() -> u64 {
86 24 }
88
89fn default_download_timeout() -> u64 {
90 300 }
92
93impl UpdateConfig {
94 pub fn load() -> Result<Self> {
96 let lock = UPDATE_CONFIG_CACHE.get_or_init(|| RwLock::new(None));
97
98 {
99 let cached = lock.read().map_err(|_error| anyhow!("update config cache is poisoned"))?;
100 if let Some(cached) = cached.as_ref() {
101 return Ok(cached.clone());
102 }
103 }
104
105 let config = Self::load_inner()?;
106 let _ = lock
107 .write()
108 .map_err(|_error| anyhow!("update config cache is poisoned"))?
109 .insert(config.clone());
110 Ok(config)
111 }
112
113 fn load_inner() -> Result<Self> {
114 let config_path = Self::config_path().context("Failed to determine update config path")?;
115
116 match std::fs::symlink_metadata(&config_path) {
117 Ok(metadata) if metadata.file_type().is_symlink() => {
118 return Err(anyhow!("Refusing to read symlinked update config: {}", config_path.display()));
119 }
120 Ok(metadata) if !metadata.is_file() => {
121 return Err(anyhow!("Update config is not a regular file: {}", config_path.display()));
122 }
123 Ok(_) => {}
124 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
125 Err(error) => {
126 return Err(error)
127 .with_context(|| format!("Failed to inspect update config: {}", config_path.display()));
128 }
129 }
130
131 let content = String::from_utf8(VtCodePaths::read_file_no_follow(&config_path)?)
132 .with_context(|| format!("Failed to read update config: {}", config_path.display()))?;
133
134 let config: UpdateConfig = toml::from_str(&content)
135 .with_context(|| format!("Failed to parse update config: {}", config_path.display()))?;
136
137 Ok(config)
138 }
139
140 pub fn save(&self) -> Result<()> {
142 let config_path = Self::config_path().context("Failed to determine update config path")?;
143
144 if let Some(parent) = config_path.parent() {
146 VtCodePaths::ensure_user_dir(parent)
147 .with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
148 }
149
150 let content = toml::to_string_pretty(self).context("Failed to serialize update config")?;
151
152 VtCodePaths::write_private_file_atomic(&config_path, content.as_bytes())
153 .with_context(|| format!("Failed to write update config: {}", config_path.display()))?;
154
155 if let Some(lock) = UPDATE_CONFIG_CACHE.get() {
157 lock.write()
158 .map_err(|_error| anyhow!("update config cache is poisoned"))?
159 .take();
160 }
161
162 Ok(())
163 }
164
165 pub fn config_path() -> Result<PathBuf> {
167 VtCodePaths::resolve()?
168 .config_path("update.toml")
169 .context("Failed to get update config path")
170 }
171
172 pub fn is_pinned(&self) -> bool {
174 self.pin.as_ref().is_some_and(|p| p.version.is_some())
175 }
176
177 pub fn pinned_version(&self) -> Option<&Version> {
179 self.pin.as_ref().and_then(|p| p.version.as_ref())
180 }
181
182 pub fn set_pin(&mut self, version: Version, reason: Option<String>, auto_unpin: bool) {
184 self.pin = Some(VersionPin { version: Some(version), reason, auto_unpin });
185 }
186
187 pub fn clear_pin(&mut self) {
189 self.pin = None;
190 }
191
192 pub fn should_auto_unpin(&self) -> bool {
196 self.pin.as_ref().is_some_and(|p| p.auto_unpin && p.version.is_some())
197 }
198
199 pub fn is_check_due(&self, last_check: Option<std::time::SystemTime>) -> bool {
201 if self.check_interval_hours == 0 {
202 return false; }
204
205 let Some(last_check) = last_check else {
206 return true; };
208
209 let elapsed = std::time::SystemTime::now().duration_since(last_check).unwrap_or_default();
210
211 elapsed >= std::time::Duration::from_secs(self.check_interval_hours * 3600)
212 }
213}
214
215pub fn create_example_config() -> String {
217 r#"# VT Code Update Configuration
218# Location: the canonical user config directory/update.toml
219
220# Release channel to follow
221# Options: stable (default), beta, nightly
222channel = "stable"
223
224# Version pinning (optional)
225# Uncomment to pin to a specific version
226# [pin]
227# version = "0.85.3"
228# reason = "Waiting for bug fix in next release"
229# auto_unpin = false
230
231# Auto-update check interval in hours (0 = disable)
232check_interval_hours = 24
233
234# Download timeout in seconds
235download_timeout_secs = 300
236"#
237 .to_string()
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn test_default_config() {
246 let config = UpdateConfig::default();
247 assert_eq!(config.channel, ReleaseChannel::Stable);
248 assert_eq!(config.check_interval_hours, 24);
249 assert_eq!(config.download_timeout_secs, 300);
250 }
251
252 #[test]
253 fn test_release_channel_display() {
254 assert_eq!(ReleaseChannel::Stable.to_string(), "stable");
255 assert_eq!(ReleaseChannel::Beta.to_string(), "beta");
256 assert_eq!(ReleaseChannel::Nightly.to_string(), "nightly");
257 }
258
259 #[test]
260 fn test_version_pin() {
261 let mut config = UpdateConfig::default();
262 let version = Version::parse("0.85.3").unwrap();
263 config.set_pin(version.clone(), Some("Testing".to_string()), false);
264
265 assert!(config.is_pinned());
266 assert_eq!(config.pinned_version(), Some(&version));
267 assert!(!config.should_auto_unpin());
268
269 config.clear_pin();
270 assert!(!config.is_pinned());
271 }
272
273 #[test]
274 fn test_auto_unpin() {
275 let mut config = UpdateConfig::default();
276 let version = Version::parse("0.85.3").unwrap();
277 config.set_pin(version, Some("Temporary".to_string()), true);
278
279 assert!(config.is_pinned());
280 assert!(config.should_auto_unpin());
281 }
282
283 #[test]
284 fn test_deserialize_with_unknown_fields() {
285 let toml = r#"
289channel = "stable"
290check_interval_hours = 12
291download_timeout_secs = 60
292keep_backup = true
293auto_rollback = false
294"#;
295 let config: UpdateConfig = toml::from_str(toml).unwrap();
296 assert_eq!(config.check_interval_hours, 12);
297 }
298}