mecha10_cli/services/
simulation_assets.rs1use anyhow::{Context, Result};
29use indicatif::{ProgressBar, ProgressStyle};
30use std::path::PathBuf;
31
32const GITHUB_REPO: &str = "mecha-industries/mecha10";
34const ASSET_NAME: &str = "mecha10-simulation.tar.gz";
36
37pub struct SimulationAssetsService {
39 cache_dir: PathBuf,
41}
42
43#[allow(dead_code)]
44impl SimulationAssetsService {
45 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 pub fn cache_dir(&self) -> &PathBuf {
53 &self.cache_dir
54 }
55
56 pub fn current_assets_path(&self) -> Option<PathBuf> {
60 let current = self.cache_dir.join("simulation").join("current");
61 if current.exists() {
62 std::fs::canonicalize(¤t).ok()
64 } else {
65 None
66 }
67 }
68
69 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 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 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 pub fn is_installed(&self) -> bool {
92 self.godot_project_path().is_some()
93 }
94
95 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 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 println!("📦 Simulation assets not found locally");
112 println!(" Downloading from GitHub releases...");
113
114 self.download_latest().await
115 }
116
117 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 fn github_token() -> Option<String> {
127 std::env::var("GITHUB_TOKEN")
128 .or_else(|_| std::env::var("GH_TOKEN"))
129 .ok()
130 }
131
132 pub async fn download_latest(&self) -> Result<PathBuf> {
134 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 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 let download_url = if token.is_some() {
193 asset["url"]
195 .as_str()
196 .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
197 } else {
198 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 self.download_and_extract(download_url, &tag_name, size, token)
211 .await
212 }
213
214 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 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 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 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 println!(" Extracting...");
274
275 let simulation_dir = self.cache_dir.join("simulation");
276 let version_dir = simulation_dir.join(version);
277
278 tokio::fs::create_dir_all(&version_dir).await?;
280
281 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 let current_link = simulation_dir.join("current");
289 if current_link.exists() {
290 tokio::fs::remove_file(¤t_link).await.ok();
291 }
292
293 #[cfg(unix)]
294 {
295 std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
296 }
297
298 #[cfg(windows)]
299 {
300 std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
302 }
303
304 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 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 pub async fn update(&self) -> Result<PathBuf> {
327 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}