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