anesis 0.9.1

CLI for scaffolding projects from remote templates and extending them with project addons
Documentation
use std::path::Path;

use anyhow::{Context, Result};
use serde::Deserialize;

use crate::{
  AppContext,
  auth::token::get_auth_user,
  utils::{archive::download_and_extract, errors::classify_reqwest_error},
};

use super::{
  cache::{CachedAddon, get_cached_addon, update_addons_cache},
  manifest::AddonManifest,
};

#[derive(Deserialize)]
struct AddonUrlResponse {
  archive_url: String,
  commit_sha: String,
}

#[derive(Debug)]
pub enum AddonInstallResult {
  Installed(AddonManifest),
  Updated(AddonManifest),
  UpToDate(AddonManifest),
}

impl AddonInstallResult {
  pub fn into_manifest(self) -> AddonManifest {
    match self {
      Self::Installed(manifest) | Self::Updated(manifest) | Self::UpToDate(manifest) => manifest,
    }
  }

  pub fn message(&self, addon_id: &str) -> Option<String> {
    match self {
      Self::Installed(_) => Some(format!("Addon '{addon_id}' successfully downloaded")),
      Self::Updated(manifest) => Some(format!(
        "Addon '{addon_id}' updated to v{}",
        manifest.version
      )),
      Self::UpToDate(_) => None,
    }
  }

  pub fn update_message(&self, addon_id: &str) -> Option<String> {
    match self {
      Self::Updated(manifest) => Some(format!(
        "Addon '{addon_id}' updated to v{}",
        manifest.version
      )),
      Self::Installed(_) | Self::UpToDate(_) => None,
    }
  }

  pub fn up_to_date_message(addon_id: &str) -> String {
    format!("Addon '{addon_id}' is already up to date")
  }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallState {
  Install,
  Update,
  UpToDate,
}

fn classify_install_state(
  cached_addon: Option<&CachedAddon>,
  addon_dir_exists: bool,
  latest_commit_sha: &str,
) -> InstallState {
  let Some(cached_addon) = cached_addon else {
    return InstallState::Install;
  };

  if !addon_dir_exists {
    return InstallState::Install;
  }

  if cached_addon.commit_sha == latest_commit_sha {
    InstallState::UpToDate
  } else {
    InstallState::Update
  }
}

#[doc(hidden)]
pub fn classify_install_state_for_tests(
  cached_addon: Option<&CachedAddon>,
  addon_dir_exists: bool,
  latest_commit_sha: &str,
) -> &'static str {
  match classify_install_state(cached_addon, addon_dir_exists, latest_commit_sha) {
    InstallState::Install => "install",
    InstallState::Update => "update",
    InstallState::UpToDate => "up_to_date",
  }
}

async fn get_addon_url(ctx: &AppContext, addon_id: &str) -> Result<AddonUrlResponse> {
  let user = get_auth_user(&ctx.paths.auth)?;

  let response = ctx
    .client
    .get(format!("{}/addon/{addon_id}/url", ctx.backend_url))
    .bearer_auth(user.token)
    .header("Content-Type", "application/json")
    .send()
    .await
    .with_context(|| format!("Failed to fetch download URL for addon '{addon_id}'"))?;

  if !response.status().is_success() {
    let err = response.error_for_status().unwrap_err();
    return Err(classify_reqwest_error(err, &format!("addon '{addon_id}'")));
  }

  let res: AddonUrlResponse = response
    .json()
    .await
    .with_context(|| format!("Failed to parse URL response for addon '{addon_id}'"))?;

  Ok(res)
}

pub async fn install_addon(ctx: &AppContext, addon_id: &str) -> Result<AddonInstallResult> {
  let info = get_addon_url(ctx, addon_id).await?;

  // The archive already contains a top-level `{addon_id}/` directory,
  // so we extract into the addons root — files land at addons/{addon_id}/...
  let addons_dir = &ctx.paths.addons;
  let addon_dir = addons_dir.join(addon_id);
  let cached_addon = get_cached_addon(addons_dir, addon_id)
    .with_context(|| format!("Failed to read addons cache while checking '{addon_id}'"))?;
  let install_state =
    classify_install_state(cached_addon.as_ref(), addon_dir.exists(), &info.commit_sha);

  if install_state == InstallState::UpToDate {
    return Ok(AddonInstallResult::UpToDate(read_cached_manifest(
      addons_dir, addon_id,
    )?));
  }

  {
    let mut guard = ctx.cleanup_state.lock().unwrap_or_else(|e| e.into_inner());
    *guard = Some(addon_dir.clone());
  }

  let download_result = download_and_extract(&ctx.client, &info.archive_url, addons_dir, None)
    .await
    .with_context(|| {
      format!(
        "Failed to download and extract addon '{addon_id}' from {}",
        info.archive_url
      )
    });

  {
    let mut guard = ctx.cleanup_state.lock().unwrap_or_else(|e| e.into_inner());
    *guard = None;
  }

  download_result?;

  let manifest_path = addon_dir.join("anesis.addon.json");
  let content = std::fs::read_to_string(&manifest_path).with_context(|| {
    format!(
      "Addon '{addon_id}' was extracted but 'anesis.addon.json' was not found at {}. \
       Make sure the addon archive contains a top-level '{addon_id}/' directory with 'anesis.addon.json' inside.",
      manifest_path.display()
    )
  })?;

  let manifest: AddonManifest = serde_json::from_str(&content)
    .with_context(|| format!("Failed to parse anesis.addon.json for addon '{addon_id}'"))?;

  update_addons_cache(addons_dir, addon_id, &manifest, &info.commit_sha)
    .with_context(|| format!("Failed to update addons cache after installing '{addon_id}'"))?;

  Ok(match install_state {
    InstallState::Install => AddonInstallResult::Installed(manifest),
    InstallState::Update => AddonInstallResult::Updated(manifest),
    InstallState::UpToDate => unreachable!("up-to-date addons should return early"),
  })
}

pub fn read_cached_manifest(addons_dir: &Path, addon_id: &str) -> Result<AddonManifest> {
  let manifest_path = addons_dir.join(addon_id).join("anesis.addon.json");
  let content = std::fs::read_to_string(&manifest_path).with_context(|| {
    format!(
      "Failed to read manifest for addon '{addon_id}' at {}",
      manifest_path.display()
    )
  })?;
  serde_json::from_str(&content)
    .with_context(|| format!("Failed to parse manifest for addon '{addon_id}'"))
}