use crate::defaults::get_config_dir;
use anyhow::{Context, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseChannel {
#[default]
Stable,
Beta,
Nightly,
}
impl std::fmt::Display for ReleaseChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stable => write!(f, "stable"),
Self::Beta => write!(f, "beta"),
Self::Nightly => write!(f, "nightly"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct MirrorConfig {
pub primary: Option<String>,
#[serde(default)]
pub fallbacks: Vec<String>,
#[serde(default = "default_true")]
pub geo_select: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct VersionPin {
pub version: Option<Version>,
pub reason: Option<String>,
#[serde(default)]
pub auto_unpin: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UpdateConfig {
#[serde(default)]
pub channel: ReleaseChannel,
#[serde(default)]
pub pin: Option<VersionPin>,
#[serde(default)]
pub mirrors: MirrorConfig,
#[serde(default = "default_check_interval")]
pub check_interval_hours: u64,
#[serde(default = "default_download_timeout")]
pub download_timeout_secs: u64,
#[serde(default = "default_true")]
pub keep_backup: bool,
#[serde(default)]
pub auto_rollback: bool,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
channel: ReleaseChannel::Stable,
pin: None,
mirrors: MirrorConfig::default(),
check_interval_hours: default_check_interval(),
download_timeout_secs: default_download_timeout(),
keep_backup: true,
auto_rollback: false,
}
}
}
fn default_true() -> bool {
true
}
fn default_check_interval() -> u64 {
24 }
fn default_download_timeout() -> u64 {
300 }
impl UpdateConfig {
pub fn load() -> Result<Self> {
let config_path = Self::config_path().context("Failed to determine update config path")?;
if !config_path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read update config: {}", config_path.display()))?;
let config: UpdateConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse update config: {}", config_path.display()))?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path().context("Failed to determine update config path")?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize update config")?;
fs::write(&config_path, content)
.with_context(|| format!("Failed to write update config: {}", config_path.display()))?;
Ok(())
}
pub fn config_path() -> Result<PathBuf> {
let config_dir = get_config_dir().context("Failed to get config directory")?;
Ok(config_dir.join("update.toml"))
}
pub fn is_pinned(&self) -> bool {
self.pin.as_ref().is_some_and(|p| p.version.is_some())
}
pub fn pinned_version(&self) -> Option<&Version> {
self.pin.as_ref().and_then(|p| p.version.as_ref())
}
pub fn set_pin(&mut self, version: Version, reason: Option<String>, auto_unpin: bool) {
self.pin = Some(VersionPin {
version: Some(version),
reason,
auto_unpin,
});
}
pub fn clear_pin(&mut self) {
self.pin = None;
}
pub fn is_check_due(&self, last_check: Option<std::time::SystemTime>) -> bool {
if self.check_interval_hours == 0 {
return false; }
let Some(last_check) = last_check else {
return true; };
let elapsed = std::time::SystemTime::now()
.duration_since(last_check)
.unwrap_or_default();
elapsed >= std::time::Duration::from_secs(self.check_interval_hours * 3600)
}
}
pub fn create_example_config() -> String {
r#"# VT Code Update Configuration
# Location: ~/.vtcode/update.toml
# Release channel to follow
# Options: stable (default), beta, nightly
channel = "stable"
# Version pinning (optional)
# Uncomment to pin to a specific version
# [pin]
# version = "0.85.3"
# reason = "Waiting for bug fix in next release"
# auto_unpin = false
# Download mirrors (optional)
# [mirrors]
# primary = "https://github.com/vinhnx/vtcode/releases"
# fallbacks = [
# "https://mirror.example.com/vtcode",
# ]
# geo_select = true
# Auto-update check interval in hours (0 = disable)
check_interval_hours = 24
# Download timeout in seconds
download_timeout_secs = 300
# Keep backup of previous version after update
keep_backup = true
# Auto-rollback on startup if new version fails
auto_rollback = false
"#
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = UpdateConfig::default();
assert_eq!(config.channel, ReleaseChannel::Stable);
assert_eq!(config.check_interval_hours, 24);
assert_eq!(config.download_timeout_secs, 300);
assert!(config.keep_backup);
assert!(!config.auto_rollback);
}
#[test]
fn test_release_channel_display() {
assert_eq!(ReleaseChannel::Stable.to_string(), "stable");
assert_eq!(ReleaseChannel::Beta.to_string(), "beta");
assert_eq!(ReleaseChannel::Nightly.to_string(), "nightly");
}
#[test]
fn test_version_pin() {
let mut config = UpdateConfig::default();
let version = Version::parse("0.85.3").unwrap();
config.set_pin(version.clone(), Some("Testing".to_string()), false);
assert!(config.is_pinned());
assert_eq!(config.pinned_version(), Some(&version));
config.clear_pin();
assert!(!config.is_pinned());
}
}