返回 CodeWhale
lib.rs
根目录 / crates / release / src / lib.rs
1 use std::time::Duration;
2
3 use anyhow::{Context, Result, bail};
4 use serde::Deserialize;
5
6 pub mod check;
7 pub mod install;
8
9 pub use check::{SuppressionReason, UpdateCheckCache, suppression_reason};
10 pub use install::{InstallMethod, current_install_method};
11
12 /// Filename of the SHA-256 checksum manifest included in every release.
13 ///
14 /// Mirror directories must contain this file alongside platform binaries so
15 /// that download integrity can be verified.
16 pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
17
18 /// GitHub API URL for the single latest stable release.
19 pub const LATEST_RELEASE_URL: &str =
20 "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
21
22 /// GitHub API URL listing recent releases (up to 100), used to find beta tags.
23 pub const RELEASES_URL: &str =
24 "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
25
26 /// Base URL of the CodeWhale repository on the CNB mirror platform.
27 pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
28
29 /// Environment variable that overrides the base URL for release asset downloads.
30 pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
31
32 /// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
33 pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
34
35 /// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
36 pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
37
38 /// Environment variable that, when set, enables the CNB mirror for downloads.
39 pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
40
41 /// Environment variable that pins the update target version.
42 pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
43
44 /// Legacy environment variable (alias for [`UPDATE_VERSION_ENV`]).
45 pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
46
47 /// User-Agent header sent with release metadata requests.
48 pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
49
50 const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
51 const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
52
53 /// Build a reqwest client builder with the TLS roots appropriate for the
54 /// current platform.
55 ///
56 /// Android command-line programs such as Termux do not run inside a Java
57 /// application and therefore cannot initialize reqwest's Android platform
58 /// verifier with a `JavaVM` and `Context`. Use the Mozilla WebPKI roots there
59 /// so native CLI/TUI HTTPS works without a JNI host. Other platforms keep
60 /// reqwest's platform verifier.
61 pub 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
74 /// Blocking counterpart of [`platform_http_client_builder`].
75 pub 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")]
89 fn 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 /// The release channel to query for updates.
98 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
99 pub enum ReleaseChannel {
100 /// Official stable releases only.
101 Stable,
102 /// Pre-release / beta versions.
103 Beta,
104 }
105
106 impl ReleaseChannel {
107 /// Creates a channel from a boolean flag (`true` → [`Beta`](Self::Beta)).
108 pub fn from_beta_flag(beta: bool) -> Self {
109 if beta { Self::Beta } else { Self::Stable }
110 }
111
112 /// Returns a lowercase human-readable label (`"stable"` or `"beta"`).
113 pub fn label(self) -> &'static str {
114 match self {
115 Self::Stable => "stable",
116 Self::Beta => "beta",
117 }
118 }
119 }
120
121 /// Describes where to fetch release metadata from.
122 #[derive(Debug, Clone, PartialEq, Eq)]
123 pub enum ReleaseQuery {
124 /// Use a custom mirror base URL and a pinned version.
125 Mirror { base_url: String, version: String },
126 /// Query the GitHub single-latest-release endpoint.
127 GitHubLatest { url: &'static str },
128 /// Query the GitHub release-list endpoint (used for beta discovery).
129 GitHubReleaseList { url: &'static str },
130 }
131
132 /// Determines the appropriate [`ReleaseQuery`] for the given channel, taking
133 /// environment-variable overrides (mirror URL, pinned version) into account.
134 pub 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
148 /// Reads the release base URL from environment variables, falling back to the
149 /// CNB mirror if `CODEWHALE_USE_CNB_MIRROR` is set. Returns `None` when no
150 /// override is configured.
151 pub 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
171 /// Constructs the CNB mirror asset URL for a given version tag.
172 pub 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
180 /// Returns the pinned update version from environment variables, or `None`
181 /// if neither `DEEPSEEK_TUI_VERSION` nor `DEEPSEEK_VERSION` is set.
182 pub 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
190 /// Joins a mirror base URL with an asset filename to produce a full download URL.
191 pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
192 format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
193 }
194
195 /// Returns a human-readable hint explaining how to use a mirror when GitHub
196 /// downloads are blocked or slow (e.g. on mainland China networks).
197 pub 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 both shipped binaries:\n\
202 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
203 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --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 platform binaries."
207 )
208 }
209
210 /// Fetches a release JSON payload from `url` using a blocking HTTP client.
211 ///
212 /// `description` is included in error messages to identify the request purpose.
213 pub 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
231 /// Async counterpart of [`fetch_release_json_blocking`].
232 pub 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
252 fn 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)]
266 struct ReleaseTag {
267 tag_name: String,
268 }
269
270 #[derive(Deserialize)]
271 struct ReleaseListEntry {
272 tag_name: String,
273 }
274
275 /// Extracts the `tag_name` field from a GitHub single-release JSON response.
276 pub 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
283 /// Scans a GitHub release-list JSON response and returns the tag of the first
284 /// entry whose name contains `"beta"`.
285 pub 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
296 /// Async helper that resolves the latest release tag for the given channel.
297 ///
298 /// For mirrors the version is derived from the pinned environment variable;
299 /// for GitHub channels the appropriate API endpoint is queried.
300 pub 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
314 /// Blocking counterpart of [`latest_release_tag_async`].
315 pub 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
329 /// Compares a current version string against a release tag using semver
330 /// ordering. Both `v` prefixes and trailing build metadata (e.g. `(abc123)`)
331 /// are stripped before comparison.
332 pub 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
343 /// Determines whether an update is needed for the given channel.
344 ///
345 /// For [`Stable`](ReleaseChannel::Stable) an update is needed when the latest
346 /// release is strictly newer. For [`Beta`](ReleaseChannel::Beta) the logic also
347 /// allows switching from a stable release to a beta on the same release line.
348 pub 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
377 /// Parses a version string (with optional `v` prefix and trailing build info)
378 /// into a [`semver::Version`].
379 pub 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
389 /// Returns `true` if the tag name contains `"beta"` (case-insensitive).
390 pub fn is_beta_tag(tag_name: &str) -> bool {
391 tag_name.to_ascii_lowercase().contains("beta")
392 }
393
394 fn version_is_beta(version: &semver::Version) -> bool {
395 version.pre.as_str().to_ascii_lowercase().contains("beta")
396 }
397
398 #[cfg(test)]
399 mod 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 // SAFETY: tests that mutate these process-wide vars hold RELEASE_ENV_LOCK.
431 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 // SAFETY: the guard still holds RELEASE_ENV_LOCK while restoring state.
444 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 // SAFETY: callers hold a ReleaseEnvGuard, which serializes env mutation.
456 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/Hmbown/CodeWhale/-/releases/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 }
624
625 #[test]
626 fn mirror_asset_url_trims_trailing_base_slashes() {
627 for base_url in [
628 "https://example.com/assets",
629 "https://example.com/assets/",
630 "https://example.com/assets//",
631 ] {
632 assert_eq!(
633 mirror_asset_url(base_url, "file.zip"),
634 "https://example.com/assets/file.zip",
635 "{base_url} should join with a single slash"
636 );
637 }
638 assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
639 }
640
641 #[test]
642 fn resolve_release_query_uses_github_without_overrides() {
643 let _env = ReleaseEnvGuard::clear();
644
645 assert_eq!(
646 resolve_release_query(ReleaseChannel::Stable),
647 ReleaseQuery::GitHubLatest {
648 url: LATEST_RELEASE_URL
649 }
650 );
651 assert_eq!(
652 resolve_release_query(ReleaseChannel::Beta),
653 ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
654 );
655 }
656
657 #[test]
658 fn resolve_release_query_uses_release_base_url_overrides() {
659 let default_version = env!("CARGO_PKG_VERSION").to_string();
660
661 for (env_name, expected_url) in [
662 (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
663 (
664 LEGACY_RELEASE_BASE_URL_ENV,
665 "https://legacy.example.com/mirror",
666 ),
667 (
668 DEEPSEEK_RELEASE_BASE_URL_ENV,
669 "https://deepseek.example.com/mirror",
670 ),
671 ] {
672 let _env = ReleaseEnvGuard::clear();
673 set_release_env(env_name, expected_url);
674
675 assert_eq!(
676 resolve_release_query(ReleaseChannel::Stable),
677 ReleaseQuery::Mirror {
678 base_url: expected_url.to_string(),
679 version: default_version.clone(),
680 },
681 "{env_name} should drive mirror query"
682 );
683 }
684 }
685
686 #[test]
687 fn resolve_release_query_uses_cnb_mirror_override() {
688 let _env = ReleaseEnvGuard::clear();
689 let default_version = env!("CARGO_PKG_VERSION").to_string();
690 set_release_env(CNB_MIRROR_ENV, "1");
691
692 assert_eq!(
693 resolve_release_query(ReleaseChannel::Stable),
694 ReleaseQuery::Mirror {
695 base_url: cnb_release_base_url(&default_version),
696 version: default_version,
697 }
698 );
699 }
700
701 #[test]
702 fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
703 {
704 let _env = ReleaseEnvGuard::clear();
705 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
706 set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
707
708 assert_eq!(
709 resolve_release_query(ReleaseChannel::Stable),
710 ReleaseQuery::Mirror {
711 base_url: "https://example.com/mirror".to_string(),
712 version: "1.2.3".to_string(),
713 }
714 );
715 }
716
717 {
718 let _env = ReleaseEnvGuard::clear();
719 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
720 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
721
722 assert_eq!(
723 resolve_release_query(ReleaseChannel::Stable),
724 ReleaseQuery::Mirror {
725 base_url: "https://example.com/mirror".to_string(),
726 version: "1.2.3-legacy".to_string(),
727 }
728 );
729 }
730 }
731
732 #[test]
733 fn cnb_release_base_url_includes_tag_directory() {
734 assert_eq!(
735 cnb_release_base_url("0.8.47"),
736 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
737 );
738 assert_eq!(
739 cnb_release_base_url("v0.8.47"),
740 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
741 );
742 }
743
744 #[test]
745 fn stable_update_is_needed_only_when_latest_is_newer() {
746 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
747 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
748 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
749 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
750 assert!(
751 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
752 );
753 }
754
755 #[test]
756 fn beta_update_allows_switching_from_same_stable_to_beta() {
757 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
758 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
759 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
760 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
761 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
762 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
763 }
764
765 #[test]
766 fn parse_release_version_accepts_tags_and_build_suffixes() {
767 assert_eq!(
768 parse_release_version("v0.9.0-beta.1").unwrap(),
769 semver::Version::parse("0.9.0-beta.1").unwrap()
770 );
771 assert_eq!(
772 parse_release_version("0.8.45 (abcdef123456)").unwrap(),
773 semver::Version::parse("0.8.45").unwrap()
774 );
775 }
776
777 #[test]
778 fn release_version_compare_ignores_v_prefix_and_build_sha() {
779 assert_eq!(
780 compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
781 std::cmp::Ordering::Equal
782 );
783 assert_eq!(
784 compare_release_versions("0.8.39", "v0.8.40").unwrap(),
785 std::cmp::Ordering::Less
786 );
787 assert_eq!(
788 compare_release_versions("0.8.40", "v0.8.39").unwrap(),
789 std::cmp::Ordering::Greater
790 );
791 }
792
793 #[test]
794 fn latest_beta_tag_selects_first_beta_release() {
795 let body = r#"[
796 { "tag_name": "v0.9.0" },
797 { "tag_name": "v0.9.0-rc.1" },
798 { "tag_name": "v0.9.0-beta.2" },
799 { "tag_name": "v0.9.0-beta.1" }
800 ]"#;
801 assert_eq!(
802 latest_beta_tag_from_release_list_json(body).unwrap(),
803 "v0.9.0-beta.2"
804 );
805 }
806
807 #[test]
808 fn latest_beta_tag_reports_missing_beta() {
809 let body = r#"[{ "tag_name": "v0.9.0" }]"#;
810 let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
811 assert!(
812 err.to_string().contains("no beta release found"),
813 "unexpected error: {err:#}"
814 );
815 }
816 }
817
817 lines RUST