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