ib-update 0.1.0

A lightweight library for software update
Documentation
use super::update_config_builder as builder;
use crate::github::{Error, Release, UpdateConfig, UpdateConfigBuilder, UpdateInfo};
use crate::http::AsyncHttp;

/// Async builder for configuring and executing an update check against a GitHub repo.
///
/// # Example
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// nyquest_preset::register();
/// futures::executor::block_on(async {
///     let checker = ib_update::github::UpdateConfig::builder()
///         .owner("owner")
///         .repo("repo")
///         .current_version("1.0.0")
///         .token("ghp_...")
///         .build_async();
///     let info = checker.check().await?;
///     println!("Latest: {}, update available: {}", info.latest().tag, info.has_update());
///     Ok(())
/// })
/// # }
/// ```
pub struct AsyncUpdateChecker {
    inner: UpdateConfig,
}

impl AsyncUpdateChecker {
    /// Create an `AsyncUpdateChecker` from a built [`UpdateConfig`].
    pub fn new(config: UpdateConfig) -> Self {
        Self { inner: config }
    }

    /// Fetch the latest release from GitHub.
    pub async fn fetch_latest(&self) -> Result<Release, Error> {
        let res = AsyncHttp::get(
            self.inner.client_builder(),
            self.inner.releases_latest_path(),
        )
        .await?;

        let status = res.status();
        let body = res.bytes().await?;
        Release::parse(status, body)
    }

    /// Fetch up to `n` releases (including pre-releases) from GitHub.
    ///
    /// - `n`: Must be <= 100 at the moment.
    pub async fn fetch(&self, n: usize) -> Result<Vec<Release>, Error> {
        debug_assert!(n <= 100);
        let res = AsyncHttp::get(
            self.inner.client_builder(),
            self.inner.releases_path(n as u8),
        )
        .await?;

        let status = res.status();
        let body = res.bytes().await?;
        Release::parse_array(status, body)
    }

    /// Fetch the latest release, including pre-releases.
    pub async fn fetch_latest_prerelease(&self) -> Result<Release, Error> {
        let releases = self.fetch(1).await?;
        releases.into_iter().next().ok_or(Error::NO_RELEASE)
    }

    /// Execute the update check.
    ///
    /// Fetches the latest release from GitHub and compares it against
    /// the current version (if set).
    pub async fn check(&self) -> Result<UpdateInfo, Error> {
        // TODO: rustc can't optimize this yet even with #[inline(always)]
        // self.check_prerelease(false).await
        let release = self.fetch_latest().await?;
        Ok(UpdateInfo::new(
            self.inner.current_version.as_deref(),
            release,
        ))
    }

    /// Execute the update check, optionally including pre-releases.
    ///
    /// You likely want to use `!env!("CARGO_PKG_VERSION_PRE").is_empty()` as the default value.
    pub async fn check_prerelease(&self, prerelease: bool) -> Result<UpdateInfo, Error> {
        let release = if prerelease {
            self.fetch_latest_prerelease().await
        } else {
            self.fetch_latest().await
        }?;
        Ok(UpdateInfo::new(
            self.inner.current_version.as_deref(),
            release,
        ))
    }
}

impl<S: builder::State> UpdateConfigBuilder<S> {
    /// Build the configuration and return an [`AsyncUpdateChecker`].
    ///
    /// This is a convenience method that combines [`UpdateConfigBuilder::build`](UpdateConfigBuilder::build)
    /// with [`AsyncUpdateChecker::new`].
    ///
    /// ```no_run
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// nyquest_preset::register();
    /// futures::executor::block_on(async {
    ///     let checker = ib_update::github::UpdateConfig::builder()
    ///         .owner("owner")
    ///         .repo("repo")
    ///         .current_version("1.0.0")
    ///         .build_async();
    ///     let info = checker.check().await?;
    ///     println!("Latest: {}, update available: {}", info.latest().tag, info.has_update());
    ///     Ok(())
    /// })
    /// # }
    /// ```
    pub fn build_async(self) -> AsyncUpdateChecker
    where
        S: builder::IsComplete,
    {
        let config = self.build();
        AsyncUpdateChecker::new(config)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::github::tests::{TEST_OWNER, TEST_REPO};

    #[test]
    fn builder_and_check() {
        futures::executor::block_on(async {
            let info = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .current_version("0.1.0")
                .build_async()
                .check()
                .await
                .unwrap();
            dbg!(&info);
            assert!(info.has_update());
            assert!(!info.latest().assets.is_empty());
        });
    }

    #[test]
    fn fetch_latest() {
        futures::executor::block_on(async {
            let release = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .build_async()
                .fetch_latest()
                .await
                .unwrap();

            assert!(
                release.tag.starts_with('v'),
                "tag should start with 'v', got: {}",
                release.tag
            );
            assert!(release.name.is_some(), "release name should be Some");
            assert!(!release.name.as_ref().unwrap().is_empty());
            assert!(!release.assets.is_empty(), "release should have assets");

            for asset in &release.assets {
                assert!(!asset.name.is_empty());
                assert!(
                    asset.download_url.starts_with("https://"),
                    "download URL should be HTTPS: {}",
                    asset.download_url
                );
            }

            assert!(
                release.html_url.contains("Chaoses-Ib/IbEverythingExt"),
                "html_url should contain the repo path"
            );
            assert!(release.body.is_some(), "release body should be Some");
            assert!(!release.body.as_ref().unwrap().is_empty());
        });
    }

    #[test]
    fn fetch() {
        futures::executor::block_on(async {
            let releases = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .build_async()
                .fetch(5)
                .await
                .unwrap();

            assert!(!releases.is_empty(), "should have at least one release");

            for release in &releases {
                assert!(!release.tag.is_empty());
                assert!(release.html_url.contains("Chaoses-Ib/IbEverythingExt"));
            }
        });
    }

    #[test]
    fn fetch_latest_prerelease() {
        futures::executor::block_on(async {
            let release = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .build_async()
                .fetch_latest_prerelease()
                .await
                .unwrap();

            assert!(!release.tag.is_empty());
            assert!(release.html_url.contains("Chaoses-Ib/IbEverythingExt"));
        });
    }

    #[test]
    fn current_version() {
        futures::executor::block_on(async {
            let latest = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .build_async()
                .fetch_latest()
                .await
                .unwrap();

            let info = UpdateConfig::builder()
                .owner(TEST_OWNER)
                .repo(TEST_REPO)
                .current_version(&latest.tag)
                .build_async()
                .check()
                .await
                .unwrap();

            assert!(
                !info.has_update(),
                "checking against the latest tag ({}) should not need an update",
                latest.tag
            );
        });
    }
}