moonup 0.5.2

Manage multiple MoonBit installations
Documentation
use futures_util::TryStreamExt;
#[cfg(target_os = "windows")]
use miette::Context;
use miette::IntoDiagnostic;
use reqwest::Client;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use std::env;
#[cfg(not(target_os = "windows"))]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncRead, BufReader};
use tokio_util::io::StreamReader;
use url::Url;

use crate::reporter::Reporter;

/// Build a basic HTTP client
pub(crate) fn build_http_client() -> Client {
    static APP_USER_AGENT: &str = concat!(
        env!("CARGO_PKG_NAME"),
        "/",
        env!("CARGO_PKG_VERSION"),
        " (+",
        env!("CARGO_PKG_HOMEPAGE"),
        ")"
    );

    Client::builder()
        .user_agent(APP_USER_AGENT)
        .read_timeout(Duration::from_secs(crate::constant::HTTP_READ_TIMEOUT))
        .build()
        .expect("failed to build HTTP client")
}

/// Build an HTTP client with exponential backoff retry policy
pub(crate) fn build_http_client_with_retry() -> ClientWithMiddleware {
    let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
    let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);

    ClientBuilder::new(build_http_client())
        .with(retry_middleware)
        .build()
}

pub(crate) fn build_dist_server_api(path: &str) -> miette::Result<Url> {
    let path = path.trim_start_matches('/');

    let baseurl = env::var(crate::constant::ENVNAME_MOONUP_DIST_SERVER)
        .unwrap_or_else(|_| crate::constant::MOONUP_DIST_SERVER.to_string());
    Url::parse(&format!("{}/{}", baseurl, path))
        .into_diagnostic()
        .inspect(|u| {
            tracing::trace!("constructed dist server API: {}", u);
        })
}

pub async fn url_to_reader(
    url: Url,
    client: &ClientWithMiddleware,
    reporter: Option<Arc<dyn Reporter>>,
) -> miette::Result<impl AsyncRead + use<>> {
    tracing::debug!("streaming: {}", url);
    let request = client.get(url);
    let response = request.send().await.into_diagnostic()?;

    if !response.status().is_success() {
        return Err(std::io::Error::other(format!(
            "failed to download {} (code: {})",
            response.url(),
            response.status()
        )))
        .into_diagnostic();
    }

    if let Some(reporter) = &reporter {
        reporter.on_start(
            response
                .content_length()
                .map(|len| len as usize)
                .unwrap_or(0),
        );
    }

    let mut current = 0;

    let byte_stream = response
        .bytes_stream()
        .inspect_ok(move |chunk| {
            current += chunk.len();
            if let Some(reporter) = &reporter {
                reporter.on_progress(current);
            }
        })
        .map_err(std::io::Error::other);

    Ok(StreamReader::new(byte_stream))
}

pub async fn path_to_reader(path: &Path) -> miette::Result<impl AsyncRead + use<>> {
    let file = tokio::fs::File::open(path).await.into_diagnostic()?;
    Ok(BufReader::new(file))
}

/// Trim the given string and return `None` if the string is empty.
#[inline]
pub(crate) fn trimmed_or_none(s: &str) -> Option<&str> {
    let trimmed = s.trim();
    (!trimmed.is_empty()).then_some(trimmed)
}

/// Pour the new executable to the destination path.
///
/// On Windows, this function will try to rename and remove the old exe
/// before copying the new one. On other platforms, it will just remove
/// the old exe and copy the new one.
pub fn replace_exe(new: &Path, old: &Path) -> miette::Result<()> {
    #[cfg(target_os = "windows")]
    {
        // On Windows, ensure shims created with `.exe` extension
        let old = old.with_extension("exe");
        let older = old.with_extension("exe.old");

        std::fs::remove_file(&older).or_else(|err| match err.kind() {
            std::io::ErrorKind::NotFound => Ok(()),
            std::io::ErrorKind::PermissionDenied => Err(err)
                .into_diagnostic()
                .wrap_err(format!("failed to remove old exe: {}", older.display()))
                .wrap_err(
                    "files may be in use, please close applications using moonbit and try again",
                ),
            _ => Err(err).into_diagnostic(),
        })?;

        match std::fs::rename(&old, &older) {
            Ok(_) => tracing::debug!("renamed old exe: {}", &older.display()),
            Err(err) => match err.kind() {
                std::io::ErrorKind::NotFound => { // ignore
                }
                _ => {
                    return Err(err).into_diagnostic().wrap_err(format!(
                        "failed to rename {} to {}",
                        old.display(),
                        &older.display()
                    ));
                }
            },
        }

        std::fs::copy(new, &old)
            .into_diagnostic()
            .wrap_err(format!(
                "failed to copy {} to {}",
                new.display(),
                old.display()
            ))?;

        tracing::debug!("replaced new exe: {}", old.display());
        let _ = std::fs::remove_file(&older);
    }

    #[cfg(not(target_os = "windows"))]
    {
        let _ = std::fs::remove_file(old);
        std::fs::copy(new, old).into_diagnostic()?;
        std::fs::set_permissions(old, std::fs::Permissions::from_mode(0o755)).into_diagnostic()?;
        tracing::debug!("replaced new exe: {}", old.display());
    }

    Ok(())
}