Skip to main content

mecha10_sim/services/
simulation_assets.rs

1//! Simulation assets service for downloading and caching simulation files via `mecha10-api`
2//!
3//! This service provides:
4//! - Downloading simulation assets (models, environments) via the public
5//!   `mecha10-api` downloads proxy (`GET /api/downloads/simulation`), never the private
6//!   Minio instance directly
7//! - Caching assets to `~/.mecha10/simulation/`
8//! - Version management and updates
9//!
10//! # Design
11//!
12//! - Assets are downloaded as a tarball from `{mecha10_api}/downloads/simulation[?version=X]`
13//! - Assets are extracted to `~/.mecha10/simulation/{version}/`
14//! - A symlink `~/.mecha10/simulation/current` points to the active version
15//! - Omitting `version` resolves to the latest published version server-side; the resolved
16//!   version is read back from the response's `Content-Disposition` filename
17
18use crate::paths;
19use anyhow::{Context, Result};
20use indicatif::{ProgressBar, ProgressStyle};
21use serde::Deserialize;
22use std::path::PathBuf;
23
24/// Service for managing simulation assets
25pub struct SimulationAssetsService {
26    /// Base directory for cached assets (~/.mecha10)
27    cache_dir: PathBuf,
28}
29
30/// Mirrors `mecha10-api`'s `catalog::downloads::types::VersionResponse`.
31#[derive(Debug, Deserialize)]
32struct VersionResponse {
33    version: String,
34}
35
36#[allow(dead_code)]
37impl SimulationAssetsService {
38    /// Create a new simulation assets service
39    pub fn new() -> Self {
40        let cache_dir = paths::user::mecha10_dir();
41        Self { cache_dir }
42    }
43
44    /// Get the simulation cache directory
45    pub fn cache_dir(&self) -> &PathBuf {
46        &self.cache_dir
47    }
48
49    /// Get the path to the current simulation assets
50    ///
51    /// Returns the path to `~/.mecha10/simulation/current` if it exists
52    pub fn current_assets_path(&self) -> Option<PathBuf> {
53        let current = paths::user::simulation_current();
54        if current.exists() {
55            // Resolve symlink to actual path
56            std::fs::canonicalize(&current).ok()
57        } else {
58            None
59        }
60    }
61
62    /// Get the models directory from cached assets
63    pub fn models_path(&self) -> Option<PathBuf> {
64        self.current_assets_path()
65            .map(|p| p.join("models"))
66            .filter(|p| p.exists())
67    }
68
69    /// Get the environments directory from cached assets
70    pub fn environments_path(&self) -> Option<PathBuf> {
71        self.current_assets_path()
72            .map(|p| p.join("environments"))
73            .filter(|p| p.exists())
74    }
75
76    /// Check if simulation assets are installed
77    pub fn is_installed(&self) -> bool {
78        self.models_path().is_some() || self.environments_path().is_some()
79    }
80
81    /// Get the installed version (if any)
82    pub fn installed_version(&self) -> Option<String> {
83        let version_file = paths::user::simulation_dir().join("version");
84        std::fs::read_to_string(version_file).ok()
85    }
86
87    /// Get the latest simulation assets version via `mecha10-api`
88    ///
89    /// Calls `GET /api/downloads/simulation/version`, which resolves the version
90    /// without downloading the (potentially large) tarball.
91    pub async fn get_latest_version(&self) -> Result<String> {
92        let url = format!("{}/downloads/simulation/version", paths::urls::mecha10_api_base_url());
93        let client = Self::build_client()?;
94        let response = client
95            .get(&url)
96            .send()
97            .await
98            .context("Failed to fetch latest simulation assets version from mecha10-api")?;
99
100        if !response.status().is_success() {
101            return Err(anyhow::anyhow!(
102                "Failed to get latest version: HTTP {}\nURL: {}",
103                response.status(),
104                url
105            ));
106        }
107
108        let parsed: VersionResponse = response
109            .json()
110            .await
111            .context("Failed to parse version response from mecha10-api")?;
112        Ok(parsed.version)
113    }
114
115    /// Ensure simulation assets are available for a specific version, downloading if needed
116    ///
117    /// Returns the path to the simulation assets directory
118    pub async fn ensure_assets(&self, version: &str) -> Result<PathBuf> {
119        let version_dir = paths::user::simulation_dir().join(version);
120        if version_dir.exists() {
121            tracing::debug!("Using cached simulation assets {}", version);
122            return Ok(version_dir);
123        }
124
125        println!("📦 Downloading simulation assets {}...", version);
126        self.download_version(version).await
127    }
128
129    /// Build an HTTP client
130    fn build_client() -> Result<reqwest::Client> {
131        reqwest::Client::builder()
132            .user_agent("mecha10-cli")
133            .build()
134            .context("Failed to build HTTP client")
135    }
136
137    /// Download simulation assets for a specific version via `mecha10-api`
138    async fn download_version(&self, version: &str) -> Result<PathBuf> {
139        let url = format!(
140            "{}/downloads/simulation?version={}",
141            paths::urls::mecha10_api_base_url(),
142            version
143        );
144
145        tracing::info!("Downloading sim assets from mecha10-api: {}", url);
146
147        let client = Self::build_client()?;
148        let response = client
149            .get(&url)
150            .send()
151            .await
152            .context("Failed to connect to mecha10-api for simulation assets")?;
153
154        if !response.status().is_success() {
155            return Err(anyhow::anyhow!(
156                "Simulation asset not found: HTTP {}\n\
157                 URL: {}\n\n\
158                 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
159                   export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
160                response.status(),
161                url
162            ));
163        }
164
165        let size = response.content_length().unwrap_or(0);
166
167        println!("   Release: {}", version);
168        if size > 0 {
169            println!("   Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
170        }
171
172        self.download_and_extract(response, version, size).await
173    }
174
175    /// Download the latest simulation assets via `mecha10-api`
176    pub async fn download_latest(&self) -> Result<PathBuf> {
177        println!("   Checking latest release...");
178        let version = self.get_latest_version().await?;
179        self.download_version(&version).await
180    }
181
182    /// Stream and extract a download response into the cache
183    async fn download_and_extract(&self, response: reqwest::Response, version: &str, size: u64) -> Result<PathBuf> {
184        // Create progress bar
185        let pb = ProgressBar::new(size);
186        pb.set_style(
187            ProgressStyle::default_bar()
188                .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
189                .unwrap()
190                .progress_chars("#>-"),
191        );
192
193        println!("   Downloading...");
194
195        // Download to temp file
196        let temp_dir = tempfile::tempdir()?;
197        let temp_file = temp_dir.path().join("simulation.tar.gz");
198
199        let mut file = tokio::fs::File::create(&temp_file).await?;
200        let mut stream = response.bytes_stream();
201
202        use futures_util::StreamExt;
203        use tokio::io::AsyncWriteExt;
204
205        while let Some(chunk) = stream.next().await {
206            let chunk = chunk.context("Error downloading chunk")?;
207            file.write_all(&chunk).await?;
208            pb.inc(chunk.len() as u64);
209        }
210
211        file.flush().await?;
212        pb.finish_with_message("Download complete");
213
214        // Extract
215        println!("   Extracting...");
216
217        let simulation_dir = paths::user::simulation_dir();
218        let version_dir = simulation_dir.join(version);
219
220        // Create directories
221        tokio::fs::create_dir_all(&version_dir).await?;
222
223        // Extract tarball
224        let tar_gz = std::fs::File::open(&temp_file)?;
225        let tar = flate2::read::GzDecoder::new(tar_gz);
226        let mut archive = tar::Archive::new(tar);
227        archive.unpack(&version_dir)?;
228
229        // Create/update symlink to current version
230        let current_link = simulation_dir.join("current");
231        if current_link.exists() {
232            tokio::fs::remove_file(&current_link).await.ok();
233        }
234
235        #[cfg(unix)]
236        {
237            std::os::unix::fs::symlink(&version_dir, &current_link)?;
238        }
239
240        #[cfg(windows)]
241        {
242            std::os::windows::fs::symlink_dir(&version_dir, &current_link)?;
243        }
244
245        // Write version file
246        let version_file = simulation_dir.join("version");
247        tokio::fs::write(&version_file, version).await?;
248
249        println!("✅ Simulation assets installed to {:?}", version_dir);
250
251        Ok(version_dir)
252    }
253
254    /// Remove cached simulation assets
255    pub async fn remove(&self) -> Result<()> {
256        let simulation_dir = paths::user::simulation_dir();
257        if simulation_dir.exists() {
258            tokio::fs::remove_dir_all(&simulation_dir).await?;
259            println!("✅ Simulation assets removed");
260        } else {
261            println!("No simulation assets installed");
262        }
263        Ok(())
264    }
265
266    /// Update to latest version
267    pub async fn update(&self) -> Result<PathBuf> {
268        // Remove current and download fresh
269        self.remove().await.ok();
270        self.download_latest().await
271    }
272}
273
274impl Default for SimulationAssetsService {
275    fn default() -> Self {
276        Self::new()
277    }
278}