1#![allow(dead_code)]
35
36use crate::paths;
37use anyhow::{Context, Result};
38use indicatif::{ProgressBar, ProgressStyle};
39use mecha10_cli_core::utils::build_client;
40use std::path::PathBuf;
41
42const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
44
45pub struct BinaryManager {
64 cache_dir: PathBuf,
66 version: String,
68 target: String,
70}
71
72impl BinaryManager {
73 pub fn new() -> Result<Self> {
75 let cache_dir = paths::user::bin_dir();
76 let version = FRAMEWORK_VERSION.to_string();
77 let target = mecha10_binary_naming::detect_target()?;
78
79 Ok(Self {
80 cache_dir,
81 version,
82 target,
83 })
84 }
85
86 #[allow(dead_code)]
88 pub fn with_version(version: String) -> Result<Self> {
89 let cache_dir = paths::user::bin_dir();
90 let target = mecha10_binary_naming::detect_target()?;
91
92 Ok(Self {
93 cache_dir,
94 version,
95 target,
96 })
97 }
98
99 pub fn is_prebuilt_available(&self) -> bool {
101 mecha10_binary_naming::is_prebuilt_available(&self.target)
102 }
103
104 #[allow(dead_code)]
106 pub fn cache_dir(&self) -> &PathBuf {
107 &self.cache_dir
108 }
109
110 #[allow(dead_code)]
112 pub fn version(&self) -> &str {
113 &self.version
114 }
115
116 #[allow(dead_code)]
118 pub fn target(&self) -> &str {
119 &self.target
120 }
121
122 pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
137 self.resolve_with_version(node_name, None).await
138 }
139
140 pub async fn resolve_with_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
152 if let Some(v) = version {
153 if let Some(path) = self.find_cached_version(node_name, v) {
154 tracing::debug!("Using cached binary: {}", path.display());
155 return Ok(path);
156 }
157 } else if let Some(cached) = self.find_cached(node_name) {
158 tracing::debug!("Using cached binary: {}", cached.display());
159 return Ok(cached);
160 }
161
162 if self.is_prebuilt_available() {
163 let result = if let Some(v) = version {
164 self.download_version(node_name, v).await
165 } else {
166 self.download(node_name).await
167 };
168
169 match result {
170 Ok(path) => {
171 tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
172 return Ok(path);
173 }
174 Err(e) => {
175 tracing::warn!("Failed to download binary for {}: {}", node_name, e);
176 if self.target.ends_with("-apple-darwin") {
177 println!("⚠️ macOS binaries for '{}' v{} not found.", node_name, self.version);
178 println!(" Compiling from source instead (this may take a few minutes)...");
179 }
180 }
181 }
182 } else {
183 tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
184 }
185
186 self.cargo_install(node_name).await
187 }
188
189 fn find_cached_version(&self, node_name: &str, version: &str) -> Option<PathBuf> {
191 mecha10_binary_naming::find_cached_version(&self.cache_dir, node_name, version, &self.target)
192 }
193
194 fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
199 mecha10_binary_naming::find_cached(&self.cache_dir, node_name, &self.target)
200 }
201
202 fn binary_name_with_version(&self, node_name: &str, version: &str) -> String {
204 mecha10_binary_naming::binary_name(node_name, version, &self.target)
205 }
206
207 fn binary_name(&self, node_name: &str) -> String {
209 self.binary_name_with_version(node_name, &self.version)
210 }
211
212 fn tarball_name_with_version(&self, node_name: &str, version: &str) -> String {
214 mecha10_binary_naming::tarball_name(node_name, version, &self.target)
215 }
216
217 #[allow(dead_code)]
219 fn tarball_name(&self, node_name: &str) -> String {
220 self.tarball_name_with_version(node_name, &self.version)
221 }
222
223 async fn download(&self, node_name: &str) -> Result<PathBuf> {
225 self.download_version(node_name, &self.version.clone()).await
226 }
227
228 async fn download_version(&self, node_name: &str, version: &str) -> Result<PathBuf> {
230 let tarball = self.tarball_name_with_version(node_name, version);
231 let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
232 let url = format!(
233 "{}/downloads/node/{}?os={}&arch={}&version={}",
234 paths::urls::mecha10_api_base_url(),
235 node_name,
236 os,
237 arch,
238 version
239 );
240
241 println!("📦 Downloading {} v{}...", node_name, version);
242 tracing::info!("Downloading binary from mecha10-api: {}", url);
243
244 let client = build_client()?;
245 let response = client
246 .get(&url)
247 .send()
248 .await
249 .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
250
251 if !response.status().is_success() {
252 return Err(anyhow::anyhow!(
253 "Binary '{}' not found: HTTP {}\n\
254 URL: {}\n\
255 Falling back to cargo install...",
256 tarball,
257 response.status(),
258 url
259 ));
260 }
261
262 let size = response.content_length().unwrap_or(0);
263 self.download_and_extract_versioned(node_name, version, response, size)
264 .await
265 }
266
267 async fn download_and_extract_versioned(
269 &self,
270 node_name: &str,
271 version: &str,
272 response: reqwest::Response,
273 size: u64,
274 ) -> Result<PathBuf> {
275 let pb = ProgressBar::new(size);
276 pb.set_style(
277 ProgressStyle::default_bar()
278 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
279 .unwrap()
280 .progress_chars("#>-"),
281 );
282
283 let temp_dir = tempfile::tempdir()?;
284 let temp_file = temp_dir.path().join("binary.tar.gz");
285
286 let mut file = tokio::fs::File::create(&temp_file).await?;
287 let mut stream = response.bytes_stream();
288
289 use futures_util::StreamExt;
290 use tokio::io::AsyncWriteExt;
291
292 while let Some(chunk) = stream.next().await {
293 let chunk = chunk.context("Error downloading chunk")?;
294 file.write_all(&chunk).await?;
295 pb.inc(chunk.len() as u64);
296 }
297
298 file.flush().await?;
299 pb.finish_with_message("Download complete");
300
301 tokio::fs::create_dir_all(&self.cache_dir).await?;
302
303 let tar_gz = std::fs::File::open(&temp_file)?;
304 let tar = flate2::read::GzDecoder::new(tar_gz);
305 let mut archive = tar::Archive::new(tar);
306
307 let extract_dir = temp_dir.path().join("extract");
308 std::fs::create_dir_all(&extract_dir)?;
309 archive.unpack(&extract_dir)?;
310
311 let extracted_binary = extract_dir.join(node_name);
312 if !extracted_binary.exists() {
313 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
314 }
315
316 let binary_name = self.binary_name_with_version(node_name, version);
317 let dest_path = self.cache_dir.join(&binary_name);
318
319 tokio::fs::copy(&extracted_binary, &dest_path).await?;
320
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
325 perms.set_mode(0o755);
326 tokio::fs::set_permissions(&dest_path, perms).await?;
327 }
328
329 let symlink_path = self.cache_dir.join(node_name);
330 if symlink_path.exists() || symlink_path.is_symlink() {
331 tokio::fs::remove_file(&symlink_path).await.ok();
332 }
333
334 #[cfg(unix)]
335 {
336 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
337 }
338
339 #[cfg(windows)]
340 {
341 std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
342 }
343
344 println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
345
346 Ok(dest_path)
347 }
348
349 async fn cargo_install(&self, node_name: &str) -> Result<PathBuf> {
351 println!(
352 "⚠️ Pre-built binary not available for {} on {}",
353 node_name, self.target
354 );
355 println!("🔨 Compiling via cargo install...");
356
357 let crate_name = format!("mecha10-nodes-{}", node_name);
358
359 let output = tokio::process::Command::new("cargo")
360 .args([
361 "install",
362 &crate_name,
363 "--version",
364 &self.version,
365 "--root",
366 &self.cache_dir.to_string_lossy(),
367 ])
368 .output()
369 .await
370 .context("Failed to run cargo install")?;
371
372 if !output.status.success() {
373 let stderr = String::from_utf8_lossy(&output.stderr);
374 return Err(anyhow::anyhow!("cargo install failed for {}: {}", crate_name, stderr));
375 }
376
377 let binary_path = self.cache_dir.join("bin").join(node_name);
378 if !binary_path.exists() {
379 return Err(anyhow::anyhow!(
380 "Binary not found after cargo install: {}",
381 binary_path.display()
382 ));
383 }
384
385 println!("✅ Compiled {} via cargo install", node_name);
386
387 Ok(binary_path)
388 }
389
390 pub async fn remove(&self, node_name: &str) -> Result<()> {
392 let binary_name = self.binary_name(node_name);
393 let binary_path = self.cache_dir.join(&binary_name);
394 let symlink_path = self.cache_dir.join(node_name);
395
396 if binary_path.exists() {
397 tokio::fs::remove_file(&binary_path).await?;
398 println!("Removed {}", binary_path.display());
399 }
400
401 if symlink_path.exists() || symlink_path.is_symlink() {
402 tokio::fs::remove_file(&symlink_path).await?;
403 println!("Removed symlink {}", symlink_path.display());
404 }
405
406 Ok(())
407 }
408
409 pub async fn clear_cache(&self) -> Result<()> {
411 if self.cache_dir.exists() {
412 tokio::fs::remove_dir_all(&self.cache_dir).await?;
413 println!("Cleared binary cache at {}", self.cache_dir.display());
414 }
415 Ok(())
416 }
417}
418
419impl Default for BinaryManager {
420 fn default() -> Self {
421 Self::new().expect("Failed to create BinaryManager")
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn test_binary_name() {
431 let manager = BinaryManager {
432 cache_dir: PathBuf::from("/tmp"),
433 version: "0.1.44".to_string(),
434 target: "aarch64-apple-darwin".to_string(),
435 };
436
437 assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
438 }
439
440 #[test]
441 fn test_tarball_name() {
442 let manager = BinaryManager {
443 cache_dir: PathBuf::from("/tmp"),
444 version: "0.1.44".to_string(),
445 target: "x86_64-apple-darwin".to_string(),
446 };
447
448 assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
449 }
450
451 #[test]
452 fn test_prebuilt_available() {
453 let manager = BinaryManager {
454 cache_dir: PathBuf::from("/tmp"),
455 version: "0.1.44".to_string(),
456 target: "aarch64-apple-darwin".to_string(),
457 };
458 assert!(manager.is_prebuilt_available());
459
460 let manager_arm_linux = BinaryManager {
461 cache_dir: PathBuf::from("/tmp"),
462 version: "0.1.44".to_string(),
463 target: "aarch64-unknown-linux-gnu".to_string(),
464 };
465 assert!(manager_arm_linux.is_prebuilt_available());
466
467 let manager_unsupported = BinaryManager {
468 cache_dir: PathBuf::from("/tmp"),
469 version: "0.1.44".to_string(),
470 target: "aarch64-unknown-linux-musl".to_string(),
471 };
472 assert!(!manager_unsupported.is_prebuilt_available());
473 }
474}