anesis 0.9.1

CLI for scaffolding projects from remote templates and extending them with project addons
Documentation
use std::{
  env, fs,
  path::{Path, PathBuf},
};

use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use reqwest::{
  Client,
  header::{ACCEPT, USER_AGENT},
};
use serde::{Deserialize, Serialize};

use crate::AppContext;

const RELEASES_API_URL: &str = "https://api.github.com/repos/anesis-dev/anesis/releases/latest";
const RELEASES_DOWNLOAD_BASE_URL: &str = "https://github.com/anesis-dev/anesis/releases/download";

#[derive(Debug, Deserialize)]
struct LatestReleaseResponse {
  tag_name: String,
}

#[derive(Debug, Deserialize, Serialize)]
struct VersionCheckCache {
  last_checked: String,
  latest_version: String,
}

pub async fn check_latest_cli_version(client: &Client) -> Result<String> {
  let release: LatestReleaseResponse = client
    .get(releases_api_url())
    .header(ACCEPT, "application/vnd.github+json")
    .header(USER_AGENT, github_user_agent())
    .send()
    .await
    .context("Failed to query the latest Anesis release")?
    .error_for_status()
    .context("GitHub releases endpoint returned an error")?
    .json()
    .await
    .context("Failed to parse latest Anesis release metadata")?;

  normalize_version_tag(&release.tag_name)
}

pub async fn upgrade_cli(ctx: &AppContext) -> Result<()> {
  let current_version = env!("CARGO_PKG_VERSION");

  println!("Checking for updates...");
  let latest_version = check_latest_cli_version(&ctx.client).await?;
  if !is_newer_version(current_version, &latest_version)? {
    println!("Anesis v{current_version} is already the latest version.");
    return Ok(());
  }

  let platform = current_platform()?;
  let asset_url = release_asset_url(&latest_version, platform);
  let current_exe = env::current_exe().context("Failed to locate the current Anesis executable")?;

  println!("Downloading Anesis v{latest_version}...");
  let binary = ctx
    .client
    .get(&asset_url)
    .header(USER_AGENT, github_user_agent())
    .send()
    .await
    .with_context(|| format!("Failed to download Anesis v{latest_version}"))?
    .error_for_status()
    .with_context(|| format!("GitHub release asset was not available at {asset_url}"))?
    .bytes()
    .await
    .with_context(|| format!("Failed to read the downloaded Anesis v{latest_version} binary"))?;

  let temp_exe = write_temp_binary(&current_exe, binary.as_ref())?;
  mark_executable(&temp_exe)?;
  replace_current_executable(&current_exe, &temp_exe)?;

  println!("✓ Anesis updated to v{latest_version}. Restart your shell if needed.");
  Ok(())
}

pub async fn check_cli_version_cached(client: &Client, path: &Path) -> Result<Option<String>> {
  if let Some(cache) = read_version_check_cache(path)?
    && is_cache_fresh(&cache, Utc::now())
  {
    return newer_version_if_available(&cache.latest_version);
  }

  let latest_version = check_latest_cli_version(client).await?;
  write_version_check_cache(
    path,
    &VersionCheckCache {
      last_checked: Utc::now().to_rfc3339(),
      latest_version: latest_version.clone(),
    },
  )?;

  newer_version_if_available(&latest_version)
}

pub fn render_upgrade_notice(latest_version: &str) -> String {
  format!(
    "\n  A new version of Anesis is available: v{} → v{}\n  Run `anesis upgrade` to update.",
    env!("CARGO_PKG_VERSION"),
    latest_version
  )
}

fn github_user_agent() -> String {
  format!("anesis/{}", env!("CARGO_PKG_VERSION"))
}

fn releases_api_url() -> String {
  env::var("ANESIS_RELEASES_API_URL").unwrap_or_else(|_| RELEASES_API_URL.to_string())
}

fn releases_download_base_url() -> String {
  env::var("ANESIS_RELEASES_DOWNLOAD_BASE_URL")
    .unwrap_or_else(|_| RELEASES_DOWNLOAD_BASE_URL.to_string())
}

fn normalize_version_tag(tag_name: &str) -> Result<String> {
  let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
  parse_version(version)?;
  Ok(version.to_string())
}

#[doc(hidden)]
pub fn normalize_version_tag_for_tests(tag_name: &str) -> Result<String> {
  normalize_version_tag(tag_name)
}

fn read_version_check_cache(path: &Path) -> Result<Option<VersionCheckCache>> {
  if !path.exists() {
    return Ok(None);
  }

  let content = fs::read_to_string(path)
    .with_context(|| format!("Failed to read version cache at {}", path.display()))?;
  let cache = match serde_json::from_str::<VersionCheckCache>(&content) {
    Ok(cache) => cache,
    Err(_) => return Ok(None),
  };
  Ok(Some(cache))
}

fn write_version_check_cache(path: &Path, cache: &VersionCheckCache) -> Result<()> {
  if let Some(parent) = path.parent() {
    fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
  }

  fs::write(path, serde_json::to_string_pretty(cache)?)
    .with_context(|| format!("Failed to write version cache to {}", path.display()))?;
  Ok(())
}

fn parse_version(version: &str) -> Result<(u64, u64, u64)> {
  let mut parts = version.split('.');
  let major = parse_version_component(parts.next(), "major", version)?;
  let minor = parse_version_component(parts.next(), "minor", version)?;
  let patch = parse_version_component(parts.next(), "patch", version)?;
  if parts.next().is_some() {
    return Err(anyhow!("Unsupported version format '{version}'"));
  }

  Ok((major, minor, patch))
}

#[doc(hidden)]
pub fn parse_version_for_tests(version: &str) -> Result<(u64, u64, u64)> {
  parse_version(version)
}

fn parse_version_component(component: Option<&str>, label: &str, version: &str) -> Result<u64> {
  let component =
    component.ok_or_else(|| anyhow!("Missing {label} version component in '{version}'"))?;
  component
    .parse::<u64>()
    .with_context(|| format!("Invalid {label} version component in '{version}'"))
}

fn is_newer_version(current: &str, latest: &str) -> Result<bool> {
  Ok(parse_version(latest)? > parse_version(current)?)
}

#[doc(hidden)]
pub fn is_newer_version_for_tests(current: &str, latest: &str) -> Result<bool> {
  is_newer_version(current, latest)
}

fn newer_version_if_available(latest: &str) -> Result<Option<String>> {
  if is_newer_version(env!("CARGO_PKG_VERSION"), latest)? {
    Ok(Some(latest.to_string()))
  } else {
    Ok(None)
  }
}

fn is_cache_fresh(cache: &VersionCheckCache, now: DateTime<Utc>) -> bool {
  let Ok(last_checked) = DateTime::parse_from_rfc3339(&cache.last_checked) else {
    return false;
  };

  now.signed_duration_since(last_checked.with_timezone(&Utc)) < ChronoDuration::hours(1)
}

#[doc(hidden)]
pub fn is_cache_fresh_for_tests(
  last_checked: &str,
  latest_version: &str,
  now: DateTime<Utc>,
) -> bool {
  is_cache_fresh(
    &VersionCheckCache {
      last_checked: last_checked.to_string(),
      latest_version: latest_version.to_string(),
    },
    now,
  )
}

fn current_platform() -> Result<&'static str> {
  if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
    Ok("linux-x86_64")
  } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
    Ok("macos-aarch64")
  } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
    Ok("windows-x86_64")
  } else {
    Err(anyhow!(
      "Unsupported platform for self-update: {}-{}",
      env::consts::OS,
      env::consts::ARCH
    ))
  }
}

fn asset_filename(platform: &str) -> String {
  if platform.starts_with("windows-") {
    format!("anesis-{platform}.exe")
  } else {
    format!("anesis-{platform}")
  }
}

#[doc(hidden)]
pub fn asset_filename_for_tests(platform: &str) -> String {
  asset_filename(platform)
}

fn release_asset_url(version: &str, platform: &str) -> String {
  format!(
    "{}/v{version}/{}",
    releases_download_base_url(),
    asset_filename(platform)
  )
}

#[doc(hidden)]
pub fn release_asset_url_for_tests(version: &str, platform: &str) -> String {
  release_asset_url(version, platform)
}

fn write_temp_binary(current_exe: &Path, binary: &[u8]) -> Result<PathBuf> {
  let exe_dir = current_exe
    .parent()
    .ok_or_else(|| anyhow!("Failed to resolve the executable directory"))?;
  let exe_name = current_exe
    .file_name()
    .and_then(|name| name.to_str())
    .ok_or_else(|| anyhow!("Executable path is not valid UTF-8"))?;
  let temp_path = exe_dir.join(format!("{exe_name}.upgrade-{}.tmp", std::process::id()));
  fs::write(&temp_path, binary).with_context(|| {
    format!(
      "Failed to write downloaded binary to {}",
      temp_path.display()
    )
  })?;
  Ok(temp_path)
}

#[cfg(unix)]
fn mark_executable(path: &Path) -> Result<()> {
  use std::os::unix::fs::PermissionsExt;

  let mut permissions = fs::metadata(path)
    .with_context(|| format!("Failed to read permissions for {}", path.display()))?
    .permissions();
  permissions.set_mode(0o755);
  fs::set_permissions(path, permissions)
    .with_context(|| format!("Failed to mark {} as executable", path.display()))?;
  Ok(())
}

#[cfg(not(unix))]
fn mark_executable(_path: &Path) -> Result<()> {
  Ok(())
}

#[cfg(not(windows))]
fn replace_current_executable(current_exe: &Path, temp_exe: &Path) -> Result<()> {
  fs::rename(temp_exe, current_exe).with_context(|| {
    format!(
      "Failed to replace {} with {}",
      current_exe.display(),
      temp_exe.display()
    )
  })?;
  Ok(())
}

#[cfg(windows)]
fn replace_current_executable(current_exe: &Path, temp_exe: &Path) -> Result<()> {
  use std::process::Command;

  let updater_script =
    current_exe.with_file_name(format!("anesis-upgrade-{}.cmd", std::process::id()));
  let script = build_windows_updater_script(current_exe, temp_exe, &updater_script)?;
  fs::write(&updater_script, script)
    .with_context(|| format!("Failed to write {}", updater_script.display()))?;

  let updater_script = path_for_shell(&updater_script)?;
  Command::new("cmd")
    .args(["/C", "start", "", "/B", updater_script.as_str()])
    .spawn()
    .context("Failed to start the Windows updater helper")?;
  Ok(())
}

#[cfg(windows)]
fn build_windows_updater_script(
  current_exe: &Path,
  temp_exe: &Path,
  updater_script: &Path,
) -> Result<String> {
  let current_exe = quoted_windows_path(current_exe)?;
  let temp_exe = quoted_windows_path(temp_exe)?;
  let updater_script = quoted_windows_path(updater_script)?;
  Ok(format!(
    "@echo off\r\nping 127.0.0.1 -n 3 > nul\r\nmove /Y {temp_exe} {current_exe} > nul\r\ndel /Q {updater_script} > nul\r\n"
  ))
}

#[cfg(windows)]
fn quoted_windows_path(path: &Path) -> Result<String> {
  Ok(format!(
    "\"{}\"",
    path_for_shell(path)?.replace('"', "\"\"")
  ))
}

#[cfg(windows)]
fn path_for_shell(path: &Path) -> Result<String> {
  path
    .to_str()
    .map(ToOwned::to_owned)
    .ok_or_else(|| anyhow!("Path '{}' is not valid UTF-8", path.display()))
}