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
33const GITHUB_REPO: &str = "mecha-industries/mecha10";
34/// Asset name pattern in GitHub releases
35const ASSET_NAME: &str = "mecha10-simulation.tar.gz";
36
37/// Service for managing simulation assets
38pub struct SimulationAssetsService {
39    /// Base directory for cached assets (~/.mecha10)
40    cache_dir: PathBuf,
41}
42
43#[allow(dead_code)]
44impl SimulationAssetsService {
45    /// Create a new simulation assets service
46    pub fn new() -> Self {
47        let cache_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".mecha10");
48        Self { cache_dir }
49    }
50
51    /// Get the simulation cache directory
52    pub fn cache_dir(&self) -> &PathBuf {
53        &self.cache_dir
54    }
55
56    /// Get the path to the current simulation assets
57    ///
58    /// Returns the path to `~/.mecha10/simulation/current` if it exists
59    pub fn current_assets_path(&self) -> Option<PathBuf> {
60        let current = self.cache_dir.join("simulation").join("current");
61        if current.exists() {
62            // Resolve symlink to actual path
63            std::fs::canonicalize(&current).ok()
64        } else {
65            None
66        }
67    }
68
69    /// Get the Godot project path from cached assets
70    pub fn godot_project_path(&self) -> Option<PathBuf> {
71        self.current_assets_path()
72            .map(|p| p.join("godot-project"))
73            .filter(|p| p.exists())
74    }
75
76    /// Get the models directory from cached assets
77    pub fn models_path(&self) -> Option<PathBuf> {
78        self.current_assets_path()
79            .map(|p| p.join("models"))
80            .filter(|p| p.exists())
81    }
82
83    /// Get the environments directory from cached assets
84    pub fn environments_path(&self) -> Option<PathBuf> {
85        self.current_assets_path()
86            .map(|p| p.join("environments"))
87            .filter(|p| p.exists())
88    }
89
90    /// Check if simulation assets are installed
91    pub fn is_installed(&self) -> bool {
92        self.godot_project_path().is_some()
93    }
94
95    /// Get the installed version (if any)
96    pub fn installed_version(&self) -> Option<String> {
97        let version_file = self.cache_dir.join("simulation").join("version");
98        std::fs::read_to_string(version_file).ok()
99    }
100
101    /// Ensure simulation assets are available, downloading if needed
102    ///
103    /// Returns the path to the simulation assets directory
104    pub async fn ensure_assets(&self) -> Result<PathBuf> {
105        if let Some(path) = self.current_assets_path() {
106            tracing::debug!("Simulation assets already installed at {:?}", path);
107            return Ok(path);
108        }
109
110        // Need to download
111        println!("📦 Simulation assets not found locally");
112        println!("   Downloading from GitHub releases...");
113
114        self.download_latest().await
115    }
116
117    /// Download the latest simulation assets from GitHub releases
118    pub async fn download_latest(&self) -> Result<PathBuf> {
119        // Get latest release info from GitHub API
120        let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
121
122        let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
123
124        println!("   Checking latest release...");
125
126        let response = client
127            .get(&release_url)
128            .send()
129            .await
130            .context("Failed to fetch release info from GitHub")?;
131
132        if !response.status().is_success() {
133            return Err(anyhow::anyhow!(
134                "Failed to get release info: HTTP {}. \n\
135                 The simulation assets may not be published yet.\n\n\
136                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
137                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
138                response.status()
139            ));
140        }
141
142        let release: serde_json::Value = response.json().await?;
143
144        let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
145
146        // Find the simulation asset
147        let assets = release["assets"]
148            .as_array()
149            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
150
151        let asset = assets
152            .iter()
153            .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
154            .ok_or_else(|| {
155                anyhow::anyhow!(
156                    "Simulation asset '{}' not found in release {}.\n\n\
157                     The release may not include simulation assets yet.\n\
158                     Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
159                    ASSET_NAME,
160                    tag_name
161                )
162            })?;
163
164        let download_url = asset["browser_download_url"]
165            .as_str()
166            .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
167
168        let size = asset["size"].as_u64().unwrap_or(0);
169
170        println!("   Release: {}", tag_name);
171        println!("   Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
172
173        // Download the asset
174        self.download_and_extract(download_url, &tag_name, size).await
175    }
176
177    /// Download and extract simulation assets
178    async fn download_and_extract(&self, url: &str, version: &str, size: u64) -> Result<PathBuf> {
179        let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
180
181        // Create progress bar
182        let pb = ProgressBar::new(size);
183        pb.set_style(
184            ProgressStyle::default_bar()
185                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
186                .unwrap()
187                .progress_chars("#>-"),
188        );
189
190        println!("   Downloading...");
191
192        let response = client
193            .get(url)
194            .send()
195            .await
196            .context("Failed to download simulation assets")?;
197
198        if !response.status().is_success() {
199            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
200        }
201
202        // Download to temp file
203        let temp_dir = tempfile::tempdir()?;
204        let temp_file = temp_dir.path().join("simulation.tar.gz");
205
206        let mut file = tokio::fs::File::create(&temp_file).await?;
207        let mut stream = response.bytes_stream();
208
209        use futures_util::StreamExt;
210        use tokio::io::AsyncWriteExt;
211
212        while let Some(chunk) = stream.next().await {
213            let chunk = chunk.context("Error downloading chunk")?;
214            file.write_all(&chunk).await?;
215            pb.inc(chunk.len() as u64);
216        }
217
218        file.flush().await?;
219        pb.finish_with_message("Download complete");
220
221        // Extract
222        println!("   Extracting...");
223
224        let simulation_dir = self.cache_dir.join("simulation");
225        let version_dir = simulation_dir.join(version);
226
227        // Create directories
228        tokio::fs::create_dir_all(&version_dir).await?;
229
230        // Extract tarball
231        let tar_gz = std::fs::File::open(&temp_file)?;
232        let tar = flate2::read::GzDecoder::new(tar_gz);
233        let mut archive = tar::Archive::new(tar);
234        archive.unpack(&version_dir)?;
235
236        // Create/update symlink to current version
237        let current_link = simulation_dir.join("current");
238        if current_link.exists() {
239            tokio::fs::remove_file(&current_link).await.ok();
240        }
241
242        #[cfg(unix)]
243        {
244            std::os::unix::fs::symlink(&version_dir, &current_link)?;
245        }
246
247        #[cfg(windows)]
248        {
249            // On Windows, use directory junction or just copy
250            std::os::windows::fs::symlink_dir(&version_dir, &current_link)?;
251        }
252
253        // Write version file
254        let version_file = simulation_dir.join("version");
255        tokio::fs::write(&version_file, version).await?;
256
257        println!("✅ Simulation assets installed to {:?}", version_dir);
258
259        Ok(version_dir)
260    }
261
262    /// Remove cached simulation assets
263    pub async fn remove(&self) -> Result<()> {
264        let simulation_dir = self.cache_dir.join("simulation");
265        if simulation_dir.exists() {
266            tokio::fs::remove_dir_all(&simulation_dir).await?;
267            println!("✅ Simulation assets removed");
268        } else {
269            println!("No simulation assets installed");
270        }
271        Ok(())
272    }
273
274    /// Update to latest version
275    pub async fn update(&self) -> Result<PathBuf> {
276        // Remove current and download fresh
277        self.remove().await.ok();
278        self.download_latest().await
279    }
280}
281
282impl Default for SimulationAssetsService {
283    fn default() -> Self {
284        Self::new()
285    }
286}