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 reqwest::Client;
use serde::Deserialize;

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

#[derive(Deserialize)]
struct TemplateInfoRes {
  archive_url: String,
  commit_sha: String,
  subdir: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallResult {
  Installed,
  Updated { version: String },
  UpToDate,
}

impl InstallResult {
  pub fn message(&self, template_name: &str) -> Option<String> {
    match self {
      Self::Installed => Some(format!(
        "Template '{template_name}' downloaded successfully"
      )),
      Self::Updated { version } => {
        Some(format!("Template '{template_name}' updated to v{version}"))
      }
      Self::UpToDate => None,
    }
  }

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

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

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

  if !template_dir_exists {
    return InstallState::Install;
  }

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

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

async fn get_template_info(
  template_name: &str,
  client: &Client,
  auth_path: &Path,
  backend_url: &str,
) -> Result<TemplateInfoRes> {
  let user = get_auth_user(auth_path)?;

  let response = client
    .get(format!("{backend_url}/template/{template_name}/url"))
    .bearer_auth(user.token)
    .header("Content-Type", "application/json")
    .send()
    .await
    .with_context(|| format!("Failed to connect to server for template '{template_name}'"))?;

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

  let res: TemplateInfoRes = response
    .json()
    .await
    .with_context(|| format!("Failed to parse response for template '{template_name}'"))?;

  Ok(res)
}

pub async fn install_template(ctx: &AppContext, template_name: &str) -> Result<InstallResult> {
  let info = get_template_info(
    template_name,
    &ctx.client,
    &ctx.paths.auth,
    &ctx.backend_url,
  )
  .await?;

  let dest = ctx.paths.templates.join(template_name);
  let cached_template = get_cached_template(ctx, template_name)?;
  let install_state =
    classify_install_state(cached_template.as_ref(), dest.exists(), &info.commit_sha);

  if install_state == InstallState::UpToDate {
    return Ok(InstallResult::UpToDate);
  }

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

  let download_result = download_and_extract(
    &ctx.client,
    &info.archive_url,
    &dest,
    info.subdir.as_deref(),
  )
  .await;

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

  download_result?;

  let cached_template = update_templates_cache(
    &ctx.paths.templates,
    Path::new(template_name),
    &info.commit_sha,
  )?;

  Ok(match install_state {
    InstallState::Install => InstallResult::Installed,
    InstallState::Update => InstallResult::Updated {
      version: cached_template.version,
    },
    InstallState::UpToDate => unreachable!("up-to-date templates should return early"),
  })
}