Abbaye

at 6297799

//! Self-update logic for the `abbaye self-update` command.
//!
//! Fetches the project's Atom feed, finds the latest release version,
//! compares it against the running binary's version, and – unless `--check`
//! was passed – downloads the appropriate pre-built binary and atomically
//! replaces the current executable.

use miette::{IntoDiagnostic, Result, miette};
use semver::Version;
use tracing::info;

/// Compile-time target triple (e.g. `x86_64-unknown-linux-musl`).
const TARGET: &str = env!("TARGET");
/// Version baked in at compile time (e.g. `0.5.1`).
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Root URL of the release site.
const BASE_URL: &str = "https://vit.am/~ololduck/abbaye";
/// Atom feed URL.
const FEED_URL: &str = "https://vit.am/~ololduck/abbaye/releases.atom";

/// Extract all semver-parseable versions from the `<title>` elements inside
/// `<entry>` blocks of an Atom feed.
///
/// The feed is generated by abbaye itself and has a predictable structure:
/// each entry's title appears on its own line as `    <title>X.Y.Z</title>`.
/// The feed-level `<title>Abbaye Releases</title>` will not parse as semver
/// and is silently ignored.
fn parse_versions_from_feed(xml: &str) -> Vec<Version> {
    let mut versions = Vec::new();
    let mut in_entry = false;

    for line in xml.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("<entry>") {
            in_entry = true;
        } else if trimmed == "</entry>" {
            in_entry = false;
        } else if in_entry {
            if let Some(rest) = trimmed.strip_prefix("<title>") {
                if let Some(title) = rest.strip_suffix("</title>") {
                    // Strip a leading 'v' in case the title ever includes it.
                    let v = title.strip_prefix('v').unwrap_or(title);
                    if let Ok(version) = Version::parse(v) {
                        versions.push(version);
                    }
                }
            }
        }
    }

    versions
}

/// Check for a newer version and – unless `check_only` is `true` – download
/// and atomically replace the running binary.
///
/// # Errors
///
/// Returns an error if the feed cannot be fetched or parsed, the download
/// fails, or the binary cannot be replaced (e.g. insufficient permissions).
pub async fn self_update(check_only: bool) -> Result<()> {
    let current = Version::parse(CURRENT_VERSION).into_diagnostic()?;

    info!("Checking for updates (current: v{current})…");
    println!("Checking for updates (current: v{current})…");

    let client = reqwest::Client::builder()
        .user_agent(format!("abbaye/{CURRENT_VERSION}"))
        .build()
        .into_diagnostic()?;

    let response = client.get(FEED_URL).send().await.into_diagnostic()?;

    if !response.status().is_success() {
        return Err(miette!(
            "Failed to fetch release feed (HTTP {}): {FEED_URL}",
            response.status()
        ));
    }

    let feed_xml = response.text().await.into_diagnostic()?;
    let mut versions = parse_versions_from_feed(&feed_xml);

    if versions.is_empty() {
        return Err(miette!(
            "No versions found in the release feed at {FEED_URL}"
        ));
    }

    versions.sort();
    let latest = versions.into_iter().max().unwrap();

    if latest <= current {
        println!("Already up to date (v{current}).");
        return Ok(());
    }

    println!("Update available: v{current} → v{latest}");

    if check_only {
        println!("Run `abbaye self-update` without --check to apply the update.");
        return Ok(());
    }

    let exe_suffix = std::env::consts::EXE_SUFFIX; // ".exe" on Windows, "" elsewhere
    let artifact_name = format!("abbaye-{latest}-{TARGET}{exe_suffix}");
    let download_url = format!("{BASE_URL}/{latest}/dist/{artifact_name}");

    println!("Downloading {artifact_name}…");
    info!("Downloading from {download_url}");

    let dl_response = client.get(&download_url).send().await.into_diagnostic()?;

    if !dl_response.status().is_success() {
        return Err(miette!(
            "Download failed (HTTP {}): {download_url}",
            dl_response.status()
        ));
    }

    let bytes = dl_response.bytes().await.into_diagnostic()?;

    // Using the same directory as the current exe guarantees we stay on the
    // same filesystem, making the subsequent rename(2) atomic.
    let current_exe = std::env::current_exe()
        .into_diagnostic()?
        .canonicalize()
        .into_diagnostic()?;

    let exe_dir = current_exe
        .parent()
        .ok_or_else(|| miette!("Could not determine the directory of the current executable"))?;

    let tmp_path = exe_dir.join(format!("abbaye.new{exe_suffix}"));

    std::fs::write(&tmp_path, &bytes).into_diagnostic()?;

    // Make the new binary executable on Unix.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = std::fs::Permissions::from_mode(0o755);
        std::fs::set_permissions(&tmp_path, perms).into_diagnostic()?;
    }

    // Atomically replace the running binary.
    std::fs::rename(&tmp_path, &current_exe).into_diagnostic()?;

    println!("Successfully updated to v{latest}!");
    info!("Updated to v{latest}");

    Ok(())
}