cargo-v5 0.12.1

Cargo tool for working with VEX V5 Rust projects
Documentation
use log::{debug, info, warn};
use serde_json::Value;

use crate::errors::CliError;
use std::{
    io,
    path::{Path, PathBuf},
};

#[derive(Debug, Clone)]
struct Template {
    pub data: Vec<u8>,
    pub sha: Option<String>,
}

const TEMPLATE_FILE_NAME: &str = "vexide-template.tar.gz";
const SHA_FILE_NAME: &str = "cache-id.txt";

#[cfg(feature = "fetch-template")]
async fn get_current_sha() -> Result<String, CliError> {
    let client = reqwest::Client::new();
    let response = client
        .get("https://api.github.com/repos/vexide/vexide-template/commits/main?per-page=1")
        .header("User-Agent", "vexide/cargo-v5")
        .send()
        .await
        .map_err(CliError::ReqwestError)?;
    let response_text = response.text().await.map_err(CliError::ReqwestError)?;
    match &serde_json::from_str::<Value>(&response_text).unwrap_or_default()["sha"] {
        Value::String(str) => Ok(str.clone()),
        _ => Err(CliError::MalformedResponse),
    }
}

#[cfg(feature = "fetch-template")]
async fn fetch_template() -> Result<Template, CliError> {
    debug!("Fetching template...");
    let response =
        reqwest::get("https://github.com/vexide/vexide-template/archive/refs/heads/main.tar.gz")
            .await;
    let response = match response {
        Ok(response) => response,
        Err(err) => return Err(CliError::ReqwestError(err)),
    };
    let bytes = response.bytes().await?;

    debug!("Successfully fetched template.");
    let template = Template {
        data: bytes.to_vec(),
        sha: get_current_sha().await.ok(),
    };
    store_cached_template(template.clone()).await;
    Ok(template)
}

#[cfg(feature = "fetch-template")]
async fn get_cached_template() -> Option<Template> {
    match cached_template_dir() {
        Some(dir) => {
            let cache_file = dir.with_file_name(TEMPLATE_FILE_NAME);
            let sha_file = dir.with_file_name(SHA_FILE_NAME);
            let sha = tokio::fs::read_to_string(sha_file).await.ok();
            let data = tokio::fs::read(cache_file).await.ok();
            data.map(|data| Template { data, sha })
        }
        None => None,
    }
}

#[cfg(feature = "fetch-template")]
async fn store_cached_template(template: Template) -> () {
    if let Some(dir) = cached_template_dir() {
        let cache_file = dir.with_file_name(TEMPLATE_FILE_NAME);
        let sha_file = dir.with_file_name(SHA_FILE_NAME);
        let _ = tokio::fs::write(cache_file, &template.data).await;
        if let Some(sha) = template.sha {
            let _ = tokio::fs::write(sha_file, sha).await;
        }
    }
}

#[cfg(feature = "fetch-template")]
fn cached_template_dir() -> Option<PathBuf> {
    use directories::ProjectDirs;
    ProjectDirs::from("", "vexide", "cargo-v5").map(|dirs| dirs.cache_dir().to_owned())
}

fn baked_in_template() -> Template {
    Template {
        data: include_bytes!("./vexide-template.tar.gz").to_vec(),
        sha: None,
    }
}

fn unpack_template(template: Vec<u8>, dir: &PathBuf) -> io::Result<()> {
    let mut archive: tar::Archive<flate2::read::GzDecoder<&[u8]>> =
        tar::Archive::new(flate2::read::GzDecoder::new(&template[..]));
    for entry in archive.entries()? {
        let mut entry = entry?;

        let path = entry.path()?;
        let stripped_path = path.iter().skip(1).collect::<PathBuf>();

        if let Some(stripped_path) = stripped_path.to_str() {
            let output_path = Path::new(dir).join(stripped_path);

            if let Some(parent) = output_path.parent() {
                std::fs::create_dir_all(parent)?;
            }

            entry.unpack(output_path)?;
        }
    }
    Ok(())
}

pub async fn new(
    path: PathBuf,
    name: Option<String>,
    download_template: bool,
) -> Result<(), CliError> {
    let dir = if let Some(name) = &name {
        let dir = path.join(name);
        std::fs::create_dir_all(&path).unwrap();
        dir
    } else {
        path
    };

    if std::fs::read_dir(&dir).is_ok_and(|e| e.count() > 0) {
        return Err(CliError::ProjectDirFull(dir));
    }

    let name = name
        .or_else(|| {
            Some(
                std::fs::canonicalize(&dir)
                    .ok()?
                    .file_name()?
                    .to_str()?
                    .to_string(),
            )
        })
        .unwrap_or("vexide project".to_string());

    #[cfg(feature = "fetch-template")]
    let template = match (get_cached_template().await, get_current_sha().await) {
        (cached_template, ..) if !download_template => cached_template,
        (Some(cached_template), Ok(current_sha))
            if cached_template.sha == Some(current_sha.clone()) =>
        {
            debug!("Cached template is current, skipping download.");
            Some(cached_template)
        }
        (cached_template, ..) => {
            debug!("Cached template is out of date.");
            let fetched_template = fetch_template().await.ok();
            fetched_template.or_else(|| {
                warn!("Could not fetch template, falling back to cache.");
                cached_template
            })
        }
    }
    .unwrap_or_else(|| {
        debug!("No template found in cache, using builtin template.");
        baked_in_template()
    });

    #[cfg(not(feature = "fetch-template"))]
    let template = baked_in_template();

    debug!("Unpacking template...");
    unpack_template(template.data, &dir)?;
    debug!("Successfully unpacked vexide-template!");

    debug!("Renaming project to {}...", &name);
    let manifest_path = dir.join("Cargo.toml");
    let manifest = tokio::fs::read_to_string(&manifest_path).await?;
    let manifest = manifest.replace("vexide-template", &name);
    tokio::fs::write(manifest_path, manifest).await?;

    info!("Successfully created new project at {dir:?}");
    Ok(())
}