Skip to main content

codewhale_release/
lib.rs

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