foundryup 0.0.9

Manage foundry toolchain installations with ease
use crate::{
    config::{Config, FOUNDRYUP_REPO, VERSION},
    download::Downloader,
    platform::{Arch, Platform},
    say,
};
use eyre::{Result, WrapErr};
use fs_err as fs;
use semver::Version;
use tracing::debug;

pub(crate) async fn run(config: &Config) -> Result<()> {
    say!("checking for updates...");

    let new_version = match check_for_update(config).await {
        Ok(Some(v)) => v,
        Ok(None) => {
            say!("foundryup is already up to date (installed: {VERSION})");
            return Ok(());
        }
        Err(e) => {
            debug!("update check failed: {e}");
            return Err(e).wrap_err("failed to check for updates");
        }
    };

    say!("downloading foundryup v{new_version}...");

    let downloader = Downloader::new()?;
    // The replacement binary must match this binary's own libc, so a musl build
    // updates from the `alpine` artifact. This differs from the install target,
    // which mirrors the legacy installer and defaults Linux to the glibc build.
    let platform = if cfg!(all(target_os = "linux", target_env = "musl")) {
        Platform::Alpine
    } else {
        Platform::detect()?
    };
    let arch = Arch::detect();
    let archive_name =
        format!("foundryup_{platform}_{arch}", platform = platform.as_str(), arch = arch.as_str());

    let download_url = format!(
        "https://github.com/{FOUNDRYUP_REPO}/releases/download/v{new_version}/{archive_name}"
    );

    let temp_dir = tempfile::tempdir()?;
    let temp_path = temp_dir.path().join("foundryup_new");

    downloader
        .download_to_file(&download_url, &temp_path)
        .await
        .wrap_err_with(|| format!("failed to download foundryup v{new_version}"))?;

    say!("installing update...");

    self_replace::self_replace(&temp_path).wrap_err("failed to replace foundryup binary")?;

    let _ = fs::remove_file(temp_path);

    let _ = config;

    say!("successfully updated foundryup: {VERSION} → {new_version}");

    Ok(())
}

pub(crate) async fn check_for_update(_config: &Config) -> Result<Option<String>> {
    let downloader = Downloader::new()?;

    let remote_version = fetch_latest_foundryup_version(&downloader).await?;

    debug!("current version: {VERSION}, remote version: {remote_version}");

    let current = Version::parse(VERSION).wrap_err("failed to parse current version")?;
    let remote = match Version::parse(&remote_version) {
        Ok(v) => v,
        Err(e) => {
            debug!("failed to parse remote version '{remote_version}': {e}");
            return Ok(None);
        }
    };

    if remote > current { Ok(Some(remote_version)) } else { Ok(None) }
}

/// Resolves the latest published foundryup version (without a leading `v`).
///
/// Prefers the `releases/latest` web redirect, which is not subject to the
/// unauthenticated GitHub API rate limit, mirroring the Foundry release
/// resolver in `install.rs`. Falls back to the GitHub API when the redirect
/// cannot be resolved.
async fn fetch_latest_foundryup_version(downloader: &Downloader) -> Result<String> {
    if let Some(version) = fetch_latest_foundryup_version_via_redirect(downloader).await {
        return Ok(version);
    }

    let releases_url = format!("https://api.github.com/repos/{FOUNDRYUP_REPO}/releases/latest");

    debug!("fetching latest release from {releases_url}");

    let response = downloader
        .download_to_string(&releases_url)
        .await
        .wrap_err("failed to fetch release information")?;

    let json: serde_json::Value =
        serde_json::from_str(&response).wrap_err("failed to parse release JSON")?;

    let tag_name = json["tag_name"]
        .as_str()
        .ok_or_else(|| eyre::eyre!("missing tag_name in release response"))?;

    Ok(tag_name.trim_start_matches('v').to_string())
}

/// Resolves the latest foundryup version by following the `releases/latest`
/// redirect and reading the tag from the final `releases/tag/<tag>` URL.
///
/// Returns `None` (so the caller falls back to the API) when the redirect cannot
/// be resolved or the final URL does not yield a valid tag.
async fn fetch_latest_foundryup_version_via_redirect(downloader: &Downloader) -> Option<String> {
    let url = format!("https://github.com/{FOUNDRYUP_REPO}/releases/latest");
    let final_url = downloader.resolve_redirect_url(&url).await.ok()?;
    let tag = crate::install::tag_from_release_url(&final_url)?;
    Some(tag.trim_start_matches('v').to_string())
}