mecha10-templates 2.0.0

Mecha10 project templates — code generation, init, and template services
Documentation
//! Assets download service
//!
//! Downloads the current published `mecha10-assets` content 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::utils::build_client;
use mecha10_retry::RetryConfig;
use std::fs;
use std::path::Path;
use std::time::Duration;
use tar::Archive;

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

/// Retry tuning for `mecha10-assets` downloads.
///
/// `mecha10-api`'s downloads proxy occasionally returns a transient `5xx` (e.g. an upstream
/// 502 while the backing store is being redeployed) rather than a real, permanent failure.
/// A handful of short retries smooths over that without meaningfully slowing down the common
/// case where the first request succeeds.
fn assets_download_retry_defaults() -> RetryConfig {
    RetryConfig {
        max_attempts: 4,
        initial_backoff: Duration::from_millis(250),
        max_backoff: Duration::from_secs(5),
        backoff_multiplier: 2.0,
    }
}

/// Whether an HTTP status is worth retrying, as opposed to a permanent client-side error
/// (e.g. 404/401) that will never succeed on retry.
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}

/// The outcome of a single download attempt: either the response bytes, or an error tagged
/// with whether it's worth retrying.
enum AttemptError {
    Retryable(anyhow::Error),
    Permanent(anyhow::Error),
}

async fn try_download(client: &reqwest::Client, url: &str) -> std::result::Result<Vec<u8>, AttemptError> {
    let response = client
        .get(url)
        .send()
        .await
        .context("Failed to connect to mecha10-api for mecha10-assets")
        .map_err(AttemptError::Retryable)?;

    let status = response.status();
    if !status.is_success() {
        let err = anyhow::anyhow!("Failed to download mecha10-assets: HTTP {}\nURL: {}", status, url);
        return Err(if is_retryable_status(status) {
            AttemptError::Retryable(err)
        } else {
            AttemptError::Permanent(err)
        });
    }

    response
        .bytes()
        .await
        .map(|bytes| bytes.to_vec())
        .context("Failed to read mecha10-assets archive")
        .map_err(AttemptError::Retryable)
}

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 retry_config = assets_download_retry_defaults();
        let mut delay = retry_config.initial_backoff;
        let mut attempt = 1;

        let bytes = loop {
            match try_download(&client, &url).await {
                Ok(bytes) => break bytes,
                Err(AttemptError::Permanent(err)) => return Err(err),
                Err(AttemptError::Retryable(err)) => {
                    if attempt >= retry_config.max_attempts {
                        return Err(err.context(format!(
                            "Failed to download mecha10-assets after {} attempts",
                            retry_config.max_attempts
                        )));
                    }

                    tracing::warn!(
                        attempt,
                        max_attempts = retry_config.max_attempts,
                        delay_ms = delay.as_millis(),
                        error = %err,
                        "mecha10-assets download failed, retrying"
                    );
                    attempt += 1;
                    tokio::time::sleep(delay).await;
                    delay = retry_config.next_delay(delay);
                }
            }
        };

        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:#}"
        );
    }

    /// Minimal raw-TCP HTTP server for exercising retry behavior without pulling in a mock-HTTP
    /// dependency. `responses` is replayed in order, one per accepted connection; once exhausted
    /// the last entry is repeated for any further requests. Returns the server's base URL plus
    /// a shared counter of how many requests it has served so far.
    fn spawn_fake_server(responses: Vec<&'static str>) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
        use std::net::TcpListener;
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::sync::Arc;

        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let served = Arc::new(AtomicUsize::new(0));
        let served_clone = served.clone();

        std::thread::spawn(move || {
            use std::io::{Read, Write};

            for stream in listener.incoming() {
                let Ok(mut socket) = stream else { break };
                let index = served_clone.fetch_add(1, Ordering::SeqCst);
                let response =
                    responses.get(index).or_else(|| responses.last()).copied().unwrap_or(
                        "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
                    );

                let mut buf = [0u8; 1024];
                let _ = socket.read(&mut buf);
                let _ = socket.write_all(response.as_bytes());
                let _ = socket.shutdown(std::net::Shutdown::Both);
            }
        });

        (format!("http://{}", addr), served)
    }

    #[tokio::test]
    async fn fetch_and_extract_from_retries_transient_502_then_stops_on_permanent_404() {
        let (base_url, served) = spawn_fake_server(vec![
            "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
            "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
        ]);

        let service = AssetsDownloadService::new();
        let err = service
            .fetch_and_extract_from(&base_url)
            .await
            .expect_err("eventual permanent 404 must still surface as an error");

        assert!(
            format!("{err:#}").contains("404"),
            "expected the final 404 to surface, got: {err:#}"
        );
        // Retried through both 502s (attempts 1 and 2), then hit the permanent 404 on attempt 3
        // and stopped immediately rather than exhausting all `max_attempts`.
        assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 3);
    }

    #[tokio::test]
    async fn fetch_and_extract_from_does_not_retry_permanent_404() {
        let (base_url, served) = spawn_fake_server(vec![
            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
        ]);

        let service = AssetsDownloadService::new();
        let err = service
            .fetch_and_extract_from(&base_url)
            .await
            .expect_err("404 must be a permanent error");

        assert!(format!("{err:#}").contains("404"));
        assert_eq!(
            served.load(std::sync::atomic::Ordering::SeqCst),
            1,
            "a permanent 404 must not be retried"
        );
    }
}