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 super::*;
351
352    #[test]
353    fn cnb_release_base_url_includes_tag_directory() {
354        assert_eq!(
355            cnb_release_base_url("0.8.47"),
356            "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
357        );
358        assert_eq!(
359            cnb_release_base_url("v0.8.47"),
360            "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
361        );
362    }
363
364    #[test]
365    fn stable_update_is_needed_only_when_latest_is_newer() {
366        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
367        assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
368        assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
369        assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
370        assert!(
371            !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
372        );
373    }
374
375    #[test]
376    fn beta_update_allows_switching_from_same_stable_to_beta() {
377        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
378        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
379        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
380        assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
381        assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
382        assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
383    }
384
385    #[test]
386    fn parse_release_version_accepts_tags_and_build_suffixes() {
387        assert_eq!(
388            parse_release_version("v0.9.0-beta.1").unwrap(),
389            semver::Version::parse("0.9.0-beta.1").unwrap()
390        );
391        assert_eq!(
392            parse_release_version("0.8.45 (abcdef123456)").unwrap(),
393            semver::Version::parse("0.8.45").unwrap()
394        );
395    }
396
397    #[test]
398    fn release_version_compare_ignores_v_prefix_and_build_sha() {
399        assert_eq!(
400            compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
401            std::cmp::Ordering::Equal
402        );
403        assert_eq!(
404            compare_release_versions("0.8.39", "v0.8.40").unwrap(),
405            std::cmp::Ordering::Less
406        );
407        assert_eq!(
408            compare_release_versions("0.8.40", "v0.8.39").unwrap(),
409            std::cmp::Ordering::Greater
410        );
411    }
412
413    #[test]
414    fn latest_beta_tag_selects_first_beta_release() {
415        let body = r#"[
416          { "tag_name": "v0.9.0" },
417          { "tag_name": "v0.9.0-rc.1" },
418          { "tag_name": "v0.9.0-beta.2" },
419          { "tag_name": "v0.9.0-beta.1" }
420        ]"#;
421        assert_eq!(
422            latest_beta_tag_from_release_list_json(body).unwrap(),
423            "v0.9.0-beta.2"
424        );
425    }
426
427    #[test]
428    fn latest_beta_tag_reports_missing_beta() {
429        let body = r#"[{ "tag_name": "v0.9.0" }]"#;
430        let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
431        assert!(
432            err.to_string().contains("no beta release found"),
433            "unexpected error: {err:#}"
434        );
435    }
436}