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> {
132 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 Self::get_gh_cli_token()
142 }
143
144 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 pub async fn download_latest(&self) -> Result<PathBuf> {
162 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 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 let download_url = if token.is_some() {
221 asset["url"]
223 .as_str()
224 .ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
225 } else {
226 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 self.download_and_extract(download_url, &tag_name, size, token).await
239 }
240
241 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 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 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 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 println!(" Extracting...");
298
299 let simulation_dir = self.cache_dir.join("simulation");
300 let version_dir = simulation_dir.join(version);
301
302 tokio::fs::create_dir_all(&version_dir).await?;
304
305 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 let current_link = simulation_dir.join("current");
313 if current_link.exists() {
314 tokio::fs::remove_file(¤t_link).await.ok();
315 }
316
317 #[cfg(unix)]
318 {
319 std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
320 }
321
322 #[cfg(windows)]
323 {
324 std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
326 }
327
328 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 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 pub async fn update(&self) -> Result<PathBuf> {
351 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}