1#![allow(dead_code)]
38
39use crate::paths;
40use anyhow::{Context, Result};
41use indicatif::{ProgressBar, ProgressStyle};
42use mecha10_cli_core::utils::build_client;
43use std::path::PathBuf;
44
45const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
46
47pub struct BinaryManager {
48 cache_dir: PathBuf,
49 version: String,
50 target: String,
51}
52
53impl BinaryManager {
54 pub fn new() -> Result<Self> {
56 let cache_dir = paths::user::bin_dir();
57 let version = FRAMEWORK_VERSION.to_string();
58 let target = mecha10_binary_naming::detect_target()?;
59
60 Ok(Self {
61 cache_dir,
62 version,
63 target,
64 })
65 }
66
67 #[allow(dead_code)]
69 pub fn with_version(version: String) -> Result<Self> {
70 let cache_dir = paths::user::bin_dir();
71 let target = mecha10_binary_naming::detect_target()?;
72
73 Ok(Self {
74 cache_dir,
75 version,
76 target,
77 })
78 }
79
80 pub fn is_prebuilt_available(&self) -> bool {
82 mecha10_binary_naming::is_prebuilt_available(&self.target)
83 }
84
85 #[allow(dead_code)]
87 pub fn cache_dir(&self) -> &PathBuf {
88 &self.cache_dir
89 }
90
91 #[allow(dead_code)]
93 pub fn version(&self) -> &str {
94 &self.version
95 }
96
97 #[allow(dead_code)]
99 pub fn target(&self) -> &str {
100 &self.target
101 }
102
103 pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
121 self.resolve_with_version(node_name, None).await
122 }
123
124 pub async fn resolve_with_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
147 mecha10_binary_naming::try_resolution_tiers!(node_name, {
148 "cached binary" => match version {
149 Some(v) => self.find_cached_version(node_name, v),
150 None => self.find_cached(node_name),
151 }
152 .inspect(|path| {
153 tracing::debug!("Using cached binary: {}", path.display());
154 })
155 .ok_or_else(|| "no cached binary found for this version/target".to_string()),
156
157 "mecha10-api download" => {
158 if !self.is_prebuilt_available() {
159 Err(format!(
160 "No pre-built binary available for '{}' on platform '{}' (supported targets: {}). \
161 Binaries are resolved solely via mecha10-api downloads ({}); there is no \
162 cargo-install or build-from-source fallback.",
163 node_name,
164 self.target,
165 mecha10_binary_naming::SUPPORTED_TARGETS.join(", "),
166 paths::urls::mecha10_api_base_url(),
167 ))
168 } else {
169 let result = if let Some(v) = version {
170 self.download_version(node_name, Some(v)).await
171 } else {
172 self.download(node_name).await
173 };
174
175 result
176 .inspect(|path| {
177 tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
178 })
179 .map_err(|e| {
180 format!(
181 "Failed to resolve binary for '{}' via mecha10-api download: {:#}",
182 node_name, e
183 )
184 })
185 }
186 },
187 })
188 .map_err(|e| anyhow::anyhow!(e))
189 }
190
191 fn find_cached_version(&self, node_name: &str, version: &str) -> Option<PathBuf> {
193 mecha10_binary_naming::find_cached_version(&self.cache_dir, node_name, version, &self.target)
194 }
195
196 fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
201 mecha10_binary_naming::find_cached(&self.cache_dir, node_name, &self.target)
202 }
203
204 fn binary_name_with_version(&self, node_name: &str, version: &str) -> String {
206 mecha10_binary_naming::binary_name(node_name, version, &self.target)
207 }
208
209 fn binary_name(&self, node_name: &str) -> String {
211 self.binary_name_with_version(node_name, &self.version)
212 }
213
214 fn tarball_name_with_version(&self, node_name: &str, version: &str) -> String {
216 mecha10_binary_naming::tarball_name(node_name, version, &self.target)
217 }
218
219 #[allow(dead_code)]
221 fn tarball_name(&self, node_name: &str) -> String {
222 self.tarball_name_with_version(node_name, &self.version)
223 }
224
225 async fn download(&self, node_name: &str) -> Result<PathBuf> {
241 self.download_version(node_name, None).await
242 }
243
244 async fn download_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
252 let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
253 let mut url = format!(
254 "{}/downloads/node/{}?os={}&arch={}",
255 paths::urls::mecha10_api_base_url(),
256 node_name,
257 os,
258 arch
259 );
260 if let Some(v) = version {
261 url.push_str(&format!("&version={}", v));
262 }
263
264 println!(
265 "📦 Downloading {} {}...",
266 node_name,
267 version.map(|v| format!("v{v}")).unwrap_or_else(|| "latest".to_string())
268 );
269 tracing::info!("Downloading binary from mecha10-api: {}", url);
270
271 let client = build_client()?;
272 let response = client
273 .get(&url)
274 .send()
275 .await
276 .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
277
278 if !response.status().is_success() {
279 return Err(anyhow::anyhow!(
280 "Binary '{}' not found: HTTP {}\n\
281 URL: {}",
282 node_name,
283 response.status(),
284 url
285 ));
286 }
287
288 let resolved_version = version
289 .map(|v| v.to_string())
290 .or_else(|| self.parse_resolved_version(node_name, &response))
291 .ok_or_else(|| {
292 anyhow::anyhow!(
293 "mecha10-api did not report which version it resolved for '{}' \
294 (missing/unparseable Content-Disposition header)",
295 node_name
296 )
297 })?;
298
299 let size = response.content_length().unwrap_or(0);
300 self.download_and_extract_versioned(node_name, &resolved_version, response, size)
301 .await
302 }
303
304 fn parse_resolved_version(&self, node_name: &str, response: &reqwest::Response) -> Option<String> {
309 let header = response
310 .headers()
311 .get(reqwest::header::CONTENT_DISPOSITION)?
312 .to_str()
313 .ok()?;
314 let filename = header.split("filename=\"").nth(1)?.strip_suffix('"')?;
315 filename
316 .strip_prefix(&format!("{node_name}-"))?
317 .strip_suffix(&format!("-{}.tar.gz", self.target))
318 .map(str::to_string)
319 }
320
321 async fn download_and_extract_versioned(
323 &self,
324 node_name: &str,
325 version: &str,
326 response: reqwest::Response,
327 size: u64,
328 ) -> Result<PathBuf> {
329 let pb = ProgressBar::new(size);
330 pb.set_style(
331 ProgressStyle::default_bar()
332 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
333 .unwrap()
334 .progress_chars("#>-"),
335 );
336
337 let temp_dir = tempfile::tempdir()?;
338 let temp_file = temp_dir.path().join("binary.tar.gz");
339
340 let mut file = tokio::fs::File::create(&temp_file).await?;
341 let mut stream = response.bytes_stream();
342
343 use futures_util::StreamExt;
344 use tokio::io::AsyncWriteExt;
345
346 while let Some(chunk) = stream.next().await {
347 let chunk = chunk.context("Error downloading chunk")?;
348 file.write_all(&chunk).await?;
349 pb.inc(chunk.len() as u64);
350 }
351
352 file.flush().await?;
353 pb.finish_with_message("Download complete");
354
355 tokio::fs::create_dir_all(&self.cache_dir).await?;
356
357 let tar_gz = std::fs::File::open(&temp_file)?;
358 let tar = flate2::read::GzDecoder::new(tar_gz);
359 let mut archive = tar::Archive::new(tar);
360
361 let extract_dir = temp_dir.path().join("extract");
362 std::fs::create_dir_all(&extract_dir)?;
363 archive.unpack(&extract_dir)?;
364
365 let extracted_binary = extract_dir.join(node_name);
366 if !extracted_binary.exists() {
367 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
368 }
369
370 let binary_name = self.binary_name_with_version(node_name, version);
371 let dest_path = self.cache_dir.join(&binary_name);
372
373 tokio::fs::copy(&extracted_binary, &dest_path).await?;
374
375 #[cfg(unix)]
376 {
377 use std::os::unix::fs::PermissionsExt;
378 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
379 perms.set_mode(0o755);
380 tokio::fs::set_permissions(&dest_path, perms).await?;
381 }
382
383 let symlink_path = self.cache_dir.join(node_name);
384 if symlink_path.exists() || symlink_path.is_symlink() {
385 tokio::fs::remove_file(&symlink_path).await.ok();
386 }
387
388 #[cfg(unix)]
389 {
390 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
391 }
392
393 #[cfg(windows)]
394 {
395 std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
396 }
397
398 println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
399
400 Ok(dest_path)
401 }
402
403 pub async fn remove(&self, node_name: &str) -> Result<()> {
405 let binary_name = self.binary_name(node_name);
406 let binary_path = self.cache_dir.join(&binary_name);
407 let symlink_path = self.cache_dir.join(node_name);
408
409 if binary_path.exists() {
410 tokio::fs::remove_file(&binary_path).await?;
411 println!("Removed {}", binary_path.display());
412 }
413
414 if symlink_path.exists() || symlink_path.is_symlink() {
415 tokio::fs::remove_file(&symlink_path).await?;
416 println!("Removed symlink {}", symlink_path.display());
417 }
418
419 Ok(())
420 }
421
422 pub async fn clear_cache(&self) -> Result<()> {
424 if self.cache_dir.exists() {
425 tokio::fs::remove_dir_all(&self.cache_dir).await?;
426 println!("Cleared binary cache at {}", self.cache_dir.display());
427 }
428 Ok(())
429 }
430}
431
432impl Default for BinaryManager {
433 fn default() -> Self {
434 Self::new().expect("Failed to create BinaryManager")
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn test_binary_name() {
444 let manager = BinaryManager {
445 cache_dir: PathBuf::from("/tmp"),
446 version: "0.1.44".to_string(),
447 target: "aarch64-apple-darwin".to_string(),
448 };
449
450 assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
451 }
452
453 #[test]
454 fn test_tarball_name() {
455 let manager = BinaryManager {
456 cache_dir: PathBuf::from("/tmp"),
457 version: "0.1.44".to_string(),
458 target: "x86_64-apple-darwin".to_string(),
459 };
460
461 assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
462 }
463
464 #[tokio::test]
465 async fn resolve_errors_immediately_on_unsupported_platform_with_no_cargo_install_fallback() {
466 let manager = BinaryManager {
467 cache_dir: PathBuf::from("/tmp/mecha10-binary-manager-test-does-not-exist"),
468 version: "0.1.44".to_string(),
469 target: "aarch64-unknown-linux-musl".to_string(),
470 };
471
472 let err = manager
473 .resolve("speaker")
474 .await
475 .expect_err("unsupported platform must error, not silently fall back");
476
477 let message = err.to_string();
478 assert!(
479 message.contains("No pre-built binary available"),
480 "error should name the missing source, got: {message}"
481 );
482 assert!(
483 message.contains("no cargo-install or build-from-source fallback"),
484 "error must make clear there is no fallback path, got: {message}"
485 );
486 }
487
488 #[test]
489 fn test_prebuilt_available() {
490 let manager = BinaryManager {
491 cache_dir: PathBuf::from("/tmp"),
492 version: "0.1.44".to_string(),
493 target: "aarch64-apple-darwin".to_string(),
494 };
495 assert!(manager.is_prebuilt_available());
496
497 let manager_arm_linux = BinaryManager {
498 cache_dir: PathBuf::from("/tmp"),
499 version: "0.1.44".to_string(),
500 target: "aarch64-unknown-linux-gnu".to_string(),
501 };
502 assert!(manager_arm_linux.is_prebuilt_available());
503
504 let manager_unsupported = BinaryManager {
505 cache_dir: PathBuf::from("/tmp"),
506 version: "0.1.44".to_string(),
507 target: "aarch64-unknown-linux-musl".to_string(),
508 };
509 assert!(!manager_unsupported.is_prebuilt_available());
510 }
511}