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 the shipped binary:\n\
202              cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
203           2. Use a binary asset mirror:\n\
204              {RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
205         The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the codewhale platform binary."
206    )
207}
208
209/// Fetches a release JSON payload from `url` using a blocking HTTP client.
210///
211/// `description` is included in error messages to identify the request purpose.
212pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result<String> {
213    let client = platform_blocking_http_client_builder()
214        .user_agent(UPDATE_USER_AGENT)
215        .timeout(RELEASE_METADATA_TIMEOUT)
216        .build()
217        .context("failed to build release check HTTP client")?;
218    let response = client
219        .get(url)
220        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
221        .send()
222        .with_context(|| format!("failed to fetch {description} from {url}"))?;
223    let status = response.status();
224    let body = response
225        .text()
226        .with_context(|| format!("failed to read {description} response from {url}"));
227    release_response_body(status, body, url, description)
228}
229
230/// Async counterpart of [`fetch_release_json_blocking`].
231pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
232    let client = platform_http_client_builder()
233        .user_agent(UPDATE_USER_AGENT)
234        .timeout(RELEASE_METADATA_TIMEOUT)
235        .build()
236        .context("failed to build release check HTTP client")?;
237    let response = client
238        .get(url)
239        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
240        .send()
241        .await
242        .with_context(|| format!("failed to fetch {description} from {url}"))?;
243    let status = response.status();
244    let body = response
245        .text()
246        .await
247        .with_context(|| format!("failed to read {description} response from {url}"));
248    release_response_body(status, body, url, description)
249}
250
251fn release_response_body(
252    status: reqwest::StatusCode,
253    body: Result<String>,
254    url: &str,
255    description: &str,
256) -> Result<String> {
257    let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
258    if !status.is_success() {
259        bail!("GitHub release request failed with HTTP {status}: {body}");
260    }
261    Ok(body)
262}
263
264#[derive(Deserialize)]
265struct ReleaseTag {
266    tag_name: String,
267}
268
269#[derive(Deserialize)]
270struct ReleaseListEntry {
271    tag_name: String,
272}
273
274/// Extracts the `tag_name` field from a GitHub single-release JSON response.
275pub fn latest_tag_from_release_json(body: &str) -> Result<String> {
276    let release: ReleaseTag = serde_json::from_str(body).with_context(|| {
277        format!("failed to parse release JSON from GitHub API. Response: {body}")
278    })?;
279    Ok(release.tag_name)
280}
281
282/// Scans a GitHub release-list JSON response and returns the tag of the first
283/// entry whose name contains `"beta"`.
284pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result<String> {
285    let releases: Vec<ReleaseListEntry> = serde_json::from_str(body).with_context(|| {
286        format!("failed to parse release list JSON from GitHub API. Response: {body}")
287    })?;
288    releases
289        .into_iter()
290        .find(|release| is_beta_tag(&release.tag_name))
291        .map(|release| release.tag_name)
292        .context("no beta release found in GitHub releases")
293}
294
295/// Async helper that resolves the latest release tag for the given channel.
296///
297/// For mirrors the version is derived from the pinned environment variable;
298/// for GitHub channels the appropriate API endpoint is queried.
299pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result<String> {
300    match resolve_release_query(channel) {
301        ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
302        ReleaseQuery::GitHubLatest { url } => {
303            let body = fetch_release_json_async(url, "latest release").await?;
304            latest_tag_from_release_json(&body)
305        }
306        ReleaseQuery::GitHubReleaseList { url } => {
307            let body = fetch_release_json_async(url, "release list").await?;
308            latest_beta_tag_from_release_list_json(&body)
309        }
310    }
311}
312
313/// Blocking counterpart of [`latest_release_tag_async`].
314pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result<String> {
315    match resolve_release_query(channel) {
316        ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
317        ReleaseQuery::GitHubLatest { url } => {
318            let body = fetch_release_json_blocking(url, "latest release")?;
319            latest_tag_from_release_json(&body)
320        }
321        ReleaseQuery::GitHubReleaseList { url } => {
322            let body = fetch_release_json_blocking(url, "release list")?;
323            latest_beta_tag_from_release_list_json(&body)
324        }
325    }
326}
327
328/// Compares a current version string against a release tag using semver
329/// ordering. Both `v` prefixes and trailing build metadata (e.g. `(abc123)`)
330/// are stripped before comparison.
331pub fn compare_release_versions(
332    current_version: &str,
333    latest_tag: &str,
334) -> Result<std::cmp::Ordering> {
335    let current = parse_release_version(current_version)
336        .with_context(|| format!("failed to parse current version {current_version:?}"))?;
337    let latest = parse_release_version(latest_tag)
338        .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
339    Ok(current.cmp(&latest))
340}
341
342/// Determines whether an update is needed for the given channel.
343///
344/// For [`Stable`](ReleaseChannel::Stable) an update is needed when the latest
345/// release is strictly newer. For [`Beta`](ReleaseChannel::Beta) the logic also
346/// allows switching from a stable release to a beta on the same release line.
347pub fn update_is_needed(
348    channel: ReleaseChannel,
349    current_version: &str,
350    latest_tag: &str,
351) -> Result<bool> {
352    let current = parse_release_version(current_version)
353        .with_context(|| format!("failed to parse current version {current_version:?}"))?;
354    let latest = parse_release_version(latest_tag)
355        .with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
356
357    match channel {
358        ReleaseChannel::Stable => Ok(current < latest),
359        ReleaseChannel::Beta => {
360            if current == latest {
361                return Ok(false);
362            }
363            let latest_is_beta = version_is_beta(&latest);
364            let current_is_stable = current.pre.is_empty();
365            let same_release_line = current.major == latest.major
366                && current.minor == latest.minor
367                && current.patch == latest.patch;
368            if current > latest && !(current_is_stable && same_release_line) {
369                return Ok(false);
370            }
371            Ok(latest_is_beta)
372        }
373    }
374}
375
376/// Parses a version string (with optional `v` prefix and trailing build info)
377/// into a [`semver::Version`].
378pub fn parse_release_version(value: &str) -> Result<semver::Version> {
379    let version = value
380        .trim()
381        .trim_start_matches('v')
382        .split_whitespace()
383        .next()
384        .unwrap_or("");
385    semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
386}
387
388/// Returns `true` if the tag name contains `"beta"` (case-insensitive).
389pub fn is_beta_tag(tag_name: &str) -> bool {
390    tag_name.to_ascii_lowercase().contains("beta")
391}
392
393fn version_is_beta(version: &semver::Version) -> bool {
394    version.pre.as_str().to_ascii_lowercase().contains("beta")
395}
396
397#[cfg(test)]
398mod tests {
399    use std::ffi::OsString;
400    use std::sync::{Mutex, MutexGuard};
401
402    use super::*;
403
404    static RELEASE_ENV_LOCK: Mutex<()> = Mutex::new(());
405    const RELEASE_ENV_VARS: &[&str] = &[
406        RELEASE_BASE_URL_ENV,
407        LEGACY_RELEASE_BASE_URL_ENV,
408        DEEPSEEK_RELEASE_BASE_URL_ENV,
409        CNB_MIRROR_ENV,
410        UPDATE_VERSION_ENV,
411        LEGACY_UPDATE_VERSION_ENV,
412    ];
413
414    struct ReleaseEnvGuard {
415        previous: Vec<(&'static str, Option<OsString>)>,
416        _lock: MutexGuard<'static, ()>,
417    }
418
419    impl ReleaseEnvGuard {
420        fn clear() -> Self {
421            let lock = RELEASE_ENV_LOCK
422                .lock()
423                .unwrap_or_else(|poisoned| poisoned.into_inner());
424            let previous = RELEASE_ENV_VARS
425                .iter()
426                .map(|&name| (name, std::env::var_os(name)))
427                .collect();
428            for &name in RELEASE_ENV_VARS {
429                // SAFETY: tests that mutate these process-wide vars hold RELEASE_ENV_LOCK.
430                unsafe { std::env::remove_var(name) };
431            }
432            Self {
433                previous,
434                _lock: lock,
435            }
436        }
437    }
438
439    impl Drop for ReleaseEnvGuard {
440        fn drop(&mut self) {
441            for (name, value) in &self.previous {
442                // SAFETY: the guard still holds RELEASE_ENV_LOCK while restoring state.
443                unsafe {
444                    match value {
445                        Some(value) => std::env::set_var(name, value),
446                        None => std::env::remove_var(name),
447                    }
448                }
449            }
450        }
451    }
452
453    fn set_release_env(name: &str, value: &str) {
454        // SAFETY: callers hold a ReleaseEnvGuard, which serializes env mutation.
455        unsafe { std::env::set_var(name, value) };
456    }
457
458    #[test]
459    fn release_channel_from_beta_flag_maps_booleans() {
460        assert_eq!(
461            ReleaseChannel::from_beta_flag(false),
462            ReleaseChannel::Stable
463        );
464        assert_eq!(ReleaseChannel::from_beta_flag(true), ReleaseChannel::Beta);
465    }
466
467    #[test]
468    fn release_channel_label_matches_channel_names() {
469        assert_eq!(ReleaseChannel::Stable.label(), "stable");
470        assert_eq!(ReleaseChannel::Beta.label(), "beta");
471    }
472
473    #[test]
474    fn is_beta_tag_detects_beta_prereleases_case_insensitively() {
475        for tag in [
476            "beta",
477            "BETA",
478            "BeTa",
479            "v1.0.0-beta.1",
480            "v1.0.0-BETA.1",
481            "v2.0.0-beta",
482            "something-beta-something",
483            "beta-1.0",
484        ] {
485            assert!(is_beta_tag(tag), "{tag} should be beta");
486        }
487
488        for tag in [
489            "",
490            "bet",
491            "alpha",
492            "rc",
493            "v1.0.0",
494            "v1.0.0-alpha.1",
495            "v1.0.0-rc.1",
496        ] {
497            assert!(!is_beta_tag(tag), "{tag} should not be beta");
498        }
499    }
500
501    #[test]
502    fn release_base_url_from_env_returns_none_without_overrides() {
503        let _env = ReleaseEnvGuard::clear();
504
505        assert_eq!(release_base_url_from_env("1.0.0"), None);
506    }
507
508    #[test]
509    fn release_base_url_from_env_prefers_primary_override() {
510        let _env = ReleaseEnvGuard::clear();
511        set_release_env(RELEASE_BASE_URL_ENV, "https://primary.example.com");
512        set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
513
514        assert_eq!(
515            release_base_url_from_env("1.0.0"),
516            Some("https://primary.example.com".to_string())
517        );
518    }
519
520    #[test]
521    fn release_base_url_from_env_falls_back_to_legacy_overrides() {
522        let _env = ReleaseEnvGuard::clear();
523        set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "https://legacy.example.com");
524        set_release_env(
525            DEEPSEEK_RELEASE_BASE_URL_ENV,
526            "https://deepseek.example.com",
527        );
528
529        assert_eq!(
530            release_base_url_from_env("1.0.0"),
531            Some("https://legacy.example.com".to_string())
532        );
533
534        set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
535
536        assert_eq!(
537            release_base_url_from_env("1.0.0"),
538            Some("https://deepseek.example.com".to_string())
539        );
540    }
541
542    #[test]
543    fn release_base_url_from_env_trims_and_ignores_empty_overrides() {
544        let _env = ReleaseEnvGuard::clear();
545        set_release_env(RELEASE_BASE_URL_ENV, "  https://spaced.example.com  \n");
546
547        assert_eq!(
548            release_base_url_from_env("1.0.0"),
549            Some("https://spaced.example.com".to_string())
550        );
551
552        set_release_env(RELEASE_BASE_URL_ENV, "   ");
553        set_release_env(LEGACY_RELEASE_BASE_URL_ENV, "");
554        set_release_env(DEEPSEEK_RELEASE_BASE_URL_ENV, "\n");
555
556        assert_eq!(release_base_url_from_env("1.0.0"), None);
557    }
558
559    #[test]
560    fn release_base_url_from_env_uses_cnb_mirror_last() {
561        let _env = ReleaseEnvGuard::clear();
562        set_release_env(CNB_MIRROR_ENV, "1");
563
564        assert_eq!(
565            release_base_url_from_env("v1.2.3"),
566            Some("https://cnb.cool/Hmbown/CodeWhale/-/releases/v1.2.3".to_string())
567        );
568
569        set_release_env(RELEASE_BASE_URL_ENV, "https://explicit.example.com");
570
571        assert_eq!(
572            release_base_url_from_env("1.0.0"),
573            Some("https://explicit.example.com".to_string())
574        );
575    }
576
577    #[test]
578    fn update_version_from_env_prefers_primary_then_legacy() {
579        {
580            let _env = ReleaseEnvGuard::clear();
581            set_release_env(UPDATE_VERSION_ENV, "  v1.2.3  ");
582
583            assert_eq!(update_version_from_env().as_deref(), Some("1.2.3"));
584        }
585
586        {
587            let _env = ReleaseEnvGuard::clear();
588            set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.4");
589
590            assert_eq!(update_version_from_env().as_deref(), Some("1.2.4"));
591        }
592    }
593
594    #[test]
595    fn update_version_from_env_ignores_missing_or_empty_values() {
596        let _env = ReleaseEnvGuard::clear();
597        assert_eq!(update_version_from_env(), None);
598
599        set_release_env(UPDATE_VERSION_ENV, "   ");
600        set_release_env(LEGACY_UPDATE_VERSION_ENV, "");
601
602        assert_eq!(update_version_from_env(), None);
603    }
604
605    #[test]
606    fn update_network_fallback_hint_mentions_required_mirror_inputs() {
607        let hint = update_network_fallback_hint();
608
609        assert!(hint.contains(CNB_REPO_URL), "hint missing CNB_REPO_URL");
610        assert!(
611            hint.contains(RELEASE_BASE_URL_ENV),
612            "hint missing RELEASE_BASE_URL_ENV"
613        );
614        assert!(
615            hint.contains(UPDATE_VERSION_ENV),
616            "hint missing UPDATE_VERSION_ENV"
617        );
618        assert!(
619            hint.contains(CHECKSUM_MANIFEST_ASSET),
620            "hint missing CHECKSUM_MANIFEST_ASSET"
621        );
622        assert!(
623            hint.contains("codewhale platform binary"),
624            "hint must describe the sole implementation asset"
625        );
626        assert!(
627            !hint.contains("codewhale-tui"),
628            "hint must not request the removed TUI implementation asset"
629        );
630    }
631
632    #[test]
633    fn mirror_asset_url_trims_trailing_base_slashes() {
634        for base_url in [
635            "https://example.com/assets",
636            "https://example.com/assets/",
637            "https://example.com/assets//",
638        ] {
639            assert_eq!(
640                mirror_asset_url(base_url, "file.zip"),
641                "https://example.com/assets/file.zip",
642                "{base_url} should join with a single slash"
643            );
644        }
645        assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
646    }
647
648    #[test]
649    fn resolve_release_query_uses_github_without_overrides() {
650        let _env = ReleaseEnvGuard::clear();
651
652        assert_eq!(
653            resolve_release_query(ReleaseChannel::Stable),
654            ReleaseQuery::GitHubLatest {
655                url: LATEST_RELEASE_URL
656            }
657        );
658        assert_eq!(
659            resolve_release_query(ReleaseChannel::Beta),
660            ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
661        );
662    }
663
664    #[test]
665    fn resolve_release_query_uses_release_base_url_overrides() {
666        let default_version = env!("CARGO_PKG_VERSION").to_string();
667
668        for (env_name, expected_url) in [
669            (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
670            (
671                LEGACY_RELEASE_BASE_URL_ENV,
672                "https://legacy.example.com/mirror",
673            ),
674            (
675                DEEPSEEK_RELEASE_BASE_URL_ENV,
676                "https://deepseek.example.com/mirror",
677            ),
678        ] {
679            let _env = ReleaseEnvGuard::clear();
680            set_release_env(env_name, expected_url);
681
682            assert_eq!(
683                resolve_release_query(ReleaseChannel::Stable),
684                ReleaseQuery::Mirror {
685                    base_url: expected_url.to_string(),
686                    version: default_version.clone(),
687                },
688                "{env_name} should drive mirror query"
689            );
690        }
691    }
692
693    #[test]
694    fn resolve_release_query_uses_cnb_mirror_override() {
695        let _env = ReleaseEnvGuard::clear();
696        let default_version = env!("CARGO_PKG_VERSION").to_string();
697        set_release_env(CNB_MIRROR_ENV, "1");
698
699        assert_eq!(
700            resolve_release_query(ReleaseChannel::Stable),
701            ReleaseQuery::Mirror {
702                base_url: cnb_release_base_url(&default_version),
703                version: default_version,
704            }
705        );
706    }
707
708    #[test]
709    fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
710        {
711            let _env = ReleaseEnvGuard::clear();
712            set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
713            set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
714
715            assert_eq!(
716                resolve_release_query(ReleaseChannel::Stable),
717                ReleaseQuery::Mirror {
718                    base_url: "https://example.com/mirror".to_string(),
719                    version: "1.2.3".to_string(),
720                }
721            );
722        }
723
724        {
725            let _env = ReleaseEnvGuard::clear();
726            set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
727            set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
728
729            assert_eq!(
730                resolve_release_query(ReleaseChannel::Stable),
731                ReleaseQuery::Mirror {
732                    base_url: "https://example.com/mirror".to_string(),
733                    version: "1.2.3-legacy".to_string(),
734                }
735            );
736        }
737    }
738
739    #[test]
740    fn cnb_release_base_url_includes_tag_directory() {
741        assert_eq!(
742            cnb_release_base_url("0.8.47"),
743            "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
744        );
745        assert_eq!(
746            cnb_release_base_url("v0.8.47"),
747            "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
748        );
749    }
750
751    #[test]
752    fn stable_update_is_needed_only_when_latest_is_newer() {
753        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
754        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
755        assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
756        assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
757        assert!(
758            !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
759        );
760    }
761
762    #[test]
763    fn beta_update_allows_switching_from_same_stable_to_beta() {
764        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
765        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
766        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
767        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
768        assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
769        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
770    }
771
772    #[test]
773    fn parse_release_version_accepts_tags_and_build_suffixes() {
774        assert_eq!(
775            parse_release_version("v0.9.0-beta.1").unwrap(),
776            semver::Version::parse("0.9.0-beta.1").unwrap()
777        );
778        assert_eq!(
779            parse_release_version("0.8.45 (abcdef123456)").unwrap(),
780            semver::Version::parse("0.8.45").unwrap()
781        );
782    }
783
784    #[test]
785    fn release_version_compare_ignores_v_prefix_and_build_sha() {
786        assert_eq!(
787            compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
788            std::cmp::Ordering::Equal
789        );
790        assert_eq!(
791            compare_release_versions("0.8.39", "v0.8.40").unwrap(),
792            std::cmp::Ordering::Less
793        );
794        assert_eq!(
795            compare_release_versions("0.8.40", "v0.8.39").unwrap(),
796            std::cmp::Ordering::Greater
797        );
798    }
799
800    #[test]
801    fn latest_beta_tag_selects_first_beta_release() {
802        let body = r#"[
803          { "tag_name": "v0.9.0" },
804          { "tag_name": "v0.9.0-rc.1" },
805          { "tag_name": "v0.9.0-beta.2" },
806          { "tag_name": "v0.9.0-beta.1" }
807        ]"#;
808        assert_eq!(
809            latest_beta_tag_from_release_list_json(body).unwrap(),
810            "v0.9.0-beta.2"
811        );
812    }
813
814    #[test]
815    fn latest_beta_tag_reports_missing_beta() {
816        let body = r#"[{ "tag_name": "v0.9.0" }]"#;
817        let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
818        assert!(
819            err.to_string().contains("no beta release found"),
820            "unexpected error: {err:#}"
821        );
822    }
823}