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