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