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
47pub fn platform_http_client_builder() -> reqwest::ClientBuilder {
56 let _ = rustls::crypto::ring::default_provider().install_default();
57 let builder = reqwest::Client::builder();
58 #[cfg(target_os = "android")]
59 {
60 builder.tls_backend_preconfigured(android_rustls_config())
61 }
62 #[cfg(not(target_os = "android"))]
63 {
64 builder
65 }
66}
67
68pub fn platform_blocking_http_client_builder() -> reqwest::blocking::ClientBuilder {
70 let _ = rustls::crypto::ring::default_provider().install_default();
71 let builder = reqwest::blocking::Client::builder();
72 #[cfg(target_os = "android")]
73 {
74 builder.tls_backend_preconfigured(android_rustls_config())
75 }
76 #[cfg(not(target_os = "android"))]
77 {
78 builder
79 }
80}
81
82#[cfg(target_os = "android")]
83fn android_rustls_config() -> rustls::ClientConfig {
84 let mut roots = rustls::RootCertStore::empty();
85 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
86 rustls::ClientConfig::builder()
87 .with_root_certificates(roots)
88 .with_no_client_auth()
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ReleaseChannel {
94 Stable,
96 Beta,
98}
99
100impl ReleaseChannel {
101 pub fn from_beta_flag(beta: bool) -> Self {
103 if beta { Self::Beta } else { Self::Stable }
104 }
105
106 pub fn label(self) -> &'static str {
108 match self {
109 Self::Stable => "stable",
110 Self::Beta => "beta",
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum ReleaseQuery {
118 Mirror { base_url: String, version: String },
120 GitHubLatest { url: &'static str },
122 GitHubReleaseList { url: &'static str },
124}
125
126pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery {
129 let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
130 if let Some(base_url) = release_base_url_from_env(&version) {
131 return ReleaseQuery::Mirror { base_url, version };
132 }
133
134 match channel {
135 ReleaseChannel::Stable => ReleaseQuery::GitHubLatest {
136 url: LATEST_RELEASE_URL,
137 },
138 ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL },
139 }
140}
141
142pub fn release_base_url_from_env(version: &str) -> Option<String> {
146 for env_name in [
147 RELEASE_BASE_URL_ENV,
148 LEGACY_RELEASE_BASE_URL_ENV,
149 DEEPSEEK_RELEASE_BASE_URL_ENV,
150 ] {
151 if let Ok(value) = std::env::var(env_name) {
152 let trimmed = value.trim().to_string();
153 if !trimmed.is_empty() {
154 return Some(trimmed);
155 }
156 }
157 }
158
159 if std::env::var(CNB_MIRROR_ENV).is_ok() {
160 return Some(cnb_release_base_url(version));
161 }
162 None
163}
164
165pub fn cnb_release_base_url(version: &str) -> String {
167 format!(
168 "{}/v{}",
169 CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
170 version.trim_start_matches('v')
171 )
172}
173
174pub fn update_version_from_env() -> Option<String> {
177 std::env::var(UPDATE_VERSION_ENV)
178 .ok()
179 .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
180 .map(|value| value.trim().trim_start_matches('v').to_string())
181 .filter(|value| !value.is_empty())
182}
183
184pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
186 format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
187}
188
189pub fn update_network_fallback_hint() -> String {
192 format!(
193 "GitHub release downloads may be blocked or slow on this network.\n\
194 For mainland China, use one of these fallback paths:\n\
195 1. Source build from the CNB mirror, installing both shipped binaries:\n\
196 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
197 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\
198 2. Use a binary asset mirror:\n\
199 {RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
200 The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
201 )
202}
203
204pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result<String> {
208 let client = platform_blocking_http_client_builder()
209 .user_agent(UPDATE_USER_AGENT)
210 .timeout(RELEASE_METADATA_TIMEOUT)
211 .build()
212 .context("failed to build release check HTTP client")?;
213 let response = client
214 .get(url)
215 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
216 .send()
217 .with_context(|| format!("failed to fetch {description} from {url}"))?;
218 let status = response.status();
219 let body = response
220 .text()
221 .with_context(|| format!("failed to read {description} response from {url}"));
222 release_response_body(status, body, url, description)
223}
224
225pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
227 let client = platform_http_client_builder()
228 .user_agent(UPDATE_USER_AGENT)
229 .timeout(RELEASE_METADATA_TIMEOUT)
230 .build()
231 .context("failed to build release check HTTP client")?;
232 let response = client
233 .get(url)
234 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
235 .send()
236 .await
237 .with_context(|| format!("failed to fetch {description} from {url}"))?;
238 let status = response.status();
239 let body = response
240 .text()
241 .await
242 .with_context(|| format!("failed to read {description} response from {url}"));
243 release_response_body(status, body, url, description)
244}
245
246fn release_response_body(
247 status: reqwest::StatusCode,
248 body: Result<String>,
249 url: &str,
250 description: &str,
251) -> Result<String> {
252 let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
253 if !status.is_success() {
254 bail!("GitHub release request failed with HTTP {status}: {body}");
255 }
256 Ok(body)
257}
258
259#[derive(Deserialize)]
260struct ReleaseTag {
261 tag_name: String,
262}
263
264#[derive(Deserialize)]
265struct ReleaseListEntry {
266 tag_name: String,
267}
268
269pub fn latest_tag_from_release_json(body: &str) -> Result<String> {
271 let release: ReleaseTag = serde_json::from_str(body).with_context(|| {
272 format!("failed to parse release JSON from GitHub API. Response: {body}")
273 })?;
274 Ok(release.tag_name)
275}
276
277pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result<String> {
280 let releases: Vec<ReleaseListEntry> = serde_json::from_str(body).with_context(|| {
281 format!("failed to parse release list JSON from GitHub API. Response: {body}")
282 })?;
283 releases
284 .into_iter()
285 .find(|release| is_beta_tag(&release.tag_name))
286 .map(|release| release.tag_name)
287 .context("no beta release found in GitHub releases")
288}
289
290pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result<String> {
295 match resolve_release_query(channel) {
296 ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
297 ReleaseQuery::GitHubLatest { url } => {
298 let body = fetch_release_json_async(url, "latest release").await?;
299 latest_tag_from_release_json(&body)
300 }
301 ReleaseQuery::GitHubReleaseList { url } => {
302 let body = fetch_release_json_async(url, "release list").await?;
303 latest_beta_tag_from_release_list_json(&body)
304 }
305 }
306}
307
308pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result<String> {
310 match resolve_release_query(channel) {
311 ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
312 ReleaseQuery::GitHubLatest { url } => {
313 let body = fetch_release_json_blocking(url, "latest release")?;
314 latest_tag_from_release_json(&body)
315 }
316 ReleaseQuery::GitHubReleaseList { url } => {
317 let body = fetch_release_json_blocking(url, "release list")?;
318 latest_beta_tag_from_release_list_json(&body)
319 }
320 }
321}
322
323pub fn compare_release_versions(
327 current_version: &str,
328 latest_tag: &str,
329) -> Result<std::cmp::Ordering> {
330 let current = parse_release_version(current_version)
331 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
332 let latest = parse_release_version(latest_tag)
333 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
334 Ok(current.cmp(&latest))
335}
336
337pub fn update_is_needed(
343 channel: ReleaseChannel,
344 current_version: &str,
345 latest_tag: &str,
346) -> Result<bool> {
347 let current = parse_release_version(current_version)
348 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
349 let latest = parse_release_version(latest_tag)
350 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
351
352 match channel {
353 ReleaseChannel::Stable => Ok(current < latest),
354 ReleaseChannel::Beta => {
355 if current == latest {
356 return Ok(false);
357 }
358 let latest_is_beta = version_is_beta(&latest);
359 let current_is_stable = current.pre.is_empty();
360 let same_release_line = current.major == latest.major
361 && current.minor == latest.minor
362 && current.patch == latest.patch;
363 if current > latest && !(current_is_stable && same_release_line) {
364 return Ok(false);
365 }
366 Ok(latest_is_beta)
367 }
368 }
369}
370
371pub fn parse_release_version(value: &str) -> Result<semver::Version> {
374 let version = value
375 .trim()
376 .trim_start_matches('v')
377 .split_whitespace()
378 .next()
379 .unwrap_or("");
380 semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
381}
382
383pub fn is_beta_tag(tag_name: &str) -> bool {
385 tag_name.to_ascii_lowercase().contains("beta")
386}
387
388fn version_is_beta(version: &semver::Version) -> bool {
389 version.pre.as_str().to_ascii_lowercase().contains("beta")
390}
391
392#[cfg(test)]
393mod tests {
394 use std::ffi::OsString;
395 use std::sync::{Mutex, MutexGuard};
396
397 use super::*;
398
399 static RELEASE_ENV_LOCK: Mutex<()> = Mutex::new(());
400 const RELEASE_ENV_VARS: &[&str] = &[
401 RELEASE_BASE_URL_ENV,
402 LEGACY_RELEASE_BASE_URL_ENV,
403 DEEPSEEK_RELEASE_BASE_URL_ENV,
404 CNB_MIRROR_ENV,
405 UPDATE_VERSION_ENV,
406 LEGACY_UPDATE_VERSION_ENV,
407 ];
408
409 struct ReleaseEnvGuard {
410 previous: Vec<(&'static str, Option<OsString>)>,
411 _lock: MutexGuard<'static, ()>,
412 }
413
414 impl ReleaseEnvGuard {
415 fn clear() -> Self {
416 let lock = RELEASE_ENV_LOCK
417 .lock()
418 .unwrap_or_else(|poisoned| poisoned.into_inner());
419 let previous = RELEASE_ENV_VARS
420 .iter()
421 .map(|&name| (name, std::env::var_os(name)))
422 .collect();
423 for &name in RELEASE_ENV_VARS {
424 unsafe { std::env::remove_var(name) };
426 }
427 Self {
428 previous,
429 _lock: lock,
430 }
431 }
432 }
433
434 impl Drop for ReleaseEnvGuard {
435 fn drop(&mut self) {
436 for (name, value) in &self.previous {
437 unsafe {
439 match value {
440 Some(value) => std::env::set_var(name, value),
441 None => std::env::remove_var(name),
442 }
443 }
444 }
445 }
446 }
447
448 fn set_release_env(name: &str, value: &str) {
449 unsafe { std::env::set_var(name, value) };
451 }
452
453 #[test]
454 fn release_channel_from_beta_flag_maps_booleans() {
455 assert_eq!(
456 ReleaseChannel::from_beta_flag(false),
457 ReleaseChannel::Stable
458 );
459 assert_eq!(ReleaseChannel::from_beta_flag(true), ReleaseChannel::Beta);
460 }
461
462 #[test]
463 fn release_channel_label_matches_channel_names() {
464 assert_eq!(ReleaseChannel::Stable.label(), "stable");
465 assert_eq!(ReleaseChannel::Beta.label(), "beta");
466 }
467
468 #[test]
469 fn is_beta_tag_detects_beta_prereleases_case_insensitively() {
470 for tag in [
471 "beta",
472 "BETA",
473 "BeTa",
474 "v1.0.0-beta.1",
475 "v1.0.0-BETA.1",
476 "v2.0.0-beta",
477 "something-beta-something",
478 "beta-1.0",
479 ] {
480 assert!(is_beta_tag(tag), "{tag} should be beta");
481 }
482
483 for tag in [
484 "",
485 "bet",
486 "alpha",
487 "rc",
488 "v1.0.0",
489 "v1.0.0-alpha.1",
490 "v1.0.0-rc.1",
491 ] {
492 assert!(!is_beta_tag(tag), "{tag} should not be beta");
493 }
494 }
495
496 #[test]
497 fn release_base_url_from_env_returns_none_without_overrides() {
498 let _env = ReleaseEnvGuard::clear();
499
500 assert_eq!(release_base_url_from_env("1.0.0"), None);
501 }
502
503 #[test]
504 fn release_base_url_from_env_prefers_primary_override() {
505 let _env = ReleaseEnvGuard::clear();
506 set_release_env(RELEASE_BASE_URL_ENV, "https://primary.example.com");
507 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
508
509 assert_eq!(
510 release_base_url_from_env("1.0.0"),
511 Some("https://primary.example.com".to_string())
512 );
513 }
514
515 #[test]
516 fn release_base_url_from_env_falls_back_to_legacy_overrides() {
517 let _env = ReleaseEnvGuard::clear();
518 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
519 set_release_env(
520 DEEPSEEK_RELEASE_BASE_URL_ENV,
521 "https://deepseek.example.com",
522 );
523
524 assert_eq!(
525 release_base_url_from_env("1.0.0"),
526 Some("https://legacy.example.com".to_string())
527 );
528
529 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
530
531 assert_eq!(
532 release_base_url_from_env("1.0.0"),
533 Some("https://deepseek.example.com".to_string())
534 );
535 }
536
537 #[test]
538 fn release_base_url_from_env_trims_and_ignores_empty_overrides() {
539 let _env = ReleaseEnvGuard::clear();
540 set_release_env(RELEASE_BASE_URL_ENV, " https://spaced.example.com \n");
541
542 assert_eq!(
543 release_base_url_from_env("1.0.0"),
544 Some("https://spaced.example.com".to_string())
545 );
546
547 set_release_env(RELEASE_BASE_URL_ENV, " ");
548 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
549 set_release_env(DEEPSEEK_RELEASE_BASE_URL_ENV, "\n");
550
551 assert_eq!(release_base_url_from_env("1.0.0"), None);
552 }
553
554 #[test]
555 fn release_base_url_from_env_uses_cnb_mirror_last() {
556 let _env = ReleaseEnvGuard::clear();
557 set_release_env(CNB_MIRROR_ENV, "1");
558
559 assert_eq!(
560 release_base_url_from_env("v1.2.3"),
561 Some("https://cnb.cool/Hmbown/CodeWhale/-/releases/v1.2.3".to_string())
562 );
563
564 set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
565
566 assert_eq!(
567 release_base_url_from_env("1.0.0"),
568 Some("https://explicit.example.com".to_string())
569 );
570 }
571
572 #[test]
573 fn update_version_from_env_prefers_primary_then_legacy() {
574 {
575 let _env = ReleaseEnvGuard::clear();
576 set_release_env(UPDATE_VERSION_ENV, " v1.2.3 ");
577
578 assert_eq!(update_version_from_env().as_deref(), Some("1.2.3"));
579 }
580
581 {
582 let _env = ReleaseEnvGuard::clear();
583 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.4");
584
585 assert_eq!(update_version_from_env().as_deref(), Some("1.2.4"));
586 }
587 }
588
589 #[test]
590 fn update_version_from_env_ignores_missing_or_empty_values() {
591 let _env = ReleaseEnvGuard::clear();
592 assert_eq!(update_version_from_env(), None);
593
594 set_release_env(UPDATE_VERSION_ENV, " ");
595 set_release_env(LEGACY_UPDATE_VERSION_ENV, "");
596
597 assert_eq!(update_version_from_env(), None);
598 }
599
600 #[test]
601 fn update_network_fallback_hint_mentions_required_mirror_inputs() {
602 let hint = update_network_fallback_hint();
603
604 assert!(hint.contains(CNB_REPO_URL), "hint missing CNB_REPO_URL");
605 assert!(
606 hint.contains(RELEASE_BASE_URL_ENV),
607 "hint missing RELEASE_BASE_URL_ENV"
608 );
609 assert!(
610 hint.contains(UPDATE_VERSION_ENV),
611 "hint missing UPDATE_VERSION_ENV"
612 );
613 assert!(
614 hint.contains(CHECKSUM_MANIFEST_ASSET),
615 "hint missing CHECKSUM_MANIFEST_ASSET"
616 );
617 }
618
619 #[test]
620 fn mirror_asset_url_trims_trailing_base_slashes() {
621 for base_url in [
622 "https://example.com/assets",
623 "https://example.com/assets/",
624 "https://example.com/assets//",
625 ] {
626 assert_eq!(
627 mirror_asset_url(base_url, "file.zip"),
628 "https://example.com/assets/file.zip",
629 "{base_url} should join with a single slash"
630 );
631 }
632 assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
633 }
634
635 #[test]
636 fn resolve_release_query_uses_github_without_overrides() {
637 let _env = ReleaseEnvGuard::clear();
638
639 assert_eq!(
640 resolve_release_query(ReleaseChannel::Stable),
641 ReleaseQuery::GitHubLatest {
642 url: LATEST_RELEASE_URL
643 }
644 );
645 assert_eq!(
646 resolve_release_query(ReleaseChannel::Beta),
647 ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
648 );
649 }
650
651 #[test]
652 fn resolve_release_query_uses_release_base_url_overrides() {
653 let default_version = env!("CARGO_PKG_VERSION").to_string();
654
655 for (env_name, expected_url) in [
656 (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
657 (
658 LEGACY_RELEASE_BASE_URL_ENV,
659 "https://legacy.example.com/mirror",
660 ),
661 (
662 DEEPSEEK_RELEASE_BASE_URL_ENV,
663 "https://deepseek.example.com/mirror",
664 ),
665 ] {
666 let _env = ReleaseEnvGuard::clear();
667 set_release_env(env_name, expected_url);
668
669 assert_eq!(
670 resolve_release_query(ReleaseChannel::Stable),
671 ReleaseQuery::Mirror {
672 base_url: expected_url.to_string(),
673 version: default_version.clone(),
674 },
675 "{env_name} should drive mirror query"
676 );
677 }
678 }
679
680 #[test]
681 fn resolve_release_query_uses_cnb_mirror_override() {
682 let _env = ReleaseEnvGuard::clear();
683 let default_version = env!("CARGO_PKG_VERSION").to_string();
684 set_release_env(CNB_MIRROR_ENV, "1");
685
686 assert_eq!(
687 resolve_release_query(ReleaseChannel::Stable),
688 ReleaseQuery::Mirror {
689 base_url: cnb_release_base_url(&default_version),
690 version: default_version,
691 }
692 );
693 }
694
695 #[test]
696 fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
697 {
698 let _env = ReleaseEnvGuard::clear();
699 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
700 set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
701
702 assert_eq!(
703 resolve_release_query(ReleaseChannel::Stable),
704 ReleaseQuery::Mirror {
705 base_url: "https://example.com/mirror".to_string(),
706 version: "1.2.3".to_string(),
707 }
708 );
709 }
710
711 {
712 let _env = ReleaseEnvGuard::clear();
713 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
714 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
715
716 assert_eq!(
717 resolve_release_query(ReleaseChannel::Stable),
718 ReleaseQuery::Mirror {
719 base_url: "https://example.com/mirror".to_string(),
720 version: "1.2.3-legacy".to_string(),
721 }
722 );
723 }
724 }
725
726 #[test]
727 fn cnb_release_base_url_includes_tag_directory() {
728 assert_eq!(
729 cnb_release_base_url("0.8.47"),
730 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
731 );
732 assert_eq!(
733 cnb_release_base_url("v0.8.47"),
734 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
735 );
736 }
737
738 #[test]
739 fn stable_update_is_needed_only_when_latest_is_newer() {
740 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
741 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
742 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
743 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
744 assert!(
745 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
746 );
747 }
748
749 #[test]
750 fn beta_update_allows_switching_from_same_stable_to_beta() {
751 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
752 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
753 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
754 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
755 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
756 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
757 }
758
759 #[test]
760 fn parse_release_version_accepts_tags_and_build_suffixes() {
761 assert_eq!(
762 parse_release_version("v0.9.0-beta.1").unwrap(),
763 semver::Version::parse("0.9.0-beta.1").unwrap()
764 );
765 assert_eq!(
766 parse_release_version("0.8.45 (abcdef123456)").unwrap(),
767 semver::Version::parse("0.8.45").unwrap()
768 );
769 }
770
771 #[test]
772 fn release_version_compare_ignores_v_prefix_and_build_sha() {
773 assert_eq!(
774 compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
775 std::cmp::Ordering::Equal
776 );
777 assert_eq!(
778 compare_release_versions("0.8.39", "v0.8.40").unwrap(),
779 std::cmp::Ordering::Less
780 );
781 assert_eq!(
782 compare_release_versions("0.8.40", "v0.8.39").unwrap(),
783 std::cmp::Ordering::Greater
784 );
785 }
786
787 #[test]
788 fn latest_beta_tag_selects_first_beta_release() {
789 let body = r#"[
790 { "tag_name": "v0.9.0" },
791 { "tag_name": "v0.9.0-rc.1" },
792 { "tag_name": "v0.9.0-beta.2" },
793 { "tag_name": "v0.9.0-beta.1" }
794 ]"#;
795 assert_eq!(
796 latest_beta_tag_from_release_list_json(body).unwrap(),
797 "v0.9.0-beta.2"
798 );
799 }
800
801 #[test]
802 fn latest_beta_tag_reports_missing_beta() {
803 let body = r#"[{ "tag_name": "v0.9.0" }]"#;
804 let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
805 assert!(
806 err.to_string().contains("no beta release found"),
807 "unexpected error: {err:#}"
808 );
809 }
810}