mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Simulation assets service for downloading and caching simulation files from GitHub releases
//!
//! This service provides:
//! - Downloading simulation assets (Godot project, models, environments) from GitHub releases
//! - Caching assets to `~/.mecha10/simulation/`
//! - Version management and updates
//!
//! # Design
//!
//! - Assets are downloaded from GitHub releases as a tarball
//! - Assets are extracted to `~/.mecha10/simulation/{version}/`
//! - A symlink `~/.mecha10/simulation/current` points to the active version
//!
//! # Usage
//!
//! ```no_run
//! use mecha10_cli::services::SimulationAssetsService;
//!
//! let service = SimulationAssetsService::new();
//!
//! // Ensure assets are available (downloads if needed)
//! let path = service.ensure_assets().await?;
//!
//! // Get Godot project path
//! let godot_path = service.godot_project_path()?;
//! ```

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

/// GitHub repository for simulation assets (public distribution repo)
/// Note: We use a separate public repo for distribution while keeping the main repo private
const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
/// Asset name pattern in GitHub releases
const ASSET_NAME: &str = "mecha10-simulation.tar.gz";

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

#[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 Godot project path from cached assets
    pub fn godot_project_path(&self) -> Option<PathBuf> {
        self.current_assets_path()
            .map(|p| p.join("godot-project"))
            .filter(|p| p.exists())
    }

    /// 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.godot_project_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()
    }

    /// Ensure simulation assets are available, downloading if needed
    ///
    /// Returns the path to the simulation assets directory
    pub async fn ensure_assets(&self) -> Result<PathBuf> {
        if let Some(path) = self.current_assets_path() {
            tracing::debug!("Simulation assets already installed at {:?}", path);
            return Ok(path);
        }

        // Need to download
        println!("📦 Simulation assets not found locally");
        println!("   Downloading from GitHub releases...");

        self.download_latest().await
    }

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

    /// Get GitHub token from environment (GITHUB_TOKEN or GH_TOKEN) or `gh` CLI
    ///
    /// Tries in order:
    /// 1. GITHUB_TOKEN environment variable
    /// 2. GH_TOKEN environment variable
    /// 3. `gh auth token` command (if `gh` CLI is installed and authenticated)
    fn github_token() -> Option<String> {
        // First try environment variables
        if let Ok(token) = std::env::var("GITHUB_TOKEN") {
            return Some(token);
        }
        if let Ok(token) = std::env::var("GH_TOKEN") {
            return Some(token);
        }

        // Fall back to `gh auth token` command
        Self::get_gh_cli_token()
    }

    /// Try to get GitHub token from `gh` CLI
    fn get_gh_cli_token() -> Option<String> {
        let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;

        if output.status.success() {
            let token = String::from_utf8(output.stdout).ok()?;
            let token = token.trim();
            if !token.is_empty() {
                tracing::debug!("Using GitHub token from `gh auth token`");
                return Some(token.to_string());
            }
        }

        None
    }

    /// Download the latest simulation assets from GitHub releases
    pub async fn download_latest(&self) -> Result<PathBuf> {
        // Get latest release info from GitHub API
        let client = Self::build_client()?;
        let token = Self::github_token();

        let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);

        println!("   Checking latest release...");

        let mut request = client.get(&release_url);
        if let Some(ref token) = token {
            request = request.header("Authorization", format!("Bearer {}", token));
        }

        let response = request
            .send()
            .await
            .context("Failed to fetch release info from GitHub")?;

        if !response.status().is_success() {
            let status = response.status();
            let hint = if status.as_u16() == 404 && token.is_none() {
                "\n\nHint: The repository may be private. Set GITHUB_TOKEN or GH_TOKEN environment variable."
            } else {
                ""
            };
            return Err(anyhow::anyhow!(
                "Failed to get release info: HTTP {}.{}\n\
                 The simulation assets may not be published yet.\n\n\
                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
                status,
                hint
            ));
        }

        let release: serde_json::Value = response.json().await?;

        let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();

        // Find the simulation asset
        let assets = release["assets"]
            .as_array()
            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;

        let asset = assets
            .iter()
            .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "Simulation asset '{}' not found in release {}.\n\n\
                     The release may not include simulation assets yet.\n\
                     Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
                    ASSET_NAME,
                    tag_name
                )
            })?;

        // For private repos, use the API URL; for public repos, use browser_download_url
        let download_url = if token.is_some() {
            // Use API URL for authenticated requests (works with private repos)
            asset["url"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
        } else {
            // Use browser URL for unauthenticated requests (public repos only)
            asset["browser_download_url"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
        };

        let size = asset["size"].as_u64().unwrap_or(0);

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

        // Download the asset
        self.download_and_extract(download_url, &tag_name, size, token).await
    }

    /// Download and extract simulation assets
    async fn download_and_extract(
        &self,
        url: &str,
        version: &str,
        size: u64,
        token: Option<String>,
    ) -> Result<PathBuf> {
        let client = Self::build_client()?;

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

        let mut request = client.get(url);

        // Add auth and accept headers for API downloads (private repos)
        if let Some(ref token) = token {
            request = request
                .header("Authorization", format!("Bearer {}", token))
                .header("Accept", "application/octet-stream");
        }

        let response = request.send().await.context("Failed to download simulation assets")?;

        if !response.status().is_success() {
            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
        }

        // 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)]
        {
            // On Windows, use directory junction or just copy
            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()
    }
}