mecha10_cli/services/
simulation_assets.rs

1//! Simulation assets service for downloading and caching simulation files from GitHub releases
2//!
3//! This service provides:
4//! - Downloading simulation assets (Godot project, models, environments) from GitHub releases
5//! - Caching assets to `~/.mecha10/simulation/`
6//! - Version management and updates
7//!
8//! # Design
9//!
10//! - Assets are downloaded from GitHub releases as a tarball
11//! - Assets are extracted to `~/.mecha10/simulation/{version}/`
12//! - A symlink `~/.mecha10/simulation/current` points to the active version
13//!
14//! # Usage
15//!
16//! ```no_run
17//! use mecha10_cli::services::SimulationAssetsService;
18//!
19//! let service = SimulationAssetsService::new();
20//!
21//! // Ensure assets are available (downloads if needed)
22//! let path = service.ensure_assets().await?;
23//!
24//! // Get Godot project path
25//! let godot_path = service.godot_project_path()?;
26//! ```
27
28use crate::paths;
29use anyhow::{Context, Result};
30use indicatif::{ProgressBar, ProgressStyle};
31use std::path::PathBuf;
32
33/// GitHub repository for simulation assets (public distribution repo)
34/// Note: We use a separate public repo for distribution while keeping the main repo private
35const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
36/// Asset name pattern in GitHub releases
37const ASSET_NAME: &str = "mecha10-simulation.tar.gz";
38
39/// Service for managing simulation assets
40pub struct SimulationAssetsService {
41    /// Base directory for cached assets (~/.mecha10)
42    cache_dir: PathBuf,
43}
44
45#[allow(dead_code)]
46impl SimulationAssetsService {
47    /// Create a new simulation assets service
48    pub fn new() -> Self {
49        let cache_dir = paths::user::mecha10_dir();
50        Self { cache_dir }
51    }
52
53    /// Get the simulation cache directory
54    pub fn cache_dir(&self) -> &PathBuf {
55        &self.cache_dir
56    }
57
58    /// Get the path to the current simulation assets
59    ///
60    /// Returns the path to `~/.mecha10/simulation/current` if it exists
61    pub fn current_assets_path(&self) -> Option<PathBuf> {
62        let current = paths::user::simulation_current();
63        if current.exists() {
64            // Resolve symlink to actual path
65            std::fs::canonicalize(&current).ok()
66        } else {
67            None
68        }
69    }
70
71    /// Get the Godot project path from cached assets
72    pub fn godot_project_path(&self) -> Option<PathBuf> {
73        self.current_assets_path()
74            .map(|p| p.join("godot-project"))
75            .filter(|p| p.exists())
76    }
77
78    /// Get the models directory from cached assets
79    pub fn models_path(&self) -> Option<PathBuf> {
80        self.current_assets_path()
81            .map(|p| p.join("models"))
82            .filter(|p| p.exists())
83    }
84
85    /// Get the environments directory from cached assets
86    pub fn environments_path(&self) -> Option<PathBuf> {
87        self.current_assets_path()
88            .map(|p| p.join("environments"))
89            .filter(|p| p.exists())
90    }
91
92    /// Check if simulation assets are installed
93    pub fn is_installed(&self) -> bool {
94        self.godot_project_path().is_some()
95    }
96
97    /// Get the installed version (if any)
98    pub fn installed_version(&self) -> Option<String> {
99        let version_file = paths::user::simulation_dir().join("version");
100        std::fs::read_to_string(version_file).ok()
101    }
102
103    /// Ensure simulation assets are available, downloading if needed
104    ///
105    /// Returns the path to the simulation assets directory
106    pub async fn ensure_assets(&self) -> Result<PathBuf> {
107        if let Some(path) = self.current_assets_path() {
108            tracing::debug!("Simulation assets already installed at {:?}", path);
109            return Ok(path);
110        }
111
112        // Need to download
113        println!("📦 Simulation assets not found locally");
114        println!("   Downloading from GitHub releases...");
115
116        self.download_latest().await
117    }
118
119    /// Build an HTTP client with optional GitHub authentication
120    fn build_client() -> Result<reqwest::Client> {
121        reqwest::Client::builder()
122            .user_agent("mecha10-cli")
123            .build()
124            .context("Failed to build HTTP client")
125    }
126
127    /// Get GitHub token from environment (GITHUB_TOKEN or GH_TOKEN) or `gh` CLI
128    ///
129    /// Tries in order:
130    /// 1. GITHUB_TOKEN environment variable
131    /// 2. GH_TOKEN environment variable
132    /// 3. `gh auth token` command (if `gh` CLI is installed and authenticated)
133    fn github_token() -> Option<String> {
134        // First try environment variables
135        if let Ok(token) = std::env::var("GITHUB_TOKEN") {
136            return Some(token);
137        }
138        if let Ok(token) = std::env::var("GH_TOKEN") {
139            return Some(token);
140        }
141
142        // Fall back to `gh auth token` command
143        Self::get_gh_cli_token()
144    }
145
146    /// Try to get GitHub token from `gh` CLI
147    fn get_gh_cli_token() -> Option<String> {
148        let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;
149
150        if output.status.success() {
151            let token = String::from_utf8(output.stdout).ok()?;
152            let token = token.trim();
153            if !token.is_empty() {
154                tracing::debug!("Using GitHub token from `gh auth token`");
155                return Some(token.to_string());
156            }
157        }
158
159        None
160    }
161
162    /// Download the latest simulation assets from GitHub releases
163    pub async fn download_latest(&self) -> Result<PathBuf> {
164        // Get latest release info from GitHub API
165        let client = Self::build_client()?;
166        let token = Self::github_token();
167
168        let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
169
170        println!("   Checking latest release...");
171
172        let mut request = client.get(&release_url);
173        if let Some(ref token) = token {
174            request = request.header("Authorization", format!("Bearer {}", token));
175        }
176
177        let response = request
178            .send()
179            .await
180            .context("Failed to fetch release info from GitHub")?;
181
182        if !response.status().is_success() {
183            let status = response.status();
184            let hint = if status.as_u16() == 404 && token.is_none() {
185                "\n\nHint: The repository may be private. Set GITHUB_TOKEN or GH_TOKEN environment variable."
186            } else {
187                ""
188            };
189            return Err(anyhow::anyhow!(
190                "Failed to get release info: HTTP {}.{}\n\
191                 The simulation assets may not be published yet.\n\n\
192                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
193                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
194                status,
195                hint
196            ));
197        }
198
199        let release: serde_json::Value = response.json().await?;
200
201        let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
202
203        // Find the simulation asset
204        let assets = release["assets"]
205            .as_array()
206            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
207
208        let asset = assets
209            .iter()
210            .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
211            .ok_or_else(|| {
212                anyhow::anyhow!(
213                    "Simulation asset '{}' not found in release {}.\n\n\
214                     The release may not include simulation assets yet.\n\
215                     Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
216                    ASSET_NAME,
217                    tag_name
218                )
219            })?;
220
221        // For private repos, use the API URL; for public repos, use browser_download_url
222        let download_url = if token.is_some() {
223            // Use API URL for authenticated requests (works with private repos)
224            asset["url"]
225                .as_str()
226                .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
227        } else {
228            // Use browser URL for unauthenticated requests (public repos only)
229            asset["browser_download_url"]
230                .as_str()
231                .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
232        };
233
234        let size = asset["size"].as_u64().unwrap_or(0);
235
236        println!("   Release: {}", tag_name);
237        println!("   Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
238
239        // Download the asset
240        self.download_and_extract(download_url, &tag_name, size, token).await
241    }
242
243    /// Download and extract simulation assets
244    async fn download_and_extract(
245        &self,
246        url: &str,
247        version: &str,
248        size: u64,
249        token: Option<String>,
250    ) -> Result<PathBuf> {
251        let client = Self::build_client()?;
252
253        // Create progress bar
254        let pb = ProgressBar::new(size);
255        pb.set_style(
256            ProgressStyle::default_bar()
257                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
258                .unwrap()
259                .progress_chars("#>-"),
260        );
261
262        println!("   Downloading...");
263
264        let mut request = client.get(url);
265
266        // Add auth and accept headers for API downloads (private repos)
267        if let Some(ref token) = token {
268            request = request
269                .header("Authorization", format!("Bearer {}", token))
270                .header("Accept", "application/octet-stream");
271        }
272
273        let response = request.send().await.context("Failed to download simulation assets")?;
274
275        if !response.status().is_success() {
276            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
277        }
278
279        // Download to temp file
280        let temp_dir = tempfile::tempdir()?;
281        let temp_file = temp_dir.path().join("simulation.tar.gz");
282
283        let mut file = tokio::fs::File::create(&temp_file).await?;
284        let mut stream = response.bytes_stream();
285
286        use futures_util::StreamExt;
287        use tokio::io::AsyncWriteExt;
288
289        while let Some(chunk) = stream.next().await {
290            let chunk = chunk.context("Error downloading chunk")?;
291            file.write_all(&chunk).await?;
292            pb.inc(chunk.len() as u64);
293        }
294
295        file.flush().await?;
296        pb.finish_with_message("Download complete");
297
298        // Extract
299        println!("   Extracting...");
300
301        let simulation_dir = paths::user::simulation_dir();
302        let version_dir = simulation_dir.join(version);
303
304        // Create directories
305        tokio::fs::create_dir_all(&version_dir).await?;
306
307        // Extract tarball
308        let tar_gz = std::fs::File::open(&temp_file)?;
309        let tar = flate2::read::GzDecoder::new(tar_gz);
310        let mut archive = tar::Archive::new(tar);
311        archive.unpack(&version_dir)?;
312
313        // Create/update symlink to current version
314        let current_link = simulation_dir.join("current");
315        if current_link.exists() {
316            tokio::fs::remove_file(&current_link).await.ok();
317        }
318
319        #[cfg(unix)]
320        {
321            std::os::unix::fs::symlink(&version_dir, &current_link)?;
322        }
323
324        #[cfg(windows)]
325        {
326            // On Windows, use directory junction or just copy
327            std::os::windows::fs::symlink_dir(&version_dir, &current_link)?;
328        }
329
330        // Write version file
331        let version_file = simulation_dir.join("version");
332        tokio::fs::write(&version_file, version).await?;
333
334        println!("✅ Simulation assets installed to {:?}", version_dir);
335
336        Ok(version_dir)
337    }
338
339    /// Remove cached simulation assets
340    pub async fn remove(&self) -> Result<()> {
341        let simulation_dir = paths::user::simulation_dir();
342        if simulation_dir.exists() {
343            tokio::fs::remove_dir_all(&simulation_dir).await?;
344            println!("✅ Simulation assets removed");
345        } else {
346            println!("No simulation assets installed");
347        }
348        Ok(())
349    }
350
351    /// Update to latest version
352    pub async fn update(&self) -> Result<PathBuf> {
353        // Remove current and download fresh
354        self.remove().await.ok();
355        self.download_latest().await
356    }
357}
358
359impl Default for SimulationAssetsService {
360    fn default() -> Self {
361        Self::new()
362    }
363}