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