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;
8pub mod tls;
9
10pub use check::{SuppressionReason, UpdateCheckCache, suppression_reason};
11pub use install::{InstallMethod, current_install_method};
12
13/// Filename of the SHA-256 checksum manifest included in every release.
14///
15/// Mirror directories must contain this file alongside platform binaries so
16/// that download integrity can be verified.
17pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
18
19/// GitHub API URL for the single latest stable release.
20pub const LATEST_RELEASE_URL: &str =
21    "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
22
23/// GitHub API URL listing recent releases (up to 100), used to find beta tags.
24pub const RELEASES_URL: &str =
25    "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
26
27/// Base URL of the CodeWhale repository on the CNB mirror platform.
28pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
29
30/// Environment variable that overrides the base URL for release asset downloads.
31pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
32
33/// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
34pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
35
36/// Legacy environment variable (alias for [`RELEASE_BASE_URL_ENV`]).
37pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
38
39/// Environment variable that, when set, enables the CNB mirror for downloads.
40pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
41
42/// Environment variable that pins the update target version.
43pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
44
45/// Legacy environment variable (alias for [`UPDATE_VERSION_ENV`]).
46pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
47
48/// User-Agent header sent with release metadata requests.
49pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
50
51const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/codewhale.net/codewhale/-/releases/download";
52const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
53
54/// Build a reqwest client builder with the TLS roots appropriate for the
55/// current platform.
56///
57/// Android command-line programs such as Termux do not run inside a Java
58/// application and therefore cannot initialize reqwest's Android platform
59/// verifier with a `JavaVM` and `Context`. Use the Mozilla WebPKI roots there
60/// so native CLI/TUI HTTPS works without a JNI host. Other platforms keep
61/// reqwest's platform verifier.
62pub fn platform_http_client_builder() -> reqwest::ClientBuilder {
63    let _ = rustls::crypto::ring::default_provider().install_default();
64    let builder = reqwest::Client::builder();
65    #[cfg(target_os = "android")]
66    {
67        builder.tls_backend_preconfigured(android_rustls_config())
68    }
69    #[cfg(not(target_os = "android"))]
70    {
71        builder
72    }
73}
74
75/// Blocking counterpart of [`platform_http_client_builder`].
76pub fn platform_blocking_http_client_builder() -> reqwest::blocking::ClientBuilder {
77    let _ = rustls::crypto::ring::default_provider().install_default();
78    let builder = reqwest::blocking::Client::builder();
79    #[cfg(target_os = "android")]
80    {
81        builder.tls_backend_preconfigured(android_rustls_config())
82    }
83    #[cfg(not(target_os = "android"))]
84    {
85        builder
86    }
87}
88
89#[cfg(target_os = "android")]
90fn android_rustls_config() -> rustls::ClientConfig {
91    let mut roots = rustls::RootCertStore::empty();
92    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
93    rustls::ClientConfig::builder()
94        .with_root_certificates(roots)
95        .with_no_client_auth()
96}
97
98/// The release channel to query for updates.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ReleaseChannel {
101    /// Official stable releases only.
102    Stable,
103    /// Pre-release / beta versions.
104    Beta,
105}
106
107impl ReleaseChannel {
108    /// Creates a channel from a boolean flag (`true` → [`Beta`](Self::Beta)).
109    pub fn from_beta_flag(beta: bool) -> Self {
110        if beta { Self::Beta } else { Self::Stable }
111    }
112
113    /// Returns a lowercase human-readable label (`"stable"` or `"beta"`).
114    pub fn label(self) -> &'static str {
115        match self {
116            Self::Stable => "stable",
117            Self::Beta => "beta",
118        }
119    }
120}
121
122/// Describes where to fetch release metadata from.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum ReleaseQuery {
125    /// Use a custom mirror base URL and a pinned version.
126    Mirror { base_url: String, version: String },
127    /// Query the GitHub single-latest-release endpoint.
128    GitHubLatest { url: &'static str },
129    /// Query the GitHub release-list endpoint (used for beta discovery).
130    GitHubReleaseList { url: &'static str },
131}
132
133/// Determines the appropriate [`ReleaseQuery`] for the given channel, taking
134/// environment-variable overrides (mirror URL, pinned version) into account.
135pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery {
136    let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
137    if let Some(base_url) = release_base_url_from_env(&version) {
138        return ReleaseQuery::Mirror { base_url, version };
139    }
140
141    match channel {
142        ReleaseChannel::Stable => ReleaseQuery::GitHubLatest {
143            url: LATEST_RELEASE_URL,
144        },
145        ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL },
146    }
147}
148
149/// Reads the release base URL from environment variables, falling back to the
150/// CNB mirror if `CODEWHALE_USE_CNB_MIRROR` is set. Returns `None` when no
151/// override is configured.
152pub fn release_base_url_from_env(version: &str) -> Option<String> {
153    for env_name in [
154        RELEASE_BASE_URL_ENV,
155        LEGACY_RELEASE_BASE_URL_ENV,
156        DEEPSEEK_RELEASE_BASE_URL_ENV,
157    ] {
158        if let Ok(value) = std::env::var(env_name) {
159            let trimmed = value.trim().to_string();
160            if !trimmed.is_empty() {
161                return Some(trimmed);
162            }
163        }
164    }
165
166    if std::env::var(CNB_MIRROR_ENV).is_ok() {
167        return Some(cnb_release_base_url(version));
168    }
169    None
170}
171
172/// Constructs the CNB mirror asset URL for a given version tag.
173pub fn cnb_release_base_url(version: &str) -> String {
174    format!(
175        "{}/v{}",
176        CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
177        version.trim_start_matches('v')
178    )
179}
180
181/// Returns the pinned update version from environment variables, or `None`
182/// if neither `DEEPSEEK_TUI_VERSION` nor `DEEPSEEK_VERSION` is set.
183pub fn update_version_from_env() -> Option<String> {
184    std::env::var(UPDATE_VERSION_ENV)
185        .ok()
186        .or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
187        .map(|value| value.trim().trim_start_matches('v').to_string())
188        .filter(|value| !value.is_empty())
189}
190
191/// Joins a mirror base URL with an asset filename to produce a full download URL.
192pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
193    format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
194}
195
196/// Returns a human-readable hint explaining how to use a mirror when GitHub
197/// downloads are blocked or slow (e.g. on mainland China networks).
198pub fn update_network_fallback_hint() -> String {
199    format!(
200        "GitHub release downloads may be blocked or slow on this network.\n\
201         For mainland China, use one of these fallback paths:\n\
202           1. Source build from the CNB mirror, installing the shipped binary:\n\
203              cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --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 codewhale platform binary."
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/codewhale.net/codewhale/-/releases/download/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        assert!(
624            hint.contains("codewhale platform binary"),
625            "hint must describe the sole implementation asset"
626        );
627        assert!(
628            !hint.contains("codewhale-tui"),
629            "hint must not request the removed TUI implementation asset"
630        );
631    }
632
633    #[test]
634    fn mirror_asset_url_trims_trailing_base_slashes() {
635        for base_url in [
636            "https://example.com/assets",
637            "https://example.com/assets/",
638            "https://example.com/assets//",
639        ] {
640            assert_eq!(
641                mirror_asset_url(base_url, "file.zip"),
642                "https://example.com/assets/file.zip",
643                "{base_url} should join with a single slash"
644            );
645        }
646        assert_eq!(mirror_asset_url("", "file.zip"), "/file.zip");
647    }
648
649    #[test]
650    fn resolve_release_query_uses_github_without_overrides() {
651        let _env = ReleaseEnvGuard::clear();
652
653        assert_eq!(
654            resolve_release_query(ReleaseChannel::Stable),
655            ReleaseQuery::GitHubLatest {
656                url: LATEST_RELEASE_URL
657            }
658        );
659        assert_eq!(
660            resolve_release_query(ReleaseChannel::Beta),
661            ReleaseQuery::GitHubReleaseList { url: RELEASES_URL }
662        );
663    }
664
665    #[test]
666    fn resolve_release_query_uses_release_base_url_overrides() {
667        let default_version = env!("CARGO_PKG_VERSION").to_string();
668
669        for (env_name, expected_url) in [
670            (RELEASE_BASE_URL_ENV, "https://primary.example.com/mirror"),
671            (
672                LEGACY_RELEASE_BASE_URL_ENV,
673                "https://legacy.example.com/mirror",
674            ),
675            (
676                DEEPSEEK_RELEASE_BASE_URL_ENV,
677                "https://deepseek.example.com/mirror",
678            ),
679        ] {
680            let _env = ReleaseEnvGuard::clear();
681            set_release_env(env_name, expected_url);
682
683            assert_eq!(
684                resolve_release_query(ReleaseChannel::Stable),
685                ReleaseQuery::Mirror {
686                    base_url: expected_url.to_string(),
687                    version: default_version.clone(),
688                },
689                "{env_name} should drive mirror query"
690            );
691        }
692    }
693
694    #[test]
695    fn resolve_release_query_uses_cnb_mirror_override() {
696        let _env = ReleaseEnvGuard::clear();
697        let default_version = env!("CARGO_PKG_VERSION").to_string();
698        set_release_env(CNB_MIRROR_ENV, "1");
699
700        assert_eq!(
701            resolve_release_query(ReleaseChannel::Stable),
702            ReleaseQuery::Mirror {
703                base_url: cnb_release_base_url(&default_version),
704                version: default_version,
705            }
706        );
707    }
708
709    #[test]
710    fn resolve_release_query_uses_pinned_release_versions_for_mirrors() {
711        {
712            let _env = ReleaseEnvGuard::clear();
713            set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
714            set_release_env(UPDATE_VERSION_ENV, "v1.2.3");
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".to_string(),
721                }
722            );
723        }
724
725        {
726            let _env = ReleaseEnvGuard::clear();
727            set_release_env(RELEASE_BASE_URL_ENV, "https://example.com/mirror");
728            set_release_env(LEGACY_UPDATE_VERSION_ENV, "v1.2.3-legacy");
729
730            assert_eq!(
731                resolve_release_query(ReleaseChannel::Stable),
732                ReleaseQuery::Mirror {
733                    base_url: "https://example.com/mirror".to_string(),
734                    version: "1.2.3-legacy".to_string(),
735                }
736            );
737        }
738    }
739
740    #[test]
741    fn cnb_release_base_url_includes_tag_directory() {
742        assert_eq!(
743            cnb_release_base_url("0.8.47"),
744            "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
745        );
746        assert_eq!(
747            cnb_release_base_url("v0.8.47"),
748            "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
749        );
750    }
751
752    #[test]
753    fn stable_update_is_needed_only_when_latest_is_newer() {
754        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
755        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
756        assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
757        assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
758        assert!(
759            !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
760        );
761    }
762
763    #[test]
764    fn beta_update_allows_switching_from_same_stable_to_beta() {
765        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
766        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
767        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
768        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
769        assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
770        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
771    }
772
773    #[test]
774    fn parse_release_version_accepts_tags_and_build_suffixes() {
775        assert_eq!(
776            parse_release_version("v0.9.0-beta.1").unwrap(),
777            semver::Version::parse("0.9.0-beta.1").unwrap()
778        );
779        assert_eq!(
780            parse_release_version("0.8.45 (abcdef123456)").unwrap(),
781            semver::Version::parse("0.8.45").unwrap()
782        );
783    }
784
785    #[test]
786    fn release_version_compare_ignores_v_prefix_and_build_sha() {
787        assert_eq!(
788            compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
789            std::cmp::Ordering::Equal
790        );
791        assert_eq!(
792            compare_release_versions("0.8.39", "v0.8.40").unwrap(),
793            std::cmp::Ordering::Less
794        );
795        assert_eq!(
796            compare_release_versions("0.8.40", "v0.8.39").unwrap(),
797            std::cmp::Ordering::Greater
798        );
799    }
800
801    #[test]
802    fn latest_beta_tag_selects_first_beta_release() {
803        let body = r#"[
804          { "tag_name": "v0.9.0" },
805          { "tag_name": "v0.9.0-rc.1" },
806          { "tag_name": "v0.9.0-beta.2" },
807          { "tag_name": "v0.9.0-beta.1" }
808        ]"#;
809        assert_eq!(
810            latest_beta_tag_from_release_list_json(body).unwrap(),
811            "v0.9.0-beta.2"
812        );
813    }
814
815    #[test]
816    fn latest_beta_tag_reports_missing_beta() {
817        let body = r#"[{ "tag_name": "v0.9.0" }]"#;
818        let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
819        assert!(
820            err.to_string().contains("no beta release found"),
821            "unexpected error: {err:#}"
822        );
823    }
824}