mecha10_cli/services/
simulation_assets.rs1use crate::paths;
29use anyhow::{Context, Result};
30use indicatif::{ProgressBar, ProgressStyle};
31use std::path::PathBuf;
32
33const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
36const ASSET_NAME: &str = "mecha10-simulation.tar.gz";
38
39pub struct SimulationAssetsService {
41 cache_dir: PathBuf,
43}
44
45#[allow(dead_code)]
46impl SimulationAssetsService {
47 pub fn new() -> Self {
49 let cache_dir = paths::user::mecha10_dir();
50 Self { cache_dir }
51 }
52
53 pub fn cache_dir(&self) -> &PathBuf {
55 &self.cache_dir
56 }
57
58 pub fn current_assets_path(&self) -> Option<PathBuf> {
62 let current = paths::user::simulation_current();
63 if current.exists() {
64 std::fs::canonicalize(¤t).ok()
66 } else {
67 None
68 }
69 }
70
71 pub fn godot_project_path(&self) -> Option<PathBuf> {
73 self.current_assets_path()
74 .map(|p| p.join("godot-project"))
75 .filter(|p| p.exists())
76 }
77
78 pub fn models_path(&self) -> Option<PathBuf> {
80 self.current_assets_path()
81 .map(|p| p.join("models"))
82 .filter(|p| p.exists())
83 }
84
85 pub fn environments_path(&self) -> Option<PathBuf> {
87 self.current_assets_path()
88 .map(|p| p.join("environments"))
89 .filter(|p| p.exists())
90 }
91
92 pub fn is_installed(&self) -> bool {
94 self.godot_project_path().is_some()
95 }
96
97 pub fn installed_version(&self) -> Option<String> {
99 let version_file = paths::user::simulation_dir().join("version");
100 std::fs::read_to_string(version_file).ok()
101 }
102
103 pub async fn ensure_assets(&self) -> Result<PathBuf> {
107 if let Some(path) = self.current_assets_path() {
108 tracing::debug!("Simulation assets already installed at {:?}", path);
109 return Ok(path);
110 }
111
112 println!("📦 Simulation assets not found locally");
114 println!(" Downloading from GitHub releases...");
115
116 self.download_latest().await
117 }
118
119 fn build_client() -> Result<reqwest::Client> {
121 reqwest::Client::builder()
122 .user_agent("mecha10-cli")
123 .build()
124 .context("Failed to build HTTP client")
125 }
126
127 fn github_token() -> Option<String> {
134 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
136 return Some(token);
137 }
138 if let Ok(token) = std::env::var("GH_TOKEN") {
139 return Some(token);
140 }
141
142 Self::get_gh_cli_token()
144 }
145
146 fn get_gh_cli_token() -> Option<String> {
148 let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;
149
150 if output.status.success() {
151 let token = String::from_utf8(output.stdout).ok()?;
152 let token = token.trim();
153 if !token.is_empty() {
154 tracing::debug!("Using GitHub token from `gh auth token`");
155 return Some(token.to_string());
156 }
157 }
158
159 None
160 }
161
162 pub async fn download_latest(&self) -> Result<PathBuf> {
164 let client = Self::build_client()?;
166 let token = Self::github_token();
167
168 let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
169
170 println!(" Checking latest release...");
171
172 let mut request = client.get(&release_url);
173 if let Some(ref token) = token {
174 request = request.header("Authorization", format!("Bearer {}", token));
175 }
176
177 let response = request
178 .send()
179 .await
180 .context("Failed to fetch release info from GitHub")?;
181
182 if !response.status().is_success() {
183 let status = response.status();
184 let hint = if status.as_u16() == 404 && token.is_none() {
185 "\n\nHint: The repository may be private. Set GITHUB_TOKEN or GH_TOKEN environment variable."
186 } else {
187 ""
188 };
189 return Err(anyhow::anyhow!(
190 "Failed to get release info: HTTP {}.{}\n\
191 The simulation assets may not be published yet.\n\n\
192 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
193 export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
194 status,
195 hint
196 ));
197 }
198
199 let release: serde_json::Value = response.json().await?;
200
201 let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
202
203 let assets = release["assets"]
205 .as_array()
206 .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
207
208 let asset = assets
209 .iter()
210 .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
211 .ok_or_else(|| {
212 anyhow::anyhow!(
213 "Simulation asset '{}' not found in release {}.\n\n\
214 The release may not include simulation assets yet.\n\
215 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
216 ASSET_NAME,
217 tag_name
218 )
219 })?;
220
221 let download_url = if token.is_some() {
223 asset["url"]
225 .as_str()
226 .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
227 } else {
228 asset["browser_download_url"]
230 .as_str()
231 .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
232 };
233
234 let size = asset["size"].as_u64().unwrap_or(0);
235
236 println!(" Release: {}", tag_name);
237 println!(" Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
238
239 self.download_and_extract(download_url, &tag_name, size, token).await
241 }
242
243 async fn download_and_extract(
245 &self,
246 url: &str,
247 version: &str,
248 size: u64,
249 token: Option<String>,
250 ) -> Result<PathBuf> {
251 let client = Self::build_client()?;
252
253 let pb = ProgressBar::new(size);
255 pb.set_style(
256 ProgressStyle::default_bar()
257 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
258 .unwrap()
259 .progress_chars("#>-"),
260 );
261
262 println!(" Downloading...");
263
264 let mut request = client.get(url);
265
266 if let Some(ref token) = token {
268 request = request
269 .header("Authorization", format!("Bearer {}", token))
270 .header("Accept", "application/octet-stream");
271 }
272
273 let response = request.send().await.context("Failed to download simulation assets")?;
274
275 if !response.status().is_success() {
276 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
277 }
278
279 let temp_dir = tempfile::tempdir()?;
281 let temp_file = temp_dir.path().join("simulation.tar.gz");
282
283 let mut file = tokio::fs::File::create(&temp_file).await?;
284 let mut stream = response.bytes_stream();
285
286 use futures_util::StreamExt;
287 use tokio::io::AsyncWriteExt;
288
289 while let Some(chunk) = stream.next().await {
290 let chunk = chunk.context("Error downloading chunk")?;
291 file.write_all(&chunk).await?;
292 pb.inc(chunk.len() as u64);
293 }
294
295 file.flush().await?;
296 pb.finish_with_message("Download complete");
297
298 println!(" Extracting...");
300
301 let simulation_dir = paths::user::simulation_dir();
302 let version_dir = simulation_dir.join(version);
303
304 tokio::fs::create_dir_all(&version_dir).await?;
306
307 let tar_gz = std::fs::File::open(&temp_file)?;
309 let tar = flate2::read::GzDecoder::new(tar_gz);
310 let mut archive = tar::Archive::new(tar);
311 archive.unpack(&version_dir)?;
312
313 let current_link = simulation_dir.join("current");
315 if current_link.exists() {
316 tokio::fs::remove_file(¤t_link).await.ok();
317 }
318
319 #[cfg(unix)]
320 {
321 std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
322 }
323
324 #[cfg(windows)]
325 {
326 std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
328 }
329
330 let version_file = simulation_dir.join("version");
332 tokio::fs::write(&version_file, version).await?;
333
334 println!("✅ Simulation assets installed to {:?}", version_dir);
335
336 Ok(version_dir)
337 }
338
339 pub async fn remove(&self) -> Result<()> {
341 let simulation_dir = paths::user::simulation_dir();
342 if simulation_dir.exists() {
343 tokio::fs::remove_dir_all(&simulation_dir).await?;
344 println!("✅ Simulation assets removed");
345 } else {
346 println!("No simulation assets installed");
347 }
348 Ok(())
349 }
350
351 pub async fn update(&self) -> Result<PathBuf> {
353 self.remove().await.ok();
355 self.download_latest().await
356 }
357}
358
359impl Default for SimulationAssetsService {
360 fn default() -> Self {
361 Self::new()
362 }
363}