mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Template download service
//!
//! Downloads project templates via the public `mecha10-api` downloads proxy

use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use mecha10_cli_core::paths::urls::mecha10_api_base_url;
use mecha10_cli_core::paths::user::templates_dir;
use mecha10_cli_core::utils::build_client;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use tar::Archive;

#[derive(Debug, Deserialize)]
struct VersionResponse {
    version: String,
}

pub const DEV_VERSION: &str = "dev";

#[derive(Clone)]
pub struct TemplateDownloadService {
    cache_dir: PathBuf,
}

impl TemplateDownloadService {
    /// Create a new TemplateDownloadService with default cache directory
    pub fn new() -> Self {
        Self {
            cache_dir: templates_dir(),
        }
    }

    /// Create a TemplateDownloadService with a custom cache directory (for testing)
    #[allow(dead_code)]
    pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
        Self { cache_dir }
    }

    /// Get the latest template version via `mecha10-api`
    ///
    /// Calls `GET /api/downloads/templates/version`, which resolves the version
    pub async fn get_latest_version(&self) -> Result<String> {
        let url = format!("{}/downloads/templates/version", mecha10_api_base_url());

        let client = build_client()?;
        let response = client
            .get(&url)
            .send()
            .await
            .context("Failed to fetch latest templates version from mecha10-api")?;

        if !response.status().is_success() {
            anyhow::bail!(
                "Failed to get latest templates version: HTTP {}\nURL: {}",
                response.status(),
                url
            );
        }

        let parsed: VersionResponse = response
            .json()
            .await
            .context("Failed to parse version response from mecha10-api")?;
        Ok(parsed.version)
    }

    /// Get the path to cached templates for a specific version
    pub fn get_cached_path(&self, version: &str) -> PathBuf {
        self.cache_dir.join(format!("v{}", version))
    }

    /// Check if templates for a specific version are cached
    pub fn is_cached(&self, version: &str) -> bool {
        let cache_path = self.get_cached_path(version);
        cache_path.exists() && cache_path.join("templates").exists()
    }

    /// Resolve which template version to use.
    ///
    /// Prefers a locally `--install`ed dev build ([`DEV_VERSION`]) if one is cached. Falls
    pub async fn resolve_version(&self) -> Result<String> {
        if self.is_cached(DEV_VERSION) {
            tracing::info!("Using locally installed dev templates (v{DEV_VERSION}) - run `mecha10 init` after a real publish to go back to the latest published templates");
            return Ok(DEV_VERSION.to_string());
        }

        self.get_latest_version().await
    }

    /// Ensure templates are available for a specific version
    ///
    /// Downloads and caches templates if not already present.
    pub async fn ensure_templates(&self, version: &str) -> Result<PathBuf> {
        let cache_path = self.get_cached_path(version);
        let templates_path = cache_path.join("templates");

        if templates_path.exists() {
            tracing::debug!("Using cached templates v{}", version);
            return Ok(templates_path);
        }

        tracing::info!("Downloading templates v{}...", version);
        self.download_templates(version, &cache_path).await?;

        Ok(templates_path)
    }

    /// Download templates for a specific version via `mecha10-api`
    async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
        let url = format!("{}/downloads/templates?version={}", mecha10_api_base_url(), version);

        let client = build_client()?;

        let response = client
            .get(&url)
            .send()
            .await
            .context("Failed to connect to mecha10-api for templates")?;

        if !response.status().is_success() {
            anyhow::bail!("Failed to download templates: HTTP {}\nURL: {}", response.status(), url);
        }

        let bytes = response.bytes().await.context("Failed to read template archive")?;

        fs::create_dir_all(cache_path).context("Failed to create template cache directory")?;

        let decoder = GzDecoder::new(&bytes[..]);
        let mut archive = Archive::new(decoder);

        archive
            .unpack(cache_path)
            .context("Failed to extract template archive")?;

        tracing::info!("Templates v{} cached at {:?}", version, cache_path);

        Ok(())
    }

    /// Resolve `relative_path` against the cached `templates/` directory for `version`.
    ///
    /// Shared by every method below that needs this same
    fn resolve_template_path(&self, version: &str, relative_path: &str) -> PathBuf {
        self.get_cached_path(version).join("templates").join(relative_path)
    }

    /// Read a template file from the cached templates as UTF-8 text.
    ///
    /// Built on [`Self::read_template_bytes`], decoding the bytes variant's result as text.
    #[allow(dead_code)]
    pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
        let bytes = self.read_template_bytes(version, relative_path)?;

        String::from_utf8(bytes).with_context(|| {
            format!(
                "Template is not valid UTF-8: {:?}",
                self.resolve_template_path(version, relative_path)
            )
        })
    }

    /// Read a binary template file from the cached templates
    #[allow(dead_code)]
    pub fn read_template_bytes(&self, version: &str, relative_path: &str) -> Result<Vec<u8>> {
        let file_path = self.resolve_template_path(version, relative_path);

        fs::read(&file_path).with_context(|| format!("Failed to read template: {:?}", file_path))
    }

    /// List all files in a template directory
    #[allow(dead_code)]
    pub fn list_template_dir(&self, version: &str, relative_path: &str) -> Result<Vec<PathBuf>> {
        let dir_path = self.resolve_template_path(version, relative_path);

        if !dir_path.exists() {
            return Ok(Vec::new());
        }

        let mut files = Vec::new();
        for entry in fs::read_dir(&dir_path)? {
            let entry = entry?;
            files.push(entry.path());
        }

        Ok(files)
    }

    /// Copy a template directory to the project
    pub fn copy_template_dir(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
        let src_path = self.resolve_template_path(version, template_relative_path);

        if !src_path.exists() {
            tracing::debug!("Template directory not found: {:?}", src_path);
            return Ok(());
        }

        copy_dir_recursive(&src_path, dest_path)?;

        Ok(())
    }

    /// Copy a single template file to the project
    pub fn copy_template_file(&self, version: &str, template_relative_path: &str, dest_path: &Path) -> Result<()> {
        let src_path = self.resolve_template_path(version, template_relative_path);

        if !src_path.exists() {
            tracing::debug!("Template file not found: {:?}", src_path);
            return Ok(());
        }

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

        fs::copy(&src_path, dest_path)?;

        Ok(())
    }
}

impl Default for TemplateDownloadService {
    fn default() -> Self {
        Self::new()
    }
}

/// Skip macOS AppleDouble/resource-fork junk (`._*`, `.DS_Store`) that can leak into
/// release tarballs when packaged on a Mac without `COPYFILE_DISABLE=1`.
fn is_junk_entry(file_name: &std::ffi::OsStr) -> bool {
    let name = file_name.to_string_lossy();
    name.starts_with("._") || name == ".DS_Store"
}

/// Recursively copy a directory
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
    fs::create_dir_all(dst)?;

    for entry in fs::read_dir(src)? {
        let entry = entry?;
        if is_junk_entry(&entry.file_name()) {
            continue;
        }
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());

        if src_path.is_dir() {
            copy_dir_recursive(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cache_path() {
        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
        assert_eq!(service.get_cached_path("0.1.14"), PathBuf::from("/tmp/test/v0.1.14"));
    }

    #[test]
    fn default_cache_dir_matches_paths_user_templates_dir() {
        let service = TemplateDownloadService::new();
        assert_eq!(service.cache_dir, templates_dir());
    }

    #[test]
    fn dev_version_path_matches_install_py_layout() {
        let service = TemplateDownloadService::with_cache_dir(PathBuf::from("/tmp/test"));
        assert_eq!(service.get_cached_path(DEV_VERSION), PathBuf::from("/tmp/test/vdev"));
    }

    #[tokio::test]
    async fn resolve_version_prefers_locally_installed_dev_build_without_network() {
        let tmp = tempfile::tempdir().unwrap();
        let dev_templates_dir = tmp.path().join(format!("v{DEV_VERSION}")).join("templates");
        fs::create_dir_all(&dev_templates_dir).unwrap();

        let service = TemplateDownloadService::with_cache_dir(tmp.path().to_path_buf());
        let version = service.resolve_version().await.unwrap();
        assert_eq!(version, DEV_VERSION);
    }

    /// `copy_template_dir` copies an entire directory, not an enumerated list of filenames, so
    /// bringing both the config and schema subtrees of a framework node into a scaffolded
    /// project takes two `copy_template_dir` calls, one per subtree.
    #[test]
    fn copy_template_dir_brings_every_file_along_for_both_config_and_schema_subtrees() {
        let cache = tempfile::tempdir().unwrap();
        let version = "1.2.3";
        let staged_templates_dir = cache.path().join(format!("v{version}")).join("templates");

        let config_template_dir = staged_templates_dir.join("configs/nodes/@mecha10/camera");
        fs::create_dir_all(&config_template_dir).unwrap();
        fs::write(
            config_template_dir.join("config.json"),
            r#"{"dev": {}, "production": {}}"#,
        )
        .unwrap();

        let schema_template_dir = staged_templates_dir.join("nodes/@mecha10/camera");
        fs::create_dir_all(&schema_template_dir).unwrap();
        fs::write(schema_template_dir.join("camera.schema.json"), r#"{"type": "object"}"#).unwrap();

        let service = TemplateDownloadService::with_cache_dir(cache.path().to_path_buf());
        let dest = tempfile::tempdir().unwrap();

        let config_dest_dir = dest.path().join("configs/nodes/@mecha10/camera");
        service
            .copy_template_dir(version, "configs/nodes/@mecha10/camera", &config_dest_dir)
            .unwrap();
        assert!(config_dest_dir.join("config.json").exists());
        assert!(
            !config_dest_dir.join("camera.schema.json").exists(),
            "the schema file must not be staged alongside config.json anymore"
        );

        let schema_dest_dir = dest.path().join("nodes/@mecha10/camera");
        service
            .copy_template_dir(version, "nodes/@mecha10/camera", &schema_dest_dir)
            .unwrap();
        assert!(
            schema_dest_dir.join("camera.schema.json").exists(),
            "whole-directory copy must bring the schema file along without any dedicated code for it"
        );
        assert_eq!(
            fs::read_to_string(schema_dest_dir.join("camera.schema.json")).unwrap(),
            r#"{"type": "object"}"#
        );
    }
}