返回 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 pub mod launch;
9 pub mod tls;
10
11 pub use check::{SuppressionReason, UpdateCheckCache, suppression_reason};
12 pub use install::{InstallMethod, current_install_method};
13 pub use launch::{LaunchOutcome, VersionChange, record_launch};
14
15 /// Filename of the SHA-256 checksum manifest included in every release.
16 ///
17 /// Mirror directories must contain this file alongside platform binaries so
18 /// that download integrity can be verified.
19 pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
20
21 /// GitHub API URL for the single latest stable release.
22 pub const LATEST_RELEASE_URL: &str =
23 "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
24
25 /// GitHub API URL listing recent releases (up to 100), used to find beta tags.
26 pub const RELEASES_URL: &str =
27 "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
28
29 /// Base URL of the CodeWhale repository on the CNB mirror platform.
30 pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
31
32 /// Environment variable that overrides the base URL for release asset downloads.
33 pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
34
35 /// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
36 pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
37
38 /// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
39 pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
40
41 /// Environment variable that, when set, enables the CNB mirror for downloads.
42 pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
43
44 /// Environment variable that pins the update target version.
45 pub const UPDATE_VERSION_ENV: &str = "CODEWHALE_VERSION";
46
47 /// Pre-rebrand environment variable (alias for [`UPDATE_VERSION_ENV`]).
48 pub const LEGACY_TUI_UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
49
50 /// Legacy environment variable (alias for [`UPDATE_VERSION_ENV`]).
51 pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
52
53 /// User-Agent header sent with release metadata requests.
54 pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
55
56 const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/codewhale.net/codewhale/-/releases/download";
57 const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
58
59 /// Build a reqwest client builder with the TLS roots appropriate for the
60 /// current platform.
61 ///
62 /// Android command-line programs such as Termux do not run inside a Java
63 /// application and therefore cannot initialize reqwest's Android platform
64 /// verifier with a `JavaVM` and `Context`. Use the Mozilla WebPKI roots there
65 /// so native CLI/TUI HTTPS works without a JNI host. Other platforms keep
66 /// reqwest's platform verifier.
67 pub fn platform_http_client_builder() -> reqwest::ClientBuilder {
68 let _ = rustls::crypto::ring::default_provider().install_default();
69 let builder = reqwest::Client::builder();
70 #[cfg(target_os = "android")]
71 {
72 builder.tls_backend_preconfigured(android_rustls_config())
73 }
74 #[cfg(not(target_os = "android"))]
75 {
76 builder
77 }
78 }
79
80 /// Blocking counterpart of [`platform_http_client_builder`].
81 pub fn platform_blocking_http_client_builder() -> reqwest::blocking::ClientBuilder {
82 let _ = rustls::crypto::ring::default_provider().install_default();
83 let builder = reqwest::blocking::Client::builder();
84 #[cfg(target_os = "android")]
85 {
86 builder.tls_backend_preconfigured(android_rustls_config())
87 }
88 #[cfg(not(target_os = "android"))]
89 {
90 builder
91 }
92 }
93
94 #[cfg(target_os = "android")]
95 fn android_rustls_config() -> rustls::ClientConfig {
96 let mut roots = rustls::RootCertStore::empty();
97 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
98 rustls::ClientConfig::builder()
99 .with_root_certificates(roots)
100 .with_no_client_auth()
101 }
102
103 /// The release channel to query for updates.
104 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
105 pub enum ReleaseChannel {
106 /// Official stable releases only.
107 Stable,
108 /// Pre-release / beta versions.
109 Beta,
110 }
111
112 impl ReleaseChannel {
113 /// Creates a channel from a boolean flag (`true` → [`Beta`](Self::Beta)).
114 pub fn from_beta_flag(beta: bool) -> Self {
115 if beta { Self::Beta } else { Self::Stable }
116 }
117
118 /// Returns a lowercase human-readable label (`"stable"` or `"beta"`).
119 pub fn label(self) -> &'static str {
120 match self {
121 Self::Stable => "stable",
122 Self::Beta => "beta",
123 }
124 }
125 }
126
127 /// Describes where to fetch release metadata from.
128 #[derive(Debug, Clone, PartialEq, Eq)]
129 pub enum ReleaseQuery {
130 /// Use a custom mirror base URL and a pinned version.
131 Mirror { base_url: String, version: String },
132 /// Query the GitHub single-latest-release endpoint.
133 GitHubLatest { url: &'static str },
134 /// Query the GitHub release-list endpoint (used for beta discovery).
135 GitHubReleaseList { url: &'static str },
136 }
137
138 /// Determines the appropriate [`ReleaseQuery`] for the given channel, taking
139 /// environment-variable overrides (mirror URL, pinned version) into account.
140 pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery {
141 let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
142 if let Some(base_url) = release_base_url_from_env(&version) {
143 return ReleaseQuery::Mirror { base_url, version };
144 }
145
146 match channel {
147 ReleaseChannel::Stable => ReleaseQuery::GitHubLatest {
148 url: LATEST_RELEASE_URL,
149 },
150 ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL },
151 }
152 }
153
154 /// Reads the release base URL from environment variables, falling back to the
155 /// CNB mirror if `CODEWHALE_USE_CNB_MIRROR=1`. Returns `None` when no override
156 /// is configured.
157 pub fn release_base_url_from_env(version: &str) -> Option<String> {
158 if let Some(base_url) = explicit_release_base_url_from_env() {
159 return Some(base_url);
160 }
161 if cnb_mirror_requested_from_env() {
162 return Some(cnb_release_base_url(version));
163 }
164 None
165 }
166
167 fn cnb_mirror_requested_from_env() -> bool {
168 std::env::var(CNB_MIRROR_ENV).is_ok_and(|value| value == "1")
169 }
170
171 /// Reads an operator-supplied release base URL, ignoring the CNB mirror flag.
172 ///
173 /// Kept separate from [`release_base_url_from_env`] so callers can tell an
174 /// explicit mirror directory apart from "use the CNB mirror for this version".
175 pub fn explicit_release_base_url_from_env() -> Option<String> {
176 for env_name in [
177 RELEASE_BASE_URL_ENV,
178 LEGACY_RELEASE_BASE_URL_ENV,
179 DEEPSEEK_RELEASE_BASE_URL_ENV,
180 ] {
181 if let Ok(value) = std::env::var(env_name) {
182 let trimmed = value.trim().to_string();
183 if !trimmed.is_empty() {
184 return Some(trimmed);
185 }
186 }
187 }
188 None
189 }
190
191 /// True when `CODEWHALE_USE_CNB_MIRROR` is the override actually in effect —
192 /// it is exactly `1`, and no explicit base URL outranks it.
193 pub fn cnb_mirror_override_active() -> bool {
194 explicit_release_base_url_from_env().is_none() && cnb_mirror_requested_from_env()
195 }
196
197 /// True when the first-party CNB mirror publishes release binaries for this
198 /// target.
199 ///
200 /// The CNB tag pipeline (`.cnb.yml`) builds exactly one artifact set — Linux
201 /// x64, statically linked against musl. Every other platform is served by
202 /// canonical GitHub Releases or by an explicit
203 /// [`RELEASE_BASE_URL_ENV`] mirror, so the updater must not offer CNB there.
204 pub fn cnb_mirror_supports_target(os: &str, rust_arch: &str) -> bool {
205 os == "linux" && rust_arch == "x86_64"
206 }
207
208 /// Constructs the CNB mirror asset URL for a given version tag.
209 pub fn cnb_release_base_url(version: &str) -> String {
210 format!(
211 "{}/v{}",
212 CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
213 version.trim_start_matches('v')
214 )
215 }
216
217 /// Returns the pinned update version from environment variables, or `None`
218 /// if `CODEWHALE_VERSION` and its two legacy aliases are unset or empty.
219 pub fn update_version_from_env() -> Option<String> {
220 [
221 UPDATE_VERSION_ENV,
222 LEGACY_TUI_UPDATE_VERSION_ENV,
223 LEGACY_UPDATE_VERSION_ENV,
224 ]
225 .into_iter()
226 .find_map(|name| {
227 std::env::var(name)
228 .ok()
229 .map(|value| value.trim().trim_start_matches('v').to_string())
230 .filter(|value| !value.is_empty())
231 })
232 }
233
234 /// Joins a mirror base URL with an asset filename to produce a full download URL.
235 pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
236 format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
237 }
238
239 /// Returns a human-readable hint explaining how to use a mirror when GitHub
240 /// downloads are blocked or slow (e.g. on mainland China networks).
241 pub fn update_network_fallback_hint() -> String {
242 format!(
243 "GitHub release downloads may be blocked or slow on this network.\n\
244 For mainland China, use one of these fallback paths:\n\
245 1. Source build from the CNB mirror, installing the shipped binary:\n\
246 cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
247 2. Use a binary asset mirror:\n\
248 {RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
249 The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the codewhale platform binary."
250 )
251 }
252
253 /// Fetches a release JSON payload from `url` (async).
254 ///
255 /// `description` is included in error messages to identify the request purpose.
256 pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
257 let client = platform_http_client_builder()
258 .user_agent(UPDATE_USER_AGENT)
259 .timeout(RELEASE_METADATA_TIMEOUT)
260 .build()
261 .context("failed to build release check HTTP client")?;
262 let response = client
263 .get(url)
264 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
265 .send()
266 .await
267 .with_context(|| format!("failed to fetch {description} from {url}"))?;
268 let status = response.status();
269 let body = response
270 .text()
271 .await
272 .with_context(|| format!("failed to read {description} response from {url}"));
273 release_response_body(status, body, url, description)
274 }
275
276 fn release_response_body(
277 status: reqwest::StatusCode,
278 body: Result<String>,
279 url: &str,
280 description: &str,
281 ) -> Result<String> {
282 let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
283 if !status.is_success() {
284 bail!("GitHub release request failed with HTTP {status}: {body}");
285 }
286 Ok(body)
287 }
288
289 #[derive(Deserialize)]
290 struct ReleaseTag {
291 tag_name: String,
292 }
293
294 #[derive(Deserialize)]
295 struct ReleaseListEntry {
296 tag_name: String,
297 }
298
299 /// Extracts the `tag_name` field from a GitHub single-release JSON response.
300 pub fn latest_tag_from_release_json(body: &str) -> Result<String> {
301 let release: ReleaseTag = serde_json::from_str(body).with_context(|| {
302 format!("failed to parse release JSON from GitHub API. Response: {body}")
303 })?;
304 Ok(release.tag_name)
305 }
306
307 /// Scans a GitHub release-list JSON response and returns the tag of the first
308 /// entry whose name contains `"beta"`.
309 pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result<String> {
310 let releases: Vec<ReleaseListEntry> = serde_json::from_str(body).with_context(|| {
311 format!("failed to parse release list JSON from GitHub API. Response: {body}")
312 })?;
313 releases
314 .into_iter()
315 .find(|release| is_beta_tag(&release.tag_name))
316 .map(|release| release.tag_name)
317 .context("no beta release found in GitHub releases")
318 }
319
320 /// Async helper that resolves the latest release tag for the given channel.
321 ///
322 /// For mirrors the version is derived from the pinned environment variable;
323 /// for GitHub channels the appropriate API endpoint is queried.
324 pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result<String> {
325 match resolve_release_query(channel) {
326 ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
327 ReleaseQuery::GitHubLatest { url } => {
328 let body = fetch_release_json_async(url, "latest release").await?;
329 latest_tag_from_release_json(&body)
330 }
331 ReleaseQuery::GitHubReleaseList { url } => {
332 let body = fetch_release_json_async(url, "release list").await?;
333 latest_beta_tag_from_release_list_json(&body)
334 }
335 }
336 }
337
338 /// Compares a current version string against a release tag using semver
339 /// ordering. Both `v` prefixes and trailing build metadata (e.g. `(abc123)`)
340 /// are stripped before comparison.
341 pub fn compare_release_versions(
342 current_version: &str,
343 latest_tag: &str,
344 ) -> Result<std::cmp::Ordering> {
345 let current = parse_release_version(current_version)
346 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
347 let latest = parse_release_version(latest_tag)
348 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
349 Ok(current.cmp(&latest))
350 }
351
352 /// Determines whether an update is needed for the given channel.
353 ///
354 /// For [`Stable`](ReleaseChannel::Stable) an update is needed when the latest
355 /// release is strictly newer. For [`Beta`](ReleaseChannel::Beta) the logic also
356 /// allows switching from a stable release to a beta on the same release line.
357 pub fn update_is_needed(
358 channel: ReleaseChannel,
359 current_version: &str,
360 latest_tag: &str,
361 ) -> Result<bool> {
362 let current = parse_release_version(current_version)
363 .with_context(|| format!("failed to parse current version {current_version:?}"))?;
364 let latest = parse_release_version(latest_tag)
365 .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
366
367 match channel {
368 ReleaseChannel::Stable => Ok(current < latest),
369 ReleaseChannel::Beta => {
370 if current == latest {
371 return Ok(false);
372 }
373 let latest_is_beta = version_is_beta(&latest);
374 let current_is_stable = current.pre.is_empty();
375 let same_release_line = current.major == latest.major
376 && current.minor == latest.minor
377 && current.patch == latest.patch;
378 if current > latest && !(current_is_stable && same_release_line) {
379 return Ok(false);
380 }
381 Ok(latest_is_beta)
382 }
383 }
384 }
385
386 /// Parses a version string (with optional `v` prefix and trailing build info)
387 /// into a [`semver::Version`].
388 pub fn parse_release_version(value: &str) -> Result<semver::Version> {
389 let version = value
390 .trim()
391 .trim_start_matches('v')
392 .split_whitespace()
393 .next()
394 .unwrap_or("");
395 semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
396 }
397
398 /// Returns `true` if the tag name contains `"beta"` (case-insensitive).
399 pub fn is_beta_tag(tag_name: &str) -> bool {
400 tag_name.to_ascii_lowercase().contains("beta")
401 }
402
403 fn version_is_beta(version: &semver::Version) -> bool {
404 version.pre.as_str().to_ascii_lowercase().contains("beta")
405 }
406
407 #[cfg(test)]
408 mod tests {
409 use std::ffi::OsString;
410 use std::sync::{Mutex, MutexGuard};
411
412 use super::*;
413
414 static RELEASE_ENV_LOCK: Mutex<()> = Mutex::new(());
415 const RELEASE_ENV_VARS: &[&str] = &[
416 RELEASE_BASE_URL_ENV,
417 LEGACY_RELEASE_BASE_URL_ENV,
418 DEEPSEEK_RELEASE_BASE_URL_ENV,
419 CNB_MIRROR_ENV,
420 UPDATE_VERSION_ENV,
421 LEGACY_TUI_UPDATE_VERSION_ENV,
422 LEGACY_UPDATE_VERSION_ENV,
423 ];
424
425 struct ReleaseEnvGuard {
426 previous: Vec<(&'static str, Option<OsString>)>,
427 _lock: MutexGuard<'static, ()>,
428 }
429
430 impl ReleaseEnvGuard {
431 fn clear() -> Self {
432 let lock = RELEASE_ENV_LOCK
433 .lock()
434 .unwrap_or_else(|poisoned| poisoned.into_inner());
435 let previous = RELEASE_ENV_VARS
436 .iter()
437 .map(|&name| (name, std::env::var_os(name)))
438 .collect();
439 for &name in RELEASE_ENV_VARS {
440 // SAFETY: tests that mutate these process-wide vars hold RELEASE_ENV_LOCK.
441 unsafe { std::env::remove_var(name) };
442 }
443 Self {
444 previous,
445 _lock: lock,
446 }
447 }
448 }
449
450 impl Drop for ReleaseEnvGuard {
451 fn drop(&mut self) {
452 for (name, value) in &self.previous {
453 // SAFETY: the guard still holds RELEASE_ENV_LOCK while restoring state.
454 unsafe {
455 match value {
456 Some(value) => std::env::set_var(name, value),
457 None => std::env::remove_var(name),
458 }
459 }
460 }
461 }
462 }
463
464 fn set_release_env(name: &str, value: &str) {
465 // SAFETY: callers hold a ReleaseEnvGuard, which serializes env mutation.
466 unsafe { std::env::set_var(name, value) };
467 }
468
469 #[test]
470 fn release_channel_from_beta_flag_maps_booleans() {
471 assert_eq!(
472 ReleaseChannel::from_beta_flag(false),
473 ReleaseChannel::Stable
474 );
475 assert_eq!(ReleaseChannel::from_beta_flag(true), ReleaseChannel::Beta);
476 }
477
478 #[test]
479 fn release_channel_label_matches_channel_names() {
480 assert_eq!(ReleaseChannel::Stable.label(), "stable");
481 assert_eq!(ReleaseChannel::Beta.label(), "beta");
482 }
483
484 #[test]
485 fn is_beta_tag_detects_beta_prereleases_case_insensitively() {
486 for tag in [
487 "beta",
488 "BETA",
489 "BeTa",
490 "v1.0.0-beta.1",
491 "v1.0.0-BETA.1",
492 "v2.0.0-beta",
493 "something-beta-something",
494 "beta-1.0",
495 ] {
496 assert!(is_beta_tag(tag), "{tag} should be beta");
497 }
498
499 for tag in [
500 "",
501 "bet",
502 "alpha",
503 "rc",
504 "v1.0.0",
505 "v1.0.0-alpha.1",
506 "v1.0.0-rc.1",
507 ] {
508 assert!(!is_beta_tag(tag), "{tag} should not be beta");
509 }
510 }
511
512 #[test]
513 fn release_base_url_from_env_returns_none_without_overrides() {
514 let _env = ReleaseEnvGuard::clear();
515
516 assert_eq!(release_base_url_from_env("1.0.0"), None);
517 }
518
519 #[test]
520 fn release_base_url_from_env_prefers_primary_override() {
521 let _env = ReleaseEnvGuard::clear();
522 set_release_env(RELEASE_BASE_URL_ENV, "https://primary.example.com");
523 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
524
525 assert_eq!(
526 release_base_url_from_env("1.0.0"),
527 Some("https://primary.example.com".to_string())
528 );
529 }
530
531 #[test]
532 fn release_base_url_from_env_falls_back_to_legacy_overrides() {
533 let _env = ReleaseEnvGuard::clear();
534 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
535 set_release_env(
536 DEEPSEEK_RELEASE_BASE_URL_ENV,
537 "https://deepseek.example.com",
538 );
539
540 assert_eq!(
541 release_base_url_from_env("1.0.0"),
542 Some("https://legacy.example.com".to_string())
543 );
544
545 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
546
547 assert_eq!(
548 release_base_url_from_env("1.0.0"),
549 Some("https://deepseek.example.com".to_string())
550 );
551 }
552
553 #[test]
554 fn release_base_url_from_env_trims_and_ignores_empty_overrides() {
555 let _env = ReleaseEnvGuard::clear();
556 set_release_env(RELEASE_BASE_URL_ENV, " https://spaced.example.com \n");
557
558 assert_eq!(
559 release_base_url_from_env("1.0.0"),
560 Some("https://spaced.example.com".to_string())
561 );
562
563 set_release_env(RELEASE_BASE_URL_ENV, " ");
564 set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
565 set_release_env(DEEPSEEK_RELEASE_BASE_URL_ENV, "\n");
566
567 assert_eq!(release_base_url_from_env("1.0.0"), None);
568 }
569
570 #[test]
571 fn release_base_url_from_env_uses_cnb_mirror_last() {
572 let _env = ReleaseEnvGuard::clear();
573 set_release_env(CNB_MIRROR_ENV, "1");
574
575 assert_eq!(
576 release_base_url_from_env("v1.2.3"),
577 Some("https://cnb.cool/codewhale.net/codewhale/-/releases/download/v1.2.3".to_string())
578 );
579
580 set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
581
582 assert_eq!(
583 release_base_url_from_env("1.0.0"),
584 Some("https://explicit.example.com".to_string())
585 );
586
587 set_release_env(RELEASE_BASE_URL_ENV, "");
588 set_release_env(CNB_MIRROR_ENV, "0");
589 assert_eq!(
590 release_base_url_from_env("1.0.0"),
591 None,
592 "the Rust updater must match npm and require an exact =1 override"
593 );
594 }
595
596 #[test]
597 fn cnb_mirror_override_is_active_only_without_an_explicit_base_url() {
598 let _env = ReleaseEnvGuard::clear();
599 assert!(!cnb_mirror_override_active());
600
601 set_release_env(CNB_MIRROR_ENV, "1");
602 assert!(cnb_mirror_override_active());
603
604 set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
605 assert!(
606 !cnb_mirror_override_active(),
607 "an explicit base URL outranks the CNB flag"
608 );
609
610 set_release_env(RELEASE_BASE_URL_ENV, "");
611 for disabled in ["", "0", "true", "yes", " 1 "] {
612 set_release_env(CNB_MIRROR_ENV, disabled);
613 assert!(
614 !cnb_mirror_override_active(),
615 "only CODEWHALE_USE_CNB_MIRROR=1 may force CNB, got {disabled:?}"
616 );
617 }
618 }
619
620 #[test]
621 fn cnb_mirror_publishes_linux_x64_only() {
622 assert!(cnb_mirror_supports_target("linux", "x86_64"));
623
624 for (os, arch) in [
625 ("linux", "aarch64"),
626 ("linux", "riscv64"),
627 ("macos", "x86_64"),
628 ("macos", "aarch64"),
629 ("windows", "x86_64"),
630 ("windows", "aarch64"),
631 ("android", "aarch64"),
632 ] {
633 assert!(
634 !cnb_mirror_supports_target(os, arch),
635 "CNB must not claim {os}/{arch}"
636 );
637 }
638 }
639
640 #[test]
641 fn update_version_from_env_prefers_primary_then_legacy() {
642 {
643 let _env = ReleaseEnvGuard::clear();
644 set_release_env(UPDATE_VERSION_ENV, " v1.2.3 ");
645
646 assert_eq!(update_version_from_env().as_deref(), Some("1.2.3"));
647 }
648
649 {
650 let _env = ReleaseEnvGuard::clear();
651 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.4");
652
653 assert_eq!(update_version_from_env().as_deref(), Some("1.2.4"));
654 }
655 }
656
657 #[test]
658 fn update_version_from_env_ignores_missing_or_empty_values() {
659 let _env = ReleaseEnvGuard::clear();
660 assert_eq!(update_version_from_env(), None);
661
662 set_release_env(UPDATE_VERSION_ENV, " ");
663 set_release_env(LEGACY_UPDATE_VERSION_ENV, "");
664
665 assert_eq!(update_version_from_env(), None);
666 }
667
668 #[test]
669 fn canonical_version_pin_outranks_both_preserved_legacy_aliases() {
670 let _env = ReleaseEnvGuard::clear();
671 set_release_env("CODEWHALE_VERSION", "v0.9.11");
672 set_release_env("DEEPSEEK_TUI_VERSION", "v0.9.10");
673 set_release_env("DEEPSEEK_VERSION", "v0.9.9");
674 assert_eq!(update_version_from_env().as_deref(), Some("0.9.11"));
675 set_release_env("CODEWHALE_VERSION", " ");
676 assert_eq!(update_version_from_env().as_deref(), Some("0.9.10"));
677 set_release_env("DEEPSEEK_TUI_VERSION", " ");
678 assert_eq!(update_version_from_env().as_deref(), Some("0.9.9"));
679 }
680
681 #[test]
682 fn update_network_fallback_hint_mentions_required_mirror_inputs() {
683 let hint = update_network_fallback_hint();
684
685 assert!(hint.contains(CNB_REPO_URL), "hint missing CNB_REPO_URL");
686 assert!(
687 hint.contains(RELEASE_BASE_URL_ENV),
688 "hint missing RELEASE_BASE_URL_ENV"
689 );
690 assert!(
691 hint.contains(UPDATE_VERSION_ENV),
692 "hint missing UPDATE_VERSION_ENV"
693 );
694 assert!(
695 hint.contains(CHECKSUM_MANIFEST_ASSET),
696 "hint missing CHECKSUM_MANIFEST_ASSET"
697 );
698 assert!(
699 hint.contains("codewhale platform binary"),
700 "hint must describe the sole implementation asset"
701 );
702 assert!(
703 !hint.contains("codewhale-tui"),
704 "hint must not request the removed TUI implementation asset"
705 );
706 }
707
708 #[test]
709 fn mirror_asset_url_trims_trailing_base_slashes() {
710 for base_url in [
711 "https://example.com/assets",
712 "https://example.com/assets/",
713 "https://example.com/assets//",
714 ] {
715 assert_eq!(
716 mirror_asset_url(base_url, "file.zip"),
717 "https://example.com/assets/file.zip",
718 "{base_url} should join with a single slash"
719 );
720 }
721 assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
722 }
723
724 #[test]
725 fn resolve_release_query_uses_github_without_overrides() {
726 let _env = ReleaseEnvGuard::clear();
727
728 assert_eq!(
729 resolve_release_query(ReleaseChannel::Stable),
730 ReleaseQuery::GitHubLatest {
731 url: LATEST_RELEASE_URL
732 }
733 );
734 assert_eq!(
735 resolve_release_query(ReleaseChannel::Beta),
736 ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
737 );
738 }
739
740 #[test]
741 fn resolve_release_query_uses_release_base_url_overrides() {
742 let default_version = env!("CARGO_PKG_VERSION").to_string();
743
744 for (env_name, expected_url) in [
745 (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
746 (
747 LEGACY_RELEASE_BASE_URL_ENV,
748 "https://legacy.example.com/mirror",
749 ),
750 (
751 DEEPSEEK_RELEASE_BASE_URL_ENV,
752 "https://deepseek.example.com/mirror",
753 ),
754 ] {
755 let _env = ReleaseEnvGuard::clear();
756 set_release_env(env_name, expected_url);
757
758 assert_eq!(
759 resolve_release_query(ReleaseChannel::Stable),
760 ReleaseQuery::Mirror {
761 base_url: expected_url.to_string(),
762 version: default_version.clone(),
763 },
764 "{env_name} should drive mirror query"
765 );
766 }
767 }
768
769 #[test]
770 fn resolve_release_query_uses_cnb_mirror_override() {
771 let _env = ReleaseEnvGuard::clear();
772 let default_version = env!("CARGO_PKG_VERSION").to_string();
773 set_release_env(CNB_MIRROR_ENV, "1");
774
775 assert_eq!(
776 resolve_release_query(ReleaseChannel::Stable),
777 ReleaseQuery::Mirror {
778 base_url: cnb_release_base_url(&default_version),
779 version: default_version,
780 }
781 );
782 }
783
784 #[test]
785 fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
786 {
787 let _env = ReleaseEnvGuard::clear();
788 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
789 set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
790
791 assert_eq!(
792 resolve_release_query(ReleaseChannel::Stable),
793 ReleaseQuery::Mirror {
794 base_url: "https://example.com/mirror".to_string(),
795 version: "1.2.3".to_string(),
796 }
797 );
798 }
799
800 {
801 let _env = ReleaseEnvGuard::clear();
802 set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
803 set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
804
805 assert_eq!(
806 resolve_release_query(ReleaseChannel::Stable),
807 ReleaseQuery::Mirror {
808 base_url: "https://example.com/mirror".to_string(),
809 version: "1.2.3-legacy".to_string(),
810 }
811 );
812 }
813 }
814
815 #[test]
816 fn cnb_release_base_url_includes_tag_directory() {
817 assert_eq!(
818 cnb_release_base_url("0.8.47"),
819 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
820 );
821 assert_eq!(
822 cnb_release_base_url("v0.8.47"),
823 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
824 );
825 }
826
827 #[test]
828 fn stable_update_is_needed_only_when_latest_is_newer() {
829 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
830 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
831 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
832 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
833 assert!(
834 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
835 );
836 }
837
838 #[test]
839 fn beta_update_allows_switching_from_same_stable_to_beta() {
840 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
841 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
842 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
843 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
844 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
845 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
846 }
847
848 #[test]
849 fn parse_release_version_accepts_tags_and_build_suffixes() {
850 assert_eq!(
851 parse_release_version("v0.9.0-beta.1").unwrap(),
852 semver::Version::parse("0.9.0-beta.1").unwrap()
853 );
854 assert_eq!(
855 parse_release_version("0.8.45 (abcdef123456)").unwrap(),
856 semver::Version::parse("0.8.45").unwrap()
857 );
858 }
859
860 #[test]
861 fn release_version_compare_ignores_v_prefix_and_build_sha() {
862 assert_eq!(
863 compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
864 std::cmp::Ordering::Equal
865 );
866 assert_eq!(
867 compare_release_versions("0.8.39", "v0.8.40").unwrap(),
868 std::cmp::Ordering::Less
869 );
870 assert_eq!(
871 compare_release_versions("0.8.40", "v0.8.39").unwrap(),
872 std::cmp::Ordering::Greater
873 );
874 }
875
876 #[test]
877 fn latest_beta_tag_selects_first_beta_release() {
878 let body = r#"[
879 { "tag_name": "v0.9.0" },
880 { "tag_name": "v0.9.0-rc.1" },
881 { "tag_name": "v0.9.0-beta.2" },
882 { "tag_name": "v0.9.0-beta.1" }
883 ]"#;
884 assert_eq!(
885 latest_beta_tag_from_release_list_json(body).unwrap(),
886 "v0.9.0-beta.2"
887 );
888 }
889
890 #[test]
891 fn latest_beta_tag_reports_missing_beta() {
892 let body = r#"[{ "tag_name": "v0.9.0" }]"#;
893 let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
894 assert!(
895 err.to_string().contains("no beta release found"),
896 "unexpected error: {err:#}"
897 );
898 }
899 }
900
900 lines RUST