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).await
211 }
212
213 async fn download_and_extract(
215 &self,
216 url: &str,
217 version: &str,
218 size: u64,
219 token: Option<String>,
220 ) -> Result<PathBuf> {
221 let client = Self::build_client()?;
222
223 let pb = ProgressBar::new(size);
225 pb.set_style(
226 ProgressStyle::default_bar()
227 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
228 .unwrap()
229 .progress_chars("#>-"),
230 );
231
232 println!(" Downloading...");
233
234 let mut request = client.get(url);
235
236 if let Some(ref token) = token {
238 request = request
239 .header("Authorization", format!("Bearer {}", token))
240 .header("Accept", "application/octet-stream");
241 }
242
243 let response = request.send().await.context("Failed to download simulation assets")?;
244
245 if !response.status().is_success() {
246 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
247 }
248
249 let temp_dir = tempfile::tempdir()?;
251 let temp_file = temp_dir.path().join("simulation.tar.gz");
252
253 let mut file = tokio::fs::File::create(&temp_file).await?;
254 let mut stream = response.bytes_stream();
255
256 use futures_util::StreamExt;
257 use tokio::io::AsyncWriteExt;
258
259 while let Some(chunk) = stream.next().await {
260 let chunk = chunk.context("Error downloading chunk")?;
261 file.write_all(&chunk).await?;
262 pb.inc(chunk.len() as u64);
263 }
264
265 file.flush().await?;
266 pb.finish_with_message("Download complete");
267
268 println!(" Extracting...");
270
271 let simulation_dir = self.cache_dir.join("simulation");
272 let version_dir = simulation_dir.join(version);
273
274 tokio::fs::create_dir_all(&version_dir).await?;
276
277 let tar_gz = std::fs::File::open(&temp_file)?;
279 let tar = flate2::read::GzDecoder::new(tar_gz);
280 let mut archive = tar::Archive::new(tar);
281 archive.unpack(&version_dir)?;
282
283 let current_link = simulation_dir.join("current");
285 if current_link.exists() {
286 tokio::fs::remove_file(¤t_link).await.ok();
287 }
288
289 #[cfg(unix)]
290 {
291 std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
292 }
293
294 #[cfg(windows)]
295 {
296 std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
298 }
299
300 let version_file = simulation_dir.join("version");
302 tokio::fs::write(&version_file, version).await?;
303
304 println!("✅ Simulation assets installed to {:?}", version_dir);
305
306 Ok(version_dir)
307 }
308
309 pub async fn remove(&self) -> Result<()> {
311 let simulation_dir = self.cache_dir.join("simulation");
312 if simulation_dir.exists() {
313 tokio::fs::remove_dir_all(&simulation_dir).await?;
314 println!("✅ Simulation assets removed");
315 } else {
316 println!("No simulation assets installed");
317 }
318 Ok(())
319 }
320
321 pub async fn update(&self) -> Result<PathBuf> {
323 self.remove().await.ok();
325 self.download_latest().await
326 }
327}
328
329impl Default for SimulationAssetsService {
330 fn default() -> Self {
331 Self::new()
332 }
333}