Skip to main content

codewhale_release/
lib.rs

1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5
6pub mod check;
7pub mod install;
8
9pub use check::{SuppressionReason, UpdateCheckCache, suppression_reason};
10pub 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.
16pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
17
18/// GitHub API URL for the single latest stable release.
19pub 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.
23pub 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.
27pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
28
29/// Environment variable that overrides the base URL for release asset downloads.
30pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
31
32/// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
33pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
34
35/// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
36pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
37
38/// Environment variable that, when set, enables the CNB mirror for downloads.
39pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
40
41/// Environment variable that pins the update target version.
42pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
43
44/// Legacy environment variable (alias for [`UPDATE_VERSION_ENV`]).
45pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
46
47/// User-Agent header sent with release metadata requests.
48pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
49
50const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
51const 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.
61pub 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`].
75pub 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")]
89fn 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)]
99pub enum ReleaseChannel {
100    /// Official stable releases only.
101    Stable,
102    /// Pre-release / beta versions.
103    Beta,
104}
105
106impl 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)]
123pub 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.
134pub 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.
151pub 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.
172pub 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.
182pub 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.
191pub 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).
197pub 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.
213pub 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`].
232pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
233    let client = platform_http_client_builder()
234        .user_agent(UPDATE_USER_AGENT)
235        .timeout(RELEASE_METADATA_TIMEOUT)
236        .build()
237        .context("failed to build release check HTTP client")?;
238    let response = client
239        .get(url)
240        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
241        .send()
242        .await
243        .with_context(|| format!("failed to fetch {description} from {url}"))?;
244    let status = response.status();
245    let body = response
246        .text()
247        .await
248        .with_context(|| format!("failed to read {description} response from {url}"));
249    release_response_body(status, body, url, description)
250}
251
252fn release_response_body(
253    status: reqwest::StatusCode,
254    body: Result<String>,
255    url: &str,
256    description: &str,
257) -> Result<String> {
258    let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
259    if !status.is_success() {
260        bail!("GitHub release request failed with HTTP {status}: {body}");
261    }
262    Ok(body)
263}
264
265#[derive(Deserialize)]
266struct ReleaseTag {
267    tag_name: String,
268}
269
270#[derive(Deserialize)]
271struct ReleaseListEntry {
272    tag_name: String,
273}
274
275/// Extracts the `tag_name` field from a GitHub single-release JSON response.
276pub 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"`.
285pub 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.
300pub 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`].
315pub 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.
332pub 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.
348pub 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`].
379pub 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).
390pub fn is_beta_tag(tag_name: &str) -> bool {
391    tag_name.to_ascii_lowercase().contains("beta")
392}
393
394fn version_is_beta(version: &semver::Version) -> bool {
395    version.pre.as_str().to_ascii_lowercase().contains("beta")
396}
397
398#[cfg(test)]
399mod tests {
400    use std::ffi::OsString;
401    use std::sync::{Mutex, MutexGuard};
402
403    use super::*;
404
405    static RELEASE_ENV_LOCK: Mutex<()> = Mutex::new(());
406    const RELEASE_ENV_VARS: &[&str] = &[
407        RELEASE_BASE_URL_ENV,
408        LEGACY_RELEASE_BASE_URL_ENV,
409        DEEPSEEK_RELEASE_BASE_URL_ENV,
410        CNB_MIRROR_ENV,
411        UPDATE_VERSION_ENV,
412        LEGACY_UPDATE_VERSION_ENV,
413    ];
414
415    struct ReleaseEnvGuard {
416        previous: Vec<(&'static str, Option<OsString>)>,
417        _lock: MutexGuard<'static, ()>,
418    }
419
420    impl ReleaseEnvGuard {
421        fn clear() -> Self {
422            let lock = RELEASE_ENV_LOCK
423                .lock()
424                .unwrap_or_else(|poisoned| poisoned.into_inner());
425            let previous = RELEASE_ENV_VARS
426                .iter()
427                .map(|&name| (name, std::env::var_os(name)))
428                .collect();
429            for &name in RELEASE_ENV_VARS {
430                // 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}