mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Template download service
//!
//! Downloads project templates from GitHub releases on-demand.
//! Templates are versioned and cached locally for offline use.

use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use std::fs;
use std::path::{Path, PathBuf};
use tar::Archive;

/// GitHub repository for template downloads
const GITHUB_REPO: &str = "mecha-industries/user-tools";

/// Service for downloading and caching project templates from GitHub
pub struct TemplateDownloadService {
    /// Cache directory for templates (~/.mecha10/templates/)
    cache_dir: PathBuf,
}

impl TemplateDownloadService {
    /// Create a new TemplateDownloadService with default cache directory
    pub fn new() -> Self {
        let cache_dir = dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(".mecha10")
            .join("templates");

        Self { cache_dir }
    }

    /// Create a TemplateService 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 from GitHub releases
    pub async fn get_latest_version(&self) -> Result<String> {
        let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);

        let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;

        let response = client
            .get(&url)
            .send()
            .await
            .context("Failed to fetch latest release from GitHub")?;

        if !response.status().is_success() {
            anyhow::bail!(
                "GitHub API returned status {}: {}",
                response.status(),
                response.text().await.unwrap_or_default()
            );
        }

        let release: serde_json::Value = response
            .json()
            .await
            .context("Failed to parse GitHub release response")?;

        let tag_name = release["tag_name"]
            .as_str()
            .context("No tag_name in release response")?;

        // Strip 'v' prefix if present
        Ok(tag_name.trim_start_matches('v').to_string())
    }

    /// 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
    #[allow(dead_code)]
    pub fn is_cached(&self, version: &str) -> bool {
        let cache_path = self.get_cached_path(version);
        cache_path.exists() && cache_path.join("templates").exists()
    }

    /// Ensure templates are available for a specific version
    ///
    /// Downloads and caches templates if not already present.
    /// Returns the path to the templates directory.
    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
    async fn download_templates(&self, version: &str, cache_path: &Path) -> Result<()> {
        let url = format!(
            "https://github.com/{}/releases/download/v{}/mecha10-v{}-templates.tar.gz",
            GITHUB_REPO, version, version
        );

        let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;

        let response = client.get(&url).send().await.context("Failed to download templates")?;

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

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

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

        // Extract tarball
        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(())
    }

    /// Read a template file from the cached templates
    #[allow(dead_code)]
    pub fn read_template(&self, version: &str, relative_path: &str) -> Result<String> {
        let templates_path = self.get_cached_path(version).join("templates");
        let file_path = templates_path.join(relative_path);

        fs::read_to_string(&file_path).with_context(|| format!("Failed to read template: {:?}", file_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 templates_path = self.get_cached_path(version).join("templates");
        let file_path = templates_path.join(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 templates_path = self.get_cached_path(version).join("templates");
        let dir_path = templates_path.join(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 templates_path = self.get_cached_path(version).join("templates");
        let src_path = templates_path.join(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 templates_path = self.get_cached_path(version).join("templates");
        let src_path = templates_path.join(template_relative_path);

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

        // Ensure destination directory exists
        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()
    }
}

/// 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?;
        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"));
    }
}