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 `~/.vtcode/update.toml`.
5
6use crate::defaults::get_config_dir;
7use anyhow::{Context, Result};
8use semver::Version;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13/// Release channel for VT Code updates
14#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum ReleaseChannel {
17    /// Stable releases (default)
18    #[default]
19    Stable,
20    /// Beta releases (pre-release testing)
21    Beta,
22    /// Nightly builds (bleeding edge)
23    Nightly,
24}
25
26impl std::fmt::Display for ReleaseChannel {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Stable => write!(f, "stable"),
30            Self::Beta => write!(f, "beta"),
31            Self::Nightly => write!(f, "nightly"),
32        }
33    }
34}
35
36/// Mirror configuration for download fallback
37#[derive(Debug, Clone, Deserialize, Serialize, Default)]
38pub struct MirrorConfig {
39    /// Primary mirror URL (GitHub Releases by default)
40    pub primary: Option<String>,
41    /// Fallback mirrors in order of preference
42    #[serde(default)]
43    pub fallbacks: Vec<String>,
44    /// Enable geographic mirror selection
45    #[serde(default = "default_true")]
46    pub geo_select: bool,
47}
48
49/// Version pinning configuration
50#[derive(Debug, Clone, Deserialize, Serialize, Default)]
51pub struct VersionPin {
52    /// Pinned version (if set, auto-update will stay on this version)
53    pub version: Option<Version>,
54    /// Reason for pinning (user note)
55    pub reason: Option<String>,
56    /// Auto-unpin after successful update check (for temporary pins)
57    #[serde(default)]
58    pub auto_unpin: bool,
59}
60
61/// Update configuration
62#[derive(Debug, Clone, Deserialize, Serialize)]
63pub struct UpdateConfig {
64    /// Release channel to follow
65    #[serde(default)]
66    pub channel: ReleaseChannel,
67
68    /// Pinned version (None = follow channel latest)
69    #[serde(default)]
70    pub pin: Option<VersionPin>,
71
72    /// Mirror configuration
73    #[serde(default)]
74    pub mirrors: MirrorConfig,
75
76    /// Auto-update check interval in hours (0 = disable)
77    #[serde(default = "default_check_interval")]
78    pub check_interval_hours: u64,
79
80    /// Download timeout in seconds
81    #[serde(default = "default_download_timeout")]
82    pub download_timeout_secs: u64,
83
84    /// Keep backup of previous version after update
85    #[serde(default = "default_true")]
86    pub keep_backup: bool,
87
88    /// Auto-rollback on startup if new version fails
89    #[serde(default)]
90    pub auto_rollback: bool,
91}
92
93impl Default for UpdateConfig {
94    fn default() -> Self {
95        Self {
96            channel: ReleaseChannel::Stable,
97            pin: None,
98            mirrors: MirrorConfig::default(),
99            check_interval_hours: default_check_interval(),
100            download_timeout_secs: default_download_timeout(),
101            keep_backup: true,
102            auto_rollback: false,
103        }
104    }
105}
106
107fn default_true() -> bool {
108    true
109}
110
111fn default_check_interval() -> u64 {
112    24 // Check daily by default
113}
114
115fn default_download_timeout() -> u64 {
116    300 // 5 minutes
117}
118
119impl UpdateConfig {
120    /// Load update configuration from default location
121    pub fn load() -> Result<Self> {
122        let config_path = Self::config_path().context("Failed to determine update config path")?;
123
124        if !config_path.exists() {
125            // Return defaults if config doesn't exist
126            return Ok(Self::default());
127        }
128
129        let content = fs::read_to_string(&config_path)
130            .with_context(|| format!("Failed to read update config: {}", config_path.display()))?;
131
132        let config: UpdateConfig = toml::from_str(&content)
133            .with_context(|| format!("Failed to parse update config: {}", config_path.display()))?;
134
135        Ok(config)
136    }
137
138    /// Save update configuration to default location
139    pub fn save(&self) -> Result<()> {
140        let config_path = Self::config_path().context("Failed to determine update config path")?;
141
142        // Ensure directory exists
143        if let Some(parent) = config_path.parent() {
144            fs::create_dir_all(parent).with_context(|| {
145                format!("Failed to create config directory: {}", parent.display())
146            })?;
147        }
148
149        let content = toml::to_string_pretty(self).context("Failed to serialize update config")?;
150
151        fs::write(&config_path, content)
152            .with_context(|| format!("Failed to write update config: {}", config_path.display()))?;
153
154        Ok(())
155    }
156
157    /// Get the configuration file path
158    pub fn config_path() -> Result<PathBuf> {
159        let config_dir = get_config_dir().context("Failed to get config directory")?;
160        Ok(config_dir.join("update.toml"))
161    }
162
163    /// Check if version is pinned
164    pub fn is_pinned(&self) -> bool {
165        self.pin.as_ref().is_some_and(|p| p.version.is_some())
166    }
167
168    /// Get pinned version if set
169    pub fn pinned_version(&self) -> Option<&Version> {
170        self.pin.as_ref().and_then(|p| p.version.as_ref())
171    }
172
173    /// Set version pin
174    pub fn set_pin(&mut self, version: Version, reason: Option<String>, auto_unpin: bool) {
175        self.pin = Some(VersionPin {
176            version: Some(version),
177            reason,
178            auto_unpin,
179        });
180    }
181
182    /// Clear version pin
183    pub fn clear_pin(&mut self) {
184        self.pin = None;
185    }
186
187    /// Check if update check is due based on interval
188    pub fn is_check_due(&self, last_check: Option<std::time::SystemTime>) -> bool {
189        if self.check_interval_hours == 0 {
190            return false; // Checks disabled
191        }
192
193        let Some(last_check) = last_check else {
194            return true; // Never checked before
195        };
196
197        let elapsed = std::time::SystemTime::now()
198            .duration_since(last_check)
199            .unwrap_or_default();
200
201        elapsed >= std::time::Duration::from_secs(self.check_interval_hours * 3600)
202    }
203}
204
205/// Create example update configuration
206pub fn create_example_config() -> String {
207    r#"# VT Code Update Configuration
208# Location: ~/.vtcode/update.toml
209
210# Release channel to follow
211# Options: stable (default), beta, nightly
212channel = "stable"
213
214# Version pinning (optional)
215# Uncomment to pin to a specific version
216# [pin]
217# version = "0.85.3"
218# reason = "Waiting for bug fix in next release"
219# auto_unpin = false
220
221# Download mirrors (optional)
222# [mirrors]
223# primary = "https://github.com/vinhnx/vtcode/releases"
224# fallbacks = [
225#     "https://mirror.example.com/vtcode",
226# ]
227# geo_select = true
228
229# Auto-update check interval in hours (0 = disable)
230check_interval_hours = 24
231
232# Download timeout in seconds
233download_timeout_secs = 300
234
235# Keep backup of previous version after update
236keep_backup = true
237
238# Auto-rollback on startup if new version fails
239auto_rollback = false
240"#
241    .to_string()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_default_config() {
250        let config = UpdateConfig::default();
251        assert_eq!(config.channel, ReleaseChannel::Stable);
252        assert_eq!(config.check_interval_hours, 24);
253        assert_eq!(config.download_timeout_secs, 300);
254        assert!(config.keep_backup);
255        assert!(!config.auto_rollback);
256    }
257
258    #[test]
259    fn test_release_channel_display() {
260        assert_eq!(ReleaseChannel::Stable.to_string(), "stable");
261        assert_eq!(ReleaseChannel::Beta.to_string(), "beta");
262        assert_eq!(ReleaseChannel::Nightly.to_string(), "nightly");
263    }
264
265    #[test]
266    fn test_version_pin() {
267        let mut config = UpdateConfig::default();
268        let version = Version::parse("0.85.3").unwrap();
269        config.set_pin(version.clone(), Some("Testing".to_string()), false);
270
271        assert!(config.is_pinned());
272        assert_eq!(config.pinned_version(), Some(&version));
273
274        config.clear_pin();
275        assert!(!config.is_pinned());
276    }
277}