1use 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#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
15#[serde(rename_all = "lowercase")]
16pub enum ReleaseChannel {
17 Stable,
19 Beta,
21 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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
43pub struct MirrorConfig {
44 pub primary: Option<String>,
46 #[serde(default)]
48 pub fallbacks: Vec<String>,
49 #[serde(default = "default_true")]
51 pub geo_select: bool,
52}
53
54#[derive(Debug, Clone, Deserialize, Serialize, Default)]
56pub struct VersionPin {
57 pub version: Option<Version>,
59 pub reason: Option<String>,
61 #[serde(default)]
63 pub auto_unpin: bool,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68pub struct UpdateConfig {
69 #[serde(default)]
71 pub channel: ReleaseChannel,
72
73 #[serde(default)]
75 pub pin: Option<VersionPin>,
76
77 #[serde(default)]
79 pub mirrors: MirrorConfig,
80
81 #[serde(default = "default_check_interval")]
83 pub check_interval_hours: u64,
84
85 #[serde(default = "default_download_timeout")]
87 pub download_timeout_secs: u64,
88
89 #[serde(default = "default_true")]
91 pub keep_backup: bool,
92
93 #[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 }
119
120fn default_download_timeout() -> u64 {
121 300 }
123
124impl UpdateConfig {
125 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 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 pub fn save(&self) -> Result<()> {
145 let config_path = Self::config_path().context("Failed to determine update config path")?;
146
147 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 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 pub fn is_pinned(&self) -> bool {
170 self.pin.as_ref().map_or(false, |p| p.version.is_some())
171 }
172
173 pub fn pinned_version(&self) -> Option<&Version> {
175 self.pin.as_ref().and_then(|p| p.version.as_ref())
176 }
177
178 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 pub fn clear_pin(&mut self) {
189 self.pin = None;
190 }
191
192 pub fn is_check_due(&self, last_check: Option<std::time::SystemTime>) -> bool {
194 if self.check_interval_hours == 0 {
195 return false; }
197
198 let Some(last_check) = last_check else {
199 return true; };
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
210pub 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}