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    /// Build an HTTP client with optional GitHub authentication
118    fn build_client() -> Result<reqwest::Client> {
119        reqwest::Client::builder()
120            .user_agent("mecha10-cli")
121            .build()
122            .context("Failed to build HTTP client")
123    }
124
125    /// Get GitHub token from environment (GITHUB_TOKEN or GH_TOKEN)
126    fn github_token() -> Option<String> {
127        std::env::var("GITHUB_TOKEN")
128            .or_else(|_| std::env::var("GH_TOKEN"))
129            .ok()
130    }
131
132    /// Download the latest simulation assets from GitHub releases
133    pub async fn download_latest(&self) -> Result<PathBuf> {
134        // Get latest release info from GitHub API
135        let client = Self::build_client()?;
136        let token = Self::github_token();
137
138        let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
139
140        println!("   Checking latest release...");
141
142        let mut request = client.get(&release_url);
143        if let Some(ref token) = token {
144            request = request.header("Authorization", format!("Bearer {}", token));
145        }
146
147        let response = request
148            .send()
149            .await
150            .context("Failed to fetch release info from GitHub")?;
151
152        if !response.status().is_success() {
153            let status = response.status();
154            let hint = if status.as_u16() == 404 && token.is_none() {
155                "\n\nHint: The repository may be private. Set GITHUB_TOKEN or GH_TOKEN environment variable."
156            } else {
157                ""
158            };
159            return Err(anyhow::anyhow!(
160                "Failed to get release info: HTTP {}.{}\n\
161                 The simulation assets may not be published yet.\n\n\
162                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
163                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
164                status,
165                hint
166            ));
167        }
168
169        let release: serde_json::Value = response.json().await?;
170
171        let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
172
173        // Find the simulation asset
174        let assets = release["assets"]
175            .as_array()
176            .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
177
178        let asset = assets
179            .iter()
180            .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
181            .ok_or_else(|| {
182                anyhow::anyhow!(
183                    "Simulation asset '{}' not found in release {}.\n\n\
184                     The release may not include simulation assets yet.\n\
185                     Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
186                    ASSET_NAME,
187                    tag_name
188                )
189            })?;
190
191        // For private repos, use the API URL; for public repos, use browser_download_url
192        let download_url = if token.is_some() {
193            // Use API URL for authenticated requests (works with private repos)
194            asset["url"]
195                .as_str()
196                .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
197        } else {
198            // Use browser URL for unauthenticated requests (public repos only)
199            asset["browser_download_url"]
200                .as_str()
201                .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
202        };
203
204        let size = asset["size"].as_u64().unwrap_or(0);
205
206        println!("   Release: {}", tag_name);
207        println!("   Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
208
209        // Download the asset
210        self.download_and_extract(download_url, &tag_name, size, token)
211            .await
212    }
213
214    /// Download and extract simulation assets
215    async fn download_and_extract(
216        &self,
217        url: &str,
218        version: &str,
219        size: u64,
220        token: Option<String>,
221    ) -> Result<PathBuf> {
222        let client = Self::build_client()?;
223
224        // Create progress bar
225        let pb = ProgressBar::new(size);
226        pb.set_style(
227            ProgressStyle::default_bar()
228                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
229                .unwrap()
230                .progress_chars("#>-"),
231        );
232
233        println!("   Downloading...");
234
235        let mut request = client.get(url);
236
237        // Add auth and accept headers for API downloads (private repos)
238        if let Some(ref token) = token {
239            request = request
240                .header("Authorization", format!("Bearer {}", token))
241                .header("Accept", "application/octet-stream");
242        }
243
244        let response = request
245            .send()
246            .await
247            .context("Failed to download simulation assets")?;
248
249        if !response.status().is_success() {
250            return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
251        }
252
253        // Download to temp file
254        let temp_dir = tempfile::tempdir()?;
255        let temp_file = temp_dir.path().join("simulation.tar.gz");
256
257        let mut file = tokio::fs::File::create(&temp_file).await?;
258        let mut stream = response.bytes_stream();
259
260        use futures_util::StreamExt;
261        use tokio::io::AsyncWriteExt;
262
263        while let Some(chunk) = stream.next().await {
264            let chunk = chunk.context("Error downloading chunk")?;
265            file.write_all(&chunk).await?;
266            pb.inc(chunk.len() as u64);
267        }
268
269        file.flush().await?;
270        pb.finish_with_message("Download complete");
271
272        // Extract
273        println!("   Extracting...");
274
275        let simulation_dir = self.cache_dir.join("simulation");
276        let version_dir = simulation_dir.join(version);
277
278        // Create directories
279        tokio::fs::create_dir_all(&version_dir).await?;
280
281        // Extract tarball
282        let tar_gz = std::fs::File::open(&temp_file)?;
283        let tar = flate2::read::GzDecoder::new(tar_gz);
284        let mut archive = tar::Archive::new(tar);
285        archive.unpack(&version_dir)?;
286
287        // Create/update symlink to current version
288        let current_link = simulation_dir.join("current");
289        if current_link.exists() {
290            tokio::fs::remove_file(&current_link).await.ok();
291        }
292
293        #[cfg(unix)]
294        {
295            std::os::unix::fs::symlink(&version_dir, &current_link)?;
296        }
297
298        #[cfg(windows)]
299        {
300            // On Windows, use directory junction or just copy
301            std::os::windows::fs::symlink_dir(&version_dir, &current_link)?;
302        }
303
304        // Write version file
305        let version_file = simulation_dir.join("version");
306        tokio::fs::write(&version_file, version).await?;
307
308        println!("✅ Simulation assets installed to {:?}", version_dir);
309
310        Ok(version_dir)
311    }
312
313    /// Remove cached simulation assets
314    pub async fn remove(&self) -> Result<()> {
315        let simulation_dir = self.cache_dir.join("simulation");
316        if simulation_dir.exists() {
317            tokio::fs::remove_dir_all(&simulation_dir).await?;
318            println!("✅ Simulation assets removed");
319        } else {
320            println!("No simulation assets installed");
321        }
322        Ok(())
323    }
324
325    /// Update to latest version
326    pub async fn update(&self) -> Result<PathBuf> {
327        // Remove current and download fresh
328        self.remove().await.ok();
329        self.download_latest().await
330    }
331}
332
333impl Default for SimulationAssetsService {
334    fn default() -> Self {
335        Self::new()
336    }
337}