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