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