ib-update 0.1.0

A lightweight library for software update
Documentation
/*!

GitHub Releases update source.

This module queries the [GitHub REST API](https://docs.github.com/rest/releases/releases#list-releases)
for the latest release of a given repository and compares it against the current version
to determine whether an update is available.
*/
use serde::Deserialize;
use thiserror::Error;

#[cfg(all(feature = "github-extra", feature = "multi-server"))]
use update_config_builder::{IsUnset, SetBaseUrls, State};

#[cfg(feature = "async")]
mod r#async;
#[cfg(feature = "blocking")]
mod blocking;

#[cfg(feature = "async")]
pub use r#async::AsyncUpdateChecker;
#[cfg(feature = "blocking")]
pub use blocking::UpdateChecker;

/// Errors that can occur during an update check.
#[derive(Debug, Error)]
pub enum Error {
    /// Reused when possible to save binary size.
    #[error(transparent)]
    Nyquest(#[from] nyquest::Error),

    /// GitHub:
    /// ```json
    /// {"message":"API rate limit exceeded for 1.2.3.4. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)","documentation_url":"https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"}
    /// ```
    #[error("rate limit")]
    RateLimit,
}

impl Error {
    pub const NO_RELEASE: Error = Error::Nyquest(nyquest::Error::NonSuccessfulStatusCode(
        nyquest::StatusCode::new(404),
    ));
}

/// A GitHub release as returned by the `/releases/latest` endpoint.
///
/// Example: https://api.github.com/repos/Chaoses-Ib/IbEverythingExt/releases/latest
#[derive(Debug, Clone, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
pub struct Release {
    /// The release tag name (e.g. `"v1.2.3"`).
    #[serde(rename = "tag_name")]
    pub tag: String,

    /// The human-readable release name (may differ from the tag).
    pub name: Option<String>,

    /// Whether this is marked as a pre-release on GitHub.
    #[serde(default)]
    pub prerelease: bool,

    /// ISO 8601 timestamp of when the release was created.
    pub created_at: String,

    /// ISO 8601 timestamp of the publish.
    pub published_at: Option<String>,

    /// Markdown release notes / changelog.
    pub body: Option<String>,

    /// URL to the release page on GitHub.
    pub html_url: String,

    /// Uploaded binaries / archives.
    #[serde(default)]
    pub assets: Vec<ReleaseAsset>,
}

impl Release {
    /// Check the HTTP status, returning an error for non-successful responses.
    fn check_status(status: nyquest::StatusCode, body: &[u8]) -> Result<(), Error> {
        if !status.is_successful() {
            // body.contains("rate")
            if status == 403 && body.array_windows::<4>().any(|x| x == b"rate") {
                return Err(Error::RateLimit);
            }
            return Err(Error::Nyquest(nyquest::Error::NonSuccessfulStatusCode(
                status,
            )));
        }
        Ok(())
    }

    /// Parse a HTTP response body into a [`Release`].
    ///
    /// - `body`: Using `Vec<u8>` is 7/10.5 KiB smaller than `String`,
    ///   plus `nyquest` `json()` uses `Vec<u8>` too.
    pub(crate) fn parse(status: nyquest::StatusCode, body: Vec<u8>) -> Result<Release, Error> {
        Self::check_status(status, &body)?;
        let release: Release = serde_json::from_slice(&body).map_err(nyquest::Error::from)?;
        Ok(release)
    }

    /// Parse a HTTP response body into an array of [`Release`]s.
    pub(crate) fn parse_array(
        status: nyquest::StatusCode,
        body: Vec<u8>,
    ) -> Result<Vec<Release>, Error> {
        Self::check_status(status, &body)?;
        let releases: Vec<Release> = serde_json::from_slice(&body).map_err(nyquest::Error::from)?;
        Ok(releases)
    }
}

/// A single release asset (binary, archive, etc.) attached to a GitHub release.
#[derive(Debug, Clone, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ReleaseAsset {
    /// File name of the asset.
    pub name: String,

    /// Direct download URL.
    #[serde(rename = "browser_download_url")]
    pub download_url: String,

    /// Size in bytes (may be null).
    pub size: u64,

    /// SHA-256 digest (format: `"sha256:<hex>"`).
    pub digest: Option<String>,

    /// Number of times this asset has been downloaded.
    ///
    /// Size +1.5/1.5 KiB
    #[cfg(feature = "github-extra")]
    pub download_count: u64,
}

/// Result of an update check — contains the latest release and comparison info.
#[derive(Debug, Clone)]
pub struct UpdateInfo {
    /// The current version that was compared against (if any was provided).
    current_version: Option<String>,
    /// The latest release from GitHub.
    latest: Release,
    /// `true` if an update is recommended.
    has_update: bool,
}

impl UpdateInfo {
    /// Build an [`UpdateInfo`] from a [`Release`] and optional current version.
    pub(crate) fn new(current_version: Option<&str>, latest: Release) -> UpdateInfo {
        let has_update = match current_version {
            Some(cv) => crate::git::is_newer(cv, &latest.tag),
            None => true,
        };

        UpdateInfo {
            current_version: current_version.map(str::to_string),
            latest,
            has_update,
        }
    }

    /// Returns the current version that was compared against, if any was provided.
    pub fn current_version(&self) -> Option<&str> {
        self.current_version.as_deref()
    }

    /// Returns a reference to the latest release from GitHub.
    pub fn latest(&self) -> &Release {
        &self.latest
    }

    /// Returns `true` if an update is recommended.
    ///
    /// This is `true` when:
    /// - No `current_version` was set (any release is "new"), or
    /// - The latest version is strictly greater than the current version
    ///   (compared via [`semver`] when both parse, otherwise lexicographically).
    #[doc(alias = "update_available")]
    #[inline]
    pub fn has_update(&self) -> bool {
        self.has_update
    }
}

/// Shared configuration for update checks — contains no I/O logic.
///
/// Both the async and blocking update checkers use this internally.
#[derive(bon::Builder)]
#[builder(on(String, into))]
pub struct UpdateConfig {
    /// GitHub repository owner (e.g. `"rust-lang"`).
    pub(crate) owner: String,

    /// GitHub repository name (e.g. `"rust"`).
    pub(crate) repo: String,

    /// Current version to compare against.
    pub(crate) current_version: Option<String>,

    /// Optional personal access token for higher rate limits.
    #[cfg(feature = "github-extra")]
    pub(crate) token: Option<String>,

    /// Base URL for the GitHub API. Defaults to `https://api.github.com`.
    #[cfg(all(feature = "github-extra", not(feature = "multi-server")))]
    pub(crate) base_url: Option<String>,

    /// Base URLs for the GitHub API. Defaults to `https://api.github.com` only.
    #[cfg(feature = "multi-server")]
    // #[builder(with = |s: String| vec![s], default)]
    #[builder(default = vec!["https://api.github.com".into()])]
    pub(crate) base_urls: Vec<String>,

    /// Custom user-agent header value.
    #[cfg(feature = "github-extra")]
    pub(crate) user_agent: Option<String>,
}

#[cfg(all(feature = "github-extra", feature = "multi-server"))]
impl<S: State> UpdateConfigBuilder<S> {
    pub fn base_url(self, url: String) -> UpdateConfigBuilder<SetBaseUrls<S>>
    where
        S::BaseUrls: IsUnset,
    {
        self.base_urls(vec![url])
    }

    pub fn maybe_base_url(self, url: Option<String>) -> UpdateConfigBuilder<SetBaseUrls<S>>
    where
        S::BaseUrls: IsUnset,
    {
        self.maybe_base_urls(url.map(|s| vec![s]))
    }
}

impl UpdateConfig {
    /// Build nyquest [`ClientBuilder`](nyquest::ClientBuilder)s configured
    /// for GitHub API requests.
    ///
    /// ## Returns
    /// Fortunately, returning `impl Iterator<Item=ClientBuilder>` doesn't increase binary size compared to `ClientBuilder`.
    #[inline(always)]
    pub(crate) fn client_builder(&self) -> impl Iterator<Item = nyquest::ClientBuilder> {
        let it = cfg_select! {
            feature = "multi-server" => self.base_urls.iter(),
            feature = "github-extra" => {{
                // #[builder(default = vec!["https://api.github.com".into()])] would be larger
                // let base = unsafe { self.base_urls.get_unchecked(0) }.as_ref();
                // let base = self.base_urls.get(0).map_or("https://api.github.com", |s| s);
                let base = self.base_url.as_deref().unwrap_or("https://api.github.com");
                [base].into_iter()
            }}
            _ => ["https://api.github.com"].into_iter()
        };

        let default_ua = concat!("ib-update/", env!("CARGO_PKG_VERSION"));
        let ua = cfg_select! {
            // Size +0.5/0 KiB
            feature = "github-extra" => self.user_agent.as_deref().unwrap_or(&default_ua),
            _ => default_ua,
        };

        it.map(move |base| {
            #[allow(unused_mut)]
            let mut builder = nyquest::ClientBuilder::default()
                .base_url(base)
                .user_agent(ua);

            // Size +1/3 KiB
            #[cfg(feature = "github-extra")]
            if let Some(ref token) = self.token {
                builder = builder.with_header("Authorization", format!("Bearer {token}"));
            }

            builder
        })
    }

    /// The API path for fetching the latest release.
    ///
    /// - 404 if no release yet.
    ///
    /// Reference: [REST API endpoints for releases - GitHub Docs](https://docs.github.com/en/rest/releases/releases)
    pub(crate) fn releases_latest_path(&self) -> String {
        format!("repos/{}/{}/releases/latest", self.owner, self.repo)
    }

    /// The API path for listing all releases.
    ///
    /// - Empty if no releases yet.
    ///
    /// Reference: [List releases - GitHub Docs](https://docs.github.com/en/rest/releases/releases#list-releases)
    pub(crate) fn releases_path(&self, per_page: u8) -> String {
        format!(
            "repos/{}/{}/releases?per_page={per_page}",
            self.owner, self.repo
        )
    }
}

#[cfg(test)]
mod tests {
    /// Test repository owner.
    pub const TEST_OWNER: &str = "Chaoses-Ib";
    /// Test repository name.
    pub const TEST_REPO: &str = "IbEverythingExt";
}