mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Assets download service
//!
//! Downloads the current published `mecha10-assets` content (behaviors, RL tasks/jobs,
//! simulation models/environments, `mecha10.json.template`) via the public `mecha10-api`
//! downloads proxy.
//!
//! Unlike [`crate::services::template_download::TemplateDownloadService`], `mecha10-assets` has
//! no version/cache concept - every push wholesale-replaces the one published tarball, so every
//! call here fetches current content fresh into a brand-new temp directory rather than caching by
//! version.

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

#[derive(Clone, Default)]
pub struct AssetsDownloadService;

impl AssetsDownloadService {
    /// Create a new AssetsDownloadService
    pub fn new() -> Self {
        Self
    }

    /// Downloads and extracts the current `mecha10-assets` tarball into a fresh temp directory.
    ///
    /// Returns the [`tempfile::TempDir`] handle itself so the caller can keep it alive for as
    /// long as it needs the extracted content (the directory is removed when the handle drops).
    pub async fn fetch_and_extract(&self) -> Result<tempfile::TempDir> {
        self.fetch_and_extract_from(&mecha10_api_base_url()).await
    }

    /// Does the actual fetch/extract work against an explicit `mecha10-api` base URL, so tests
    /// can exercise the error path without mutating the process-global `MECHA10_API_URL` env var.
    async fn fetch_and_extract_from(&self, api_base_url: &str) -> Result<tempfile::TempDir> {
        let url = format!("{}/downloads/assets", api_base_url);

        let client = build_client()?;

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

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

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

        let temp_dir = tempfile::tempdir().context("Failed to create temp directory for mecha10-assets")?;

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

        archive
            .unpack(temp_dir.path())
            .context("Failed to extract mecha10-assets archive")?;

        tracing::info!("mecha10-assets extracted to {:?}", temp_dir.path());

        Ok(temp_dir)
    }
}

/// 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 from an extracted `mecha10-assets` tree into a project
/// destination. Mirrors [`crate::services::template_download`]'s own tree-walk helper, since
/// there's no version-keyed cache layer here to abstract the copy behind.
pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
    fs::create_dir_all(dst).with_context(|| format!("Failed to create directory {:?}", dst))?;

    for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory {:?}", 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)
                .with_context(|| format!("Failed to copy {:?} to {:?}", src_path, dst_path))?;
        }
    }

    Ok(())
}

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

    #[test]
    fn copy_dir_recursive_copies_nested_files_and_skips_junk_entries() {
        let src = tempfile::tempdir().unwrap();
        fs::create_dir_all(src.path().join("@mecha10/idle-wander")).unwrap();
        fs::write(
            src.path().join("@mecha10/idle-wander/config.json"),
            r#"{"name": "idle-wander"}"#,
        )
        .unwrap();
        fs::write(src.path().join("@mecha10/idle-wander/._config.json"), b"junk").unwrap();
        fs::write(src.path().join(".DS_Store"), b"junk").unwrap();

        let dst = tempfile::tempdir().unwrap();
        copy_dir_recursive(src.path(), dst.path()).unwrap();

        assert_eq!(
            fs::read_to_string(dst.path().join("@mecha10/idle-wander/config.json")).unwrap(),
            r#"{"name": "idle-wander"}"#
        );
        assert!(!dst.path().join("@mecha10/idle-wander/._config.json").exists());
        assert!(!dst.path().join(".DS_Store").exists());
    }

    #[tokio::test]
    async fn fetch_and_extract_errors_clearly_when_mecha10_api_is_unreachable() {
        let service = AssetsDownloadService::new();

        let err = service
            .fetch_and_extract_from("http://127.0.0.1:9/api")
            .await
            .expect_err("unreachable mecha10-api must produce an error, not panic");

        assert!(
            format!("{err:#}").contains("mecha10-assets"),
            "error should name what was being fetched, got: {err:#}"
        );
    }
}