1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5
6pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
11
12pub const LATEST_RELEASE_URL: &str =
14 "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
15
16pub const RELEASES_URL: &str =
18 "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
19
20pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
22
23pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
25
26pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
28
29pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
31
32pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
34
35pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
37
38pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
40
41pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
43
44const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
45const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ReleaseChannel {
50 Stable,
52 Beta,
54}
55
56impl ReleaseChannel {
57 pub fn from_beta_flag(beta: bool) -> Self {
59 if beta { Self::Beta } else { Self::Stable }
60 }
61
62 pub fn label(self) -> &'static str {
64 match self {
65 Self::Stable => "stable",
66 Self::Beta => "beta",
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ReleaseQuery {
74 Mirror { base_url: String, version: String },
76 GitHubLatest { url: &'static str },
78 GitHubReleaseList { url: &'static str },
80}
81
82pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery {
85 let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
86 if let Some(base_url) = release_base_url_from_env(&version) {
87 return ReleaseQuery::Mirror { base_url, version };
88 }
89
90 match channel {
91 ReleaseChannel::Stable => ReleaseQuery::GitHubLatest {
92 url: LATEST_RELEASE_URL,
93 },
94 ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL },
95 }
96}
97
98pub fn release_base_url_from_env(version: &str) -> Option<String> {
102 for env_name in [
103 RELEASE_BASE_URL_ENV,
104 LEGACY_RELEASE_BASE_URL_ENV,
105 DEEPSEEK_RELEASE_BASE_URL_ENV,
106 ] {
107 if let Ok(value) = std::env::var(env_name) {
108 let trimmed = value.trim().to_string();
109 if !trimmed.is_empty() {
110 return Some(trimmed);
111 }
112 }
113 }
114
115 if std::env::var(CNB_MIRROR_ENV).is_ok() {
116 return Some(cnb_release_base_url(version));
117 }
118 None
119}
120
121pub fn cnb_release_base_url(version: &str) -> String {
123 format!(
124 "{}/v{}",
125 CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
126 version.trim_start_matches('v')
127 )
128}
129
130pub fn update_version_from_env() -> Option<String> {
133 std::env::var(UPDATE_VERSION_ENV)
134 .ok()
135 .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
136 .map(|value| value.trim().trim_start_matches('v').to_string())
137 .filter(|value| !value.is_empty())
138}
139
140pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
142 format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
143}
144
145pub fn update_network_fallback_hint() -> String {
148 format!(
149 "GitHub release downloads may be blocked or slow on this network.\n\
150 For mainland China, use one of these fallback paths:\n\
151 1. Source build from the CNB mirror, installing both shipped binaries:\n\
152 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
153 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\
154 2. Use a binary asset mirror:\n\
155 {RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
156 The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
157 )
158}
159
160pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result<String> {
164 let client = reqwest::blocking::Client::builder()
165 .user_agent(UPDATE_USER_AGENT)
166 .timeout(RELEASE_METADATA_TIMEOUT)
167 .build()
168 .context("failed to build release check HTTP client")?;
169 let response = client
170 .get(url)
171 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
172 .send()
173 .with_context(|| format!("failed to fetch {description} from {url}"))?;
174 let status = response.status();
175 let body = response
176 .text()
177 .with_context(|| format!("failed to read {description} response from {url}"));
178 release_response_body(status, body, url, description)
179}
180
181pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
183 let client = reqwest::Client::builder()
184 .user_agent(UPDATE_USER_AGENT)
185 .timeout(RELEASE_METADATA_TIMEOUT)
186 .build()
187 .context("failed to build release check HTTP client")?;
188 let response = client
189 .get(url)
190 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
191 .send()
192 .await
193 .with_context(|| format!("failed to fetch {description} from {url}"))?;
194 let status = response.status();
195 let body = response
196 .text()
197 .await
198 .with_context(|| format!("failed to read {description} response from {url}"));
199 release_response_body(status, body, url, description)
200}
201
202fn release_response_body(
203 status: reqwest::StatusCode,
204 body: Result<String>,
205 url: &str,
206 description: &str,
207) -> Result<String> {
208 let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
209 if !status.is_success() {
210 bail!("GitHub release request failed with HTTP {status}: {body}");
211 }
212 Ok(body)
213}
214
215#[derive(Deserialize)]
216struct ReleaseTag {
217 tag_name: String,
218}
219
220#[derive(Deserialize)]
221struct ReleaseListEntry {
222 tag_name: String,
223}
224
225pub fn latest_tag_from_release_json(body: &str) -> Result<String> {
227 let release: ReleaseTag = serde_json::from_str(body).with_context(|| {
228 format!("failed to parse release JSON from GitHub API. Response: {body}")
229 })?;
230 Ok(release.tag_name)
231}
232
233pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result<String> {
236 let releases: Vec<ReleaseListEntry> = serde_json::from_str(body).with_context(|| {
237 format!("failed to parse release list JSON from GitHub API. Response: {body}")
238 })?;
239 releases
240 .into_iter()
241 .find(|release| is_beta_tag(&release.tag_name))
242 .map(|release| release.tag_name)
243 .context("no beta release found in GitHub releases")
244}
245
246pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result<String> {
251 match resolve_release_query(channel) {
252 ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
253 ReleaseQuery::GitHubLatest { url } => {
254 let body = fetch_release_json_async(url, "latest release").await?;
255 latest_tag_from_release_json(&body)
256 }
257 ReleaseQuery::GitHubReleaseList { url } => {
258 let body = fetch_release_json_async(url, "release list").await?;
259 latest_beta_tag_from_release_list_json(&body)
260 }
261 }
262}
263
264pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result<String> {
266 match resolve_release_query(channel) {
267 ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
268 ReleaseQuery::GitHubLatest { url } => {
269 let body = fetch_release_json_blocking(url, "latest release")?;
270 latest_tag_from_release_json(&body)
271 }
272 ReleaseQuery::GitHubReleaseList { url } => {
273 let body = fetch_release_json_blocking(url, "release list")?;
274 latest_beta_tag_from_release_list_json(&body)
275 }
276 }
277}
278
279pub fn compare_release_versions(
283 current_version: &str,
284 latest_tag: &str,
285) -> Result<std::cmp::Ordering> {
286 let current = parse_release_version(current_version)
287 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
288 let latest = parse_release_version(latest_tag)
289 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
290 Ok(current.cmp(&latest))
291}
292
293pub fn update_is_needed(
299 channel: ReleaseChannel,
300 current_version: &str,
301 latest_tag: &str,
302) -> Result<bool> {
303 let current = parse_release_version(current_version)
304 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
305 let latest = parse_release_version(latest_tag)
306 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
307
308 match channel {
309 ReleaseChannel::Stable => Ok(current < latest),
310 ReleaseChannel::Beta => {
311 if current == latest {
312 return Ok(false);
313 }
314 let latest_is_beta = version_is_beta(&latest);
315 let current_is_stable = current.pre.is_empty();
316 let same_release_line = current.major == latest.major
317 && current.minor == latest.minor
318 && current.patch == latest.patch;
319 if current > latest && !(current_is_stable && same_release_line) {
320 return Ok(false);
321 }
322 Ok(latest_is_beta)
323 }
324 }
325}
326
327pub fn parse_release_version(value: &str) -> Result<semver::Version> {
330 let version = value
331 .trim()
332 .trim_start_matches('v')
333 .split_whitespace()
334 .next()
335 .unwrap_or("");
336 semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
337}
338
339pub fn is_beta_tag(tag_name: &str) -> bool {
341 tag_name.to_ascii_lowercase().contains("beta")
342}
343
344fn version_is_beta(version: &semver::Version) -> bool {
345 version.pre.as_str().to_ascii_lowercase().contains("beta")
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn cnb_release_base_url_includes_tag_directory() {
354 assert_eq!(
355 cnb_release_base_url("0.8.47"),
356 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
357 );
358 assert_eq!(
359 cnb_release_base_url("v0.8.47"),
360 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
361 );
362 }
363
364 #[test]
365 fn stable_update_is_needed_only_when_latest_is_newer() {
366 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
367 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
368 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
369 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
370 assert!(
371 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
372 );
373 }
374
375 #[test]
376 fn beta_update_allows_switching_from_same_stable_to_beta() {
377 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
378 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
379 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
380 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
381 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
382 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
383 }
384
385 #[test]
386 fn parse_release_version_accepts_tags_and_build_suffixes() {
387 assert_eq!(
388 parse_release_version("v0.9.0-beta.1").unwrap(),
389 semver::Version::parse("0.9.0-beta.1").unwrap()
390 );
391 assert_eq!(
392 parse_release_version("0.8.45 (abcdef123456)").unwrap(),
393 semver::Version::parse("0.8.45").unwrap()
394 );
395 }
396
397 #[test]
398 fn release_version_compare_ignores_v_prefix_and_build_sha() {
399 assert_eq!(
400 compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
401 std::cmp::Ordering::Equal
402 );
403 assert_eq!(
404 compare_release_versions("0.8.39", "v0.8.40").unwrap(),
405 std::cmp::Ordering::Less
406 );
407 assert_eq!(
408 compare_release_versions("0.8.40", "v0.8.39").unwrap(),
409 std::cmp::Ordering::Greater
410 );
411 }
412
413 #[test]
414 fn latest_beta_tag_selects_first_beta_release() {
415 let body = r#"[
416 { "tag_name": "v0.9.0" },
417 { "tag_name": "v0.9.0-rc.1" },
418 { "tag_name": "v0.9.0-beta.2" },
419 { "tag_name": "v0.9.0-beta.1" }
420 ]"#;
421 assert_eq!(
422 latest_beta_tag_from_release_list_json(body).unwrap(),
423 "v0.9.0-beta.2"
424 );
425 }
426
427 #[test]
428 fn latest_beta_tag_reports_missing_beta() {
429 let body = r#"[{ "tag_name": "v0.9.0" }]"#;
430 let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
431 assert!(
432 err.to_string().contains("no beta release found"),
433 "unexpected error: {err:#}"
434 );
435 }
436}