at 2f4eb91
//! 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, ¤t_exe).into_diagnostic()?; println!("Successfully updated to v{latest}!"); info!("Updated to v{latest}"); Ok(()) }