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 pub async fn download_latest(&self) -> Result<PathBuf> {
119 let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
121
122 let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
123
124 println!(" Checking latest release...");
125
126 let response = client
127 .get(&release_url)
128 .send()
129 .await
130 .context("Failed to fetch release info from GitHub")?;
131
132 if !response.status().is_success() {
133 return Err(anyhow::anyhow!(
134 "Failed to get release info: HTTP {}. \n\
135 The simulation assets may not be published yet.\n\n\
136 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
137 export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
138 response.status()
139 ));
140 }
141
142 let release: serde_json::Value = response.json().await?;
143
144 let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
145
146 let assets = release["assets"]
148 .as_array()
149 .ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
150
151 let asset = assets
152 .iter()
153 .find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
154 .ok_or_else(|| {
155 anyhow::anyhow!(
156 "Simulation asset '{}' not found in release {}.\n\n\
157 The release may not include simulation assets yet.\n\
158 Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
159 ASSET_NAME,
160 tag_name
161 )
162 })?;
163
164 let download_url = asset["browser_download_url"]
165 .as_str()
166 .ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?;
167
168 let size = asset["size"].as_u64().unwrap_or(0);
169
170 println!(" Release: {}", tag_name);
171 println!(" Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
172
173 self.download_and_extract(download_url, &tag_name, size).await
175 }
176
177 async fn download_and_extract(&self, url: &str, version: &str, size: u64) -> Result<PathBuf> {
179 let client = reqwest::Client::builder().user_agent("mecha10-cli").build()?;
180
181 let pb = ProgressBar::new(size);
183 pb.set_style(
184 ProgressStyle::default_bar()
185 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
186 .unwrap()
187 .progress_chars("#>-"),
188 );
189
190 println!(" Downloading...");
191
192 let response = client
193 .get(url)
194 .send()
195 .await
196 .context("Failed to download simulation assets")?;
197
198 if !response.status().is_success() {
199 return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
200 }
201
202 let temp_dir = tempfile::tempdir()?;
204 let temp_file = temp_dir.path().join("simulation.tar.gz");
205
206 let mut file = tokio::fs::File::create(&temp_file).await?;
207 let mut stream = response.bytes_stream();
208
209 use futures_util::StreamExt;
210 use tokio::io::AsyncWriteExt;
211
212 while let Some(chunk) = stream.next().await {
213 let chunk = chunk.context("Error downloading chunk")?;
214 file.write_all(&chunk).await?;
215 pb.inc(chunk.len() as u64);
216 }
217
218 file.flush().await?;
219 pb.finish_with_message("Download complete");
220
221 println!(" Extracting...");
223
224 let simulation_dir = self.cache_dir.join("simulation");
225 let version_dir = simulation_dir.join(version);
226
227 tokio::fs::create_dir_all(&version_dir).await?;
229
230 let tar_gz = std::fs::File::open(&temp_file)?;
232 let tar = flate2::read::GzDecoder::new(tar_gz);
233 let mut archive = tar::Archive::new(tar);
234 archive.unpack(&version_dir)?;
235
236 let current_link = simulation_dir.join("current");
238 if current_link.exists() {
239 tokio::fs::remove_file(¤t_link).await.ok();
240 }
241
242 #[cfg(unix)]
243 {
244 std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
245 }
246
247 #[cfg(windows)]
248 {
249 std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
251 }
252
253 let version_file = simulation_dir.join("version");
255 tokio::fs::write(&version_file, version).await?;
256
257 println!("✅ Simulation assets installed to {:?}", version_dir);
258
259 Ok(version_dir)
260 }
261
262 pub async fn remove(&self) -> Result<()> {
264 let simulation_dir = self.cache_dir.join("simulation");
265 if simulation_dir.exists() {
266 tokio::fs::remove_dir_all(&simulation_dir).await?;
267 println!("✅ Simulation assets removed");
268 } else {
269 println!("No simulation assets installed");
270 }
271 Ok(())
272 }
273
274 pub async fn update(&self) -> Result<PathBuf> {
276 self.remove().await.ok();
278 self.download_latest().await
279 }
280}
281
282impl Default for SimulationAssetsService {
283 fn default() -> Self {
284 Self::new()
285 }
286}