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 std::ffi::OsString;
351 use std::sync::{Mutex, MutexGuard};
352
353 use super::*;
354
355 static RELEASE_ENV_LOCK: Mutex<()> = Mutex::new(());
356 const RELEASE_ENV_VARS: &[&str] = &[
357 RELEASE_BASE_URL_ENV,
358 LEGACY_RELEASE_BASE_URL_ENV,
359 DEEPSEEK_RELEASE_BASE_URL_ENV,
360 CNB_MIRROR_ENV,
361 UPDATE_VERSION_ENV,
362 LEGACY_UPDATE_VERSION_ENV,
363 ];
364
365 struct ReleaseEnvGuard {
366 previous: Vec<(&'static str, Option<OsString>)>,
367 _lock: MutexGuard<'static, ()>,
368 }
369
370 impl ReleaseEnvGuard {
371 fn clear() -> Self {
372 let lock = RELEASE_ENV_LOCK
373 .lock()
374 .unwrap_or_else(|poisoned| poisoned.into_inner());
375 let previous = RELEASE_ENV_VARS
376 .iter()
377 .map(|&name| (name, std::env::var_os(name)))
378 .collect();
379 for &name in RELEASE_ENV_VARS {
380 unsafe { std::env::remove_var(name) };
382 }
383 Self {
384 previous,
385 _lock: lock,
386 }
387 }
388 }
389
390 impl Drop for ReleaseEnvGuard {
391 fn drop(&mut self) {
392 for (name, value) in &self.previous {
393 unsafe {
395 match value {
396 Some(value) => std::env::set_var(name, value),
397 None => std::env::remove_var(name),
398 }
399 }
400 }
401 }
402 }
403
404 fn set_release_env(name: &str, value: &str) {
405 unsafe { std::env::set_var(name, value) };
407 }
408
409 #[test]
410 fn release_channel_from_beta_flag_maps_booleans() {
411 assert_eq!(
412 ReleaseChannel::from_beta_flag(false),
413 ReleaseChannel::Stable
414 );
415 assert_eq!(ReleaseChannel::from_beta_flag(true), ReleaseChannel::Beta);
416 }
417
418 #[test]
419 fn release_channel_label_matches_channel_names() {
420 assert_eq!(ReleaseChannel::Stable.label(), "stable");
421 assert_eq!(ReleaseChannel::Beta.label(), "beta");
422 }
423
424 #[test]
425 fn is_beta_tag_detects_beta_prereleases_case_insensitively() {
426 for tag in [
427 "beta",
428 "BETA",
429 "BeTa",
430 "v1.0.0-beta.1",
431 "v1.0.0-BETA.1",
432 "v2.0.0-beta",
433 "something-beta-something",
434 "beta-1.0",
435 ] {
436 assert!(is_beta_tag(tag), "{tag} should be beta");
437 }
438
439 for tag in [
440 "",
441 "bet",
442 "alpha",
443 "rc",
444 "v1.0.0",
445 "v1.0.0-alpha.1",
446 "v1.0.0-rc.1",
447 ] {
448 assert!(!is_beta_tag(tag), "{tag} should not be beta");
449 }
450 }
451
452 #[test]
453 fn release_base_url_from_env_returns_none_without_overrides() {
454 let _env = ReleaseEnvGuard::clear();
455
456 assert_eq!(release_base_url_from_env("1.0.0"), None);
457 }
458
459 #[test]
460 fn release_base_url_from_env_prefers_primary_override() {
461 let _env = ReleaseEnvGuard::clear();
462 set_release_env(RELEASE_BASE_URL_ENV, "https://primary.example.com");
463 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
464
465 assert_eq!(
466 release_base_url_from_env("1.0.0"),
467 Some("https://primary.example.com".to_string())
468 );
469 }
470
471 #[test]
472 fn release_base_url_from_env_falls_back_to_legacy_overrides() {
473 let _env = ReleaseEnvGuard::clear();
474 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
475 set_release_env(
476 DEEPSEEK_RELEASE_BASE_URL_ENV,
477 "https://deepseek.example.com",
478 );
479
480 assert_eq!(
481 release_base_url_from_env("1.0.0"),
482 Some("https://legacy.example.com".to_string())
483 );
484
485 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
486
487 assert_eq!(
488 release_base_url_from_env("1.0.0"),
489 Some("https://deepseek.example.com".to_string())
490 );
491 }
492
493 #[test]
494 fn release_base_url_from_env_trims_and_ignores_empty_overrides() {
495 let _env = ReleaseEnvGuard::clear();
496 set_release_env(RELEASE_BASE_URL_ENV, " https://spaced.example.com \n");
497
498 assert_eq!(
499 release_base_url_from_env("1.0.0"),
500 Some("https://spaced.example.com".to_string())
501 );
502
503 set_release_env(RELEASE_BASE_URL_ENV, " ");
504 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
505 set_release_env(DEEPSEEK_RELEASE_BASE_URL_ENV, "\n");
506
507 assert_eq!(release_base_url_from_env("1.0.0"), None);
508 }
509
510 #[test]
511 fn release_base_url_from_env_uses_cnb_mirror_last() {
512 let _env = ReleaseEnvGuard::clear();
513 set_release_env(CNB_MIRROR_ENV, "1");
514
515 assert_eq!(
516 release_base_url_from_env("v1.2.3"),
517 Some("https://cnb.cool/Hmbown/CodeWhale/-/releases/v1.2.3".to_string())
518 );
519
520 set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
521
522 assert_eq!(
523 release_base_url_from_env("1.0.0"),
524 Some("https://explicit.example.com".to_string())
525 );
526 }
527
528 #[test]
529 fn update_version_from_env_prefers_primary_then_legacy() {
530 {
531 let _env = ReleaseEnvGuard::clear();
532 set_release_env(UPDATE_VERSION_ENV, " v1.2.3 ");
533
534 assert_eq!(update_version_from_env().as_deref(), Some("1.2.3"));
535 }
536
537 {
538 let _env = ReleaseEnvGuard::clear();
539 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.4");
540
541 assert_eq!(update_version_from_env().as_deref(), Some("1.2.4"));
542 }
543 }
544
545 #[test]
546 fn update_version_from_env_ignores_missing_or_empty_values() {
547 let _env = ReleaseEnvGuard::clear();
548 assert_eq!(update_version_from_env(), None);
549
550 set_release_env(UPDATE_VERSION_ENV, " ");
551 set_release_env(LEGACY_UPDATE_VERSION_ENV, "");
552
553 assert_eq!(update_version_from_env(), None);
554 }
555
556 #[test]
557 fn update_network_fallback_hint_mentions_required_mirror_inputs() {
558 let hint = update_network_fallback_hint();
559
560 assert!(hint.contains(CNB_REPO_URL), "hint missing CNB_REPO_URL");
561 assert!(
562 hint.contains(RELEASE_BASE_URL_ENV),
563 "hint missing RELEASE_BASE_URL_ENV"
564 );
565 assert!(
566 hint.contains(UPDATE_VERSION_ENV),
567 "hint missing UPDATE_VERSION_ENV"
568 );
569 assert!(
570 hint.contains(CHECKSUM_MANIFEST_ASSET),
571 "hint missing CHECKSUM_MANIFEST_ASSET"
572 );
573 }
574
575 #[test]
576 fn mirror_asset_url_trims_trailing_base_slashes() {
577 for base_url in [
578 "https://example.com/assets",
579 "https://example.com/assets/",
580 "https://example.com/assets//",
581 ] {
582 assert_eq!(
583 mirror_asset_url(base_url, "file.zip"),
584 "https://example.com/assets/file.zip",
585 "{base_url} should join with a single slash"
586 );
587 }
588 assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
589 }
590
591 #[test]
592 fn resolve_release_query_uses_github_without_overrides() {
593 let _env = ReleaseEnvGuard::clear();
594
595 assert_eq!(
596 resolve_release_query(ReleaseChannel::Stable),
597 ReleaseQuery::GitHubLatest {
598 url: LATEST_RELEASE_URL
599 }
600 );
601 assert_eq!(
602 resolve_release_query(ReleaseChannel::Beta),
603 ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
604 );
605 }
606
607 #[test]
608 fn resolve_release_query_uses_release_base_url_overrides() {
609 let default_version = env!("CARGO_PKG_VERSION").to_string();
610
611 for (env_name, expected_url) in [
612 (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
613 (
614 LEGACY_RELEASE_BASE_URL_ENV,
615 "https://legacy.example.com/mirror",
616 ),
617 (
618 DEEPSEEK_RELEASE_BASE_URL_ENV,
619 "https://deepseek.example.com/mirror",
620 ),
621 ] {
622 let _env = ReleaseEnvGuard::clear();
623 set_release_env(env_name, expected_url);
624
625 assert_eq!(
626 resolve_release_query(ReleaseChannel::Stable),
627 ReleaseQuery::Mirror {
628 base_url: expected_url.to_string(),
629 version: default_version.clone(),
630 },
631 "{env_name} should drive mirror query"
632 );
633 }
634 }
635
636 #[test]
637 fn resolve_release_query_uses_cnb_mirror_override() {
638 let _env = ReleaseEnvGuard::clear();
639 let default_version = env!("CARGO_PKG_VERSION").to_string();
640 set_release_env(CNB_MIRROR_ENV, "1");
641
642 assert_eq!(
643 resolve_release_query(ReleaseChannel::Stable),
644 ReleaseQuery::Mirror {
645 base_url: cnb_release_base_url(&default_version),
646 version: default_version,
647 }
648 );
649 }
650
651 #[test]
652 fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
653 {
654 let _env = ReleaseEnvGuard::clear();
655 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
656 set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
657
658 assert_eq!(
659 resolve_release_query(ReleaseChannel::Stable),
660 ReleaseQuery::Mirror {
661 base_url: "https://example.com/mirror".to_string(),
662 version: "1.2.3".to_string(),
663 }
664 );
665 }
666
667 {
668 let _env = ReleaseEnvGuard::clear();
669 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
670 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
671
672 assert_eq!(
673 resolve_release_query(ReleaseChannel::Stable),
674 ReleaseQuery::Mirror {
675 base_url: "https://example.com/mirror".to_string(),
676 version: "1.2.3-legacy".to_string(),
677 }
678 );
679 }
680 }
681
682 #[test]
683 fn cnb_release_base_url_includes_tag_directory() {
684 assert_eq!(
685 cnb_release_base_url("0.8.47"),
686 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
687 );
688 assert_eq!(
689 cnb_release_base_url("v0.8.47"),
690 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
691 );
692 }
693
694 #[test]
695 fn stable_update_is_needed_only_when_latest_is_newer() {
696 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
697 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
698 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
699 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
700 assert!(
701 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
702 );
703 }
704
705 #[test]
706 fn beta_update_allows_switching_from_same_stable_to_beta() {
707 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
708 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
709 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
710 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
711 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
712 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
713 }
714
715 #[test]
716 fn parse_release_version_accepts_tags_and_build_suffixes() {
717 assert_eq!(
718 parse_release_version("v0.9.0-beta.1").unwrap(),
719 semver::Version::parse("0.9.0-beta.1").unwrap()
720 );
721 assert_eq!(
722 parse_release_version("0.8.45 (abcdef123456)").unwrap(),
723 semver::Version::parse("0.8.45").unwrap()
724 );
725 }
726
727 #[test]
728 fn release_version_compare_ignores_v_prefix_and_build_sha() {
729 assert_eq!(
730 compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
731 std::cmp::Ordering::Equal
732 );
733 assert_eq!(
734 compare_release_versions("0.8.39", "v0.8.40").unwrap(),
735 std::cmp::Ordering::Less
736 );
737 assert_eq!(
738 compare_release_versions("0.8.40", "v0.8.39").unwrap(),
739 std::cmp::Ordering::Greater
740 );
741 }
742
743 #[test]
744 fn latest_beta_tag_selects_first_beta_release() {
745 let body = r#"[
746 { "tag_name": "v0.9.0" },
747 { "tag_name": "v0.9.0-rc.1" },
748 { "tag_name": "v0.9.0-beta.2" },
749 { "tag_name": "v0.9.0-beta.1" }
750 ]"#;
751 assert_eq!(
752 latest_beta_tag_from_release_list_json(body).unwrap(),
753 "v0.9.0-beta.2"
754 );
755 }
756
757 #[test]
758 fn latest_beta_tag_reports_missing_beta() {
759 let body = r#"[{ "tag_name": "v0.9.0" }]"#;
760 let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
761 assert!(
762 err.to_string().contains("no beta release found"),
763 "unexpected error: {err:#}"
764 );
765 }
766}