mecha10-sim 0.6.2

Mecha10 simulation support — scene generation, asset management
Documentation
//! Simulation assets service for downloading and caching simulation files via `mecha10-api`
//!
//! This service provides:
//! - Downloading simulation assets (models, environments) via the public
//!   `mecha10-api` downloads proxy (`GET /api/downloads/simulation`), never the private
//!   Minio instance directly
//! - Caching assets to `~/.mecha10/simulation/`
//! - Version management and updates
//!
//! # Design
//!
//! - Assets are downloaded as a tarball from `{mecha10_api}/downloads/simulation[?version=X]`
//! - Assets are extracted to `~/.mecha10/simulation/{version}/`
//! - A symlink `~/.mecha10/simulation/current` points to the active version
//! - Omitting `version` resolves to the latest published version server-side; the resolved
//!   version is read back from the response's `Content-Disposition` filename

use crate::paths;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Deserialize;
use std::path::PathBuf;

/// Service for managing simulation assets
pub struct SimulationAssetsService {
    /// Base directory for cached assets (~/.mecha10)
    cache_dir: PathBuf,
}

/// Mirrors `mecha10-api`'s `catalog::downloads::types::VersionResponse`.
#[derive(Debug, Deserialize)]
struct VersionResponse {
    version: String,
}

#[allow(dead_code)]
impl SimulationAssetsService {
    /// Create a new simulation assets service
    pub fn new() -> Self {
        let cache_dir = paths::user::mecha10_dir();
        Self { cache_dir }
    }

    /// Get the simulation cache directory
    pub fn cache_dir(&self) -> &PathBuf {
        &self.cache_dir
    }

    /// Get the path to the current simulation assets
    ///
    /// Returns the path to `~/.mecha10/simulation/current` if it exists
    pub fn current_assets_path(&self) -> Option<PathBuf> {
        let current = paths::user::simulation_current();
        if current.exists() {
            // Resolve symlink to actual path
            std::fs::canonicalize(&current).ok()
        } else {
            None
        }
    }

    /// Get the models directory from cached assets
    pub fn models_path(&self) -> Option<PathBuf> {
        self.current_assets_path()
            .map(|p| p.join("models"))
            .filter(|p| p.exists())
    }

    /// Get the environments directory from cached assets
    pub fn environments_path(&self) -> Option<PathBuf> {
        self.current_assets_path()
            .map(|p| p.join("environments"))
            .filter(|p| p.exists())
    }

    /// Check if simulation assets are installed
    pub fn is_installed(&self) -> bool {
        self.models_path().is_some() || self.environments_path().is_some()
    }

    /// Get the installed version (if any)
    pub fn installed_version(&self) -> Option<String> {
        let version_file = paths::user::simulation_dir().join("version");
        std::fs::read_to_string(version_file).ok()
    }

    /// Get the latest simulation assets version via `mecha10-api`
    ///
    /// Calls `GET /api/downloads/simulation/version`, which resolves the version
    /// without downloading the (potentially large) tarball.
    pub async fn get_latest_version(&self) -> Result<String> {
        let url = format!("{}/downloads/simulation/version", paths::urls::mecha10_api_base_url());
        let client = Self::build_client()?;
        let response = client
            .get(&url)
            .send()
            .await
            .context("Failed to fetch latest simulation assets version from mecha10-api")?;

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

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

    /// Ensure simulation assets are available for a specific version, downloading if needed
    ///
    /// Returns the path to the simulation assets directory
    pub async fn ensure_assets(&self, version: &str) -> Result<PathBuf> {
        let version_dir = paths::user::simulation_dir().join(version);
        if version_dir.exists() {
            tracing::debug!("Using cached simulation assets {}", version);
            return Ok(version_dir);
        }

        println!("📦 Downloading simulation assets {}...", version);
        self.download_version(version).await
    }

    /// Build an HTTP client
    fn build_client() -> Result<reqwest::Client> {
        reqwest::Client::builder()
            .user_agent("mecha10-cli")
            .build()
            .context("Failed to build HTTP client")
    }

    /// Download simulation assets for a specific version via `mecha10-api`
    async fn download_version(&self, version: &str) -> Result<PathBuf> {
        let url = format!(
            "{}/downloads/simulation?version={}",
            paths::urls::mecha10_api_base_url(),
            version
        );

        tracing::info!("Downloading sim assets from mecha10-api: {}", url);

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

        if !response.status().is_success() {
            return Err(anyhow::anyhow!(
                "Simulation asset not found: HTTP {}\n\
                 URL: {}\n\n\
                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
                response.status(),
                url
            ));
        }

        let size = response.content_length().unwrap_or(0);

        println!("   Release: {}", version);
        if size > 0 {
            println!("   Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
        }

        self.download_and_extract(response, version, size).await
    }

    /// Download the latest simulation assets via `mecha10-api`
    pub async fn download_latest(&self) -> Result<PathBuf> {
        println!("   Checking latest release...");
        let version = self.get_latest_version().await?;
        self.download_version(&version).await
    }

    /// Stream and extract a download response into the cache
    async fn download_and_extract(&self, response: reqwest::Response, version: &str, size: u64) -> Result<PathBuf> {
        // Create progress bar
        let pb = ProgressBar::new(size);
        pb.set_style(
            ProgressStyle::default_bar()
                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
                .unwrap()
                .progress_chars("#>-"),
        );

        println!("   Downloading...");

        // Download to temp file
        let temp_dir = tempfile::tempdir()?;
        let temp_file = temp_dir.path().join("simulation.tar.gz");

        let mut file = tokio::fs::File::create(&temp_file).await?;
        let mut stream = response.bytes_stream();

        use futures_util::StreamExt;
        use tokio::io::AsyncWriteExt;

        while let Some(chunk) = stream.next().await {
            let chunk = chunk.context("Error downloading chunk")?;
            file.write_all(&chunk).await?;
            pb.inc(chunk.len() as u64);
        }

        file.flush().await?;
        pb.finish_with_message("Download complete");

        // Extract
        println!("   Extracting...");

        let simulation_dir = paths::user::simulation_dir();
        let version_dir = simulation_dir.join(version);

        // Create directories
        tokio::fs::create_dir_all(&version_dir).await?;

        // Extract tarball
        let tar_gz = std::fs::File::open(&temp_file)?;
        let tar = flate2::read::GzDecoder::new(tar_gz);
        let mut archive = tar::Archive::new(tar);
        archive.unpack(&version_dir)?;

        // Create/update symlink to current version
        let current_link = simulation_dir.join("current");
        if current_link.exists() {
            tokio::fs::remove_file(&current_link).await.ok();
        }

        #[cfg(unix)]
        {
            std::os::unix::fs::symlink(&version_dir, &current_link)?;
        }

        #[cfg(windows)]
        {
            std::os::windows::fs::symlink_dir(&version_dir, &current_link)?;
        }

        // Write version file
        let version_file = simulation_dir.join("version");
        tokio::fs::write(&version_file, version).await?;

        println!("✅ Simulation assets installed to {:?}", version_dir);

        Ok(version_dir)
    }

    /// Remove cached simulation assets
    pub async fn remove(&self) -> Result<()> {
        let simulation_dir = paths::user::simulation_dir();
        if simulation_dir.exists() {
            tokio::fs::remove_dir_all(&simulation_dir).await?;
            println!("✅ Simulation assets removed");
        } else {
            println!("No simulation assets installed");
        }
        Ok(())
    }

    /// Update to latest version
    pub async fn update(&self) -> Result<PathBuf> {
        // Remove current and download fresh
        self.remove().await.ok();
        self.download_latest().await
    }
}

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