Skip to main content

vtcode_config/
update.rs

1//! Update configuration for VT Code auto-updater
2//!
3//! Manages release channel preferences, version pinning, and download mirrors.
4//! Configuration stored in the canonical user config directory's `update.toml`.
5
6use 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/// Release channel for VT Code updates
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
17#[serde(rename_all = "lowercase")]
18pub enum ReleaseChannel {
19    /// Stable releases (default)
20    #[default]
21    Stable,
22    /// Beta releases (pre-release testing)
23    Beta,
24    /// Nightly builds (bleeding edge)
25    Nightly,
26    /// Catch-all for unknown channels added by future versions.
27    #[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/// Version pinning configuration
43#[derive(Debug, Clone, Deserialize, Serialize, Default)]
44pub struct VersionPin {
45    /// Pinned version (if set, auto-update will stay on this version)
46    version: Option<Version>,
47    /// Reason for pinning (user note)
48    pub reason: Option<String>,
49    /// Auto-unpin after successful update check (for temporary pins)
50    #[serde(default)]
51    auto_unpin: bool,
52}
53
54/// Update configuration
55#[derive(Debug, Clone, Deserialize, Serialize)]
56pub struct UpdateConfig {
57    /// Release channel to follow
58    #[serde(default)]
59    pub channel: ReleaseChannel,
60
61    /// Pinned version (None = follow channel latest)
62    #[serde(default)]
63    pub pin: Option<VersionPin>,
64
65    /// Auto-update check interval in hours (0 = disable)
66    #[serde(default = "default_check_interval")]
67    pub check_interval_hours: u64,
68
69    /// Download timeout in seconds
70    #[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 // Check daily by default
87}
88
89fn default_download_timeout() -> u64 {
90    300 // 5 minutes
91}
92
93impl UpdateConfig {
94    /// Load update configuration from default location
95    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    /// Save update configuration to default location
141    pub fn save(&self) -> Result<()> {
142        let config_path = Self::config_path().context("Failed to determine update config path")?;
143
144        // Ensure directory exists
145        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        // Invalidate the in-process cache so the next load() observes the new file.
156        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    /// Get the configuration file path
166    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    /// Check if version is pinned
173    pub fn is_pinned(&self) -> bool {
174        self.pin.as_ref().is_some_and(|p| p.version.is_some())
175    }
176
177    /// Get pinned version if set
178    pub fn pinned_version(&self) -> Option<&Version> {
179        self.pin.as_ref().and_then(|p| p.version.as_ref())
180    }
181
182    /// Set version pin
183    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    /// Clear version pin
188    pub fn clear_pin(&mut self) {
189        self.pin = None;
190    }
191
192    /// Whether the current pin should be automatically removed when a newer
193    /// release is available. Returns `false` when not pinned or when
194    /// `auto_unpin` was not set.
195    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    /// Check if update check is due based on interval
200    pub fn is_check_due(&self, last_check: Option<std::time::SystemTime>) -> bool {
201        if self.check_interval_hours == 0 {
202            return false; // Checks disabled
203        }
204
205        let Some(last_check) = last_check else {
206            return true; // Never checked before
207        };
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
215/// Create example update configuration
216pub 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        // Configs from older versions may contain removed fields like
286        // keep_backup, auto_rollback, or mirrors. serde(ignore) via
287        // deny_unknown_fields=false (the default) silently skips them.
288        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}