1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct UpdateStatusSnapshot {
11 pub current_version: String,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub latest_version: Option<String>,
20 #[serde(default)]
22 pub update_available: bool,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub release_url: Option<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub release_highlights: Vec<String>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub current_release_highlights: Vec<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub current_release_url: Option<String>,
40 #[serde(default)]
44 pub current_release_seen: bool,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub channel: Option<String>,
48 #[serde(default)]
50 pub check_due: bool,
51 #[serde(default)]
55 pub install: UpdateInstallState,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", tag = "phase", rename_all_fields = "camelCase")]
66pub enum UpdateInstallState {
67 #[default]
69 Idle,
70 Downloading {
72 downloaded: u64,
74 total: Option<u64>,
77 },
78 Verifying,
80 Extracting,
82 Installing,
84 Completed {
86 version: String,
88 #[serde(default)]
93 needs_gatekeeper_approval: bool,
94 },
95 Failed {
97 message: String,
99 },
100}
101
102impl UpdateInstallState {
103 pub fn is_in_flight(&self) -> bool {
106 matches!(self, Self::Downloading { .. } | Self::Verifying | Self::Extracting | Self::Installing)
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn update_status_snapshot_round_trips_and_older_payloads_decode() {
116 let snapshot = UpdateStatusSnapshot {
117 current_version: "0.180.5".into(),
118 latest_version: Some("0.181.0".into()),
119 update_available: true,
120 release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.181.0".into()),
121 release_highlights: vec!["Faster startup".into(), "New model picker".into()],
122 current_release_highlights: vec!["Older highlight".into()],
123 current_release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.180.5".into()),
124 current_release_seen: true,
125 channel: Some("stable".into()),
126 check_due: false,
127 install: UpdateInstallState::Downloading { downloaded: 1024, total: Some(2048) },
128 };
129 let decoded: UpdateStatusSnapshot = serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap();
130 assert_eq!(decoded, snapshot);
131
132 let quiet = UpdateStatusSnapshot {
136 current_version: "0.181.0".into(),
137 latest_version: None,
138 update_available: false,
139 release_url: None,
140 release_highlights: Vec::new(),
141 current_release_highlights: vec!["Freshly installed".into()],
142 current_release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.181.0".into()),
143 current_release_seen: false,
144 channel: None,
145 check_due: true,
146 install: UpdateInstallState::Idle,
147 };
148 let decoded_quiet: UpdateStatusSnapshot =
149 serde_json::from_value(serde_json::to_value(&quiet).unwrap()).unwrap();
150 assert_eq!(decoded_quiet, quiet);
151
152 let legacy: UpdateStatusSnapshot =
153 serde_json::from_value(serde_json::json!({ "currentVersion": "0.179.0" })).unwrap();
154 assert_eq!(legacy.current_version, "0.179.0");
155 assert!(legacy.latest_version.is_none());
156 assert!(!legacy.update_available);
157 assert!(legacy.release_url.is_none());
158 assert!(legacy.release_highlights.is_empty());
159 assert!(legacy.current_release_highlights.is_empty());
160 assert!(legacy.current_release_url.is_none());
161 assert!(!legacy.current_release_seen);
162 assert!(legacy.channel.is_none());
163 assert!(!legacy.check_due);
164 assert_eq!(legacy.install, UpdateInstallState::Idle);
165 }
166
167 #[test]
170 fn install_state_round_trips_with_a_named_phase() {
171 let states = [
172 UpdateInstallState::Idle,
173 UpdateInstallState::Downloading { downloaded: 1846, total: Some(18462109) },
174 UpdateInstallState::Downloading { downloaded: 4096, total: None },
177 UpdateInstallState::Verifying,
178 UpdateInstallState::Extracting,
179 UpdateInstallState::Installing,
180 UpdateInstallState::Completed {
181 version: "0.181.8".into(),
182 needs_gatekeeper_approval: false,
183 },
184 UpdateInstallState::Failed { message: "network unreachable".into() },
185 ];
186
187 for state in states {
188 let encoded = serde_json::to_value(&state).unwrap();
189 let decoded: UpdateInstallState = serde_json::from_value(encoded.clone()).unwrap();
190 assert_eq!(decoded, state);
191 assert!(encoded.get("phase").is_some_and(|phase| phase.is_string()), "{encoded}");
192 }
193
194 let gated = UpdateInstallState::Completed {
196 version: "0.181.8".into(),
197 needs_gatekeeper_approval: true,
198 };
199 let decoded: UpdateInstallState = serde_json::from_value(serde_json::to_value(&gated).unwrap()).unwrap();
200 assert_eq!(decoded, gated);
201
202 let legacy: UpdateInstallState =
205 serde_json::from_value(serde_json::json!({ "phase": "completed", "version": "0.181.8" })).unwrap();
206 assert_eq!(
207 legacy,
208 UpdateInstallState::Completed {
209 version: "0.181.8".into(),
210 needs_gatekeeper_approval: false
211 }
212 );
213
214 assert_eq!(
215 serde_json::to_value(UpdateInstallState::Downloading { downloaded: 7, total: None }).unwrap(),
216 serde_json::json!({ "phase": "downloading", "downloaded": 7, "total": null })
217 );
218 }
219
220 #[test]
223 fn a_payload_without_install_decodes_as_idle() {
224 let decoded: UpdateStatusSnapshot =
225 serde_json::from_value(serde_json::json!({ "currentVersion": "0.181.7" })).unwrap();
226
227 assert_eq!(decoded.install, UpdateInstallState::Idle);
228 }
229
230 #[test]
231 fn only_active_phases_report_an_install_in_flight() {
232 assert!(!UpdateInstallState::Idle.is_in_flight());
233 assert!(
234 !UpdateInstallState::Completed {
235 version: "0.181.8".into(),
236 needs_gatekeeper_approval: true
237 }
238 .is_in_flight()
239 );
240 assert!(!UpdateInstallState::Failed { message: "boom".into() }.is_in_flight());
241 assert!(UpdateInstallState::Downloading { downloaded: 1, total: Some(2) }.is_in_flight());
242 assert!(UpdateInstallState::Verifying.is_in_flight());
243 assert!(UpdateInstallState::Extracting.is_in_flight());
244 assert!(UpdateInstallState::Installing.is_in_flight());
245 }
246}