Skip to main content

codewhale_release/
lib.rs

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