#![allow(dead_code)]
use crate::paths;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::PathBuf;
const FRAMEWORK_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct BinaryManager {
cache_dir: PathBuf,
version: String,
target: String,
}
impl BinaryManager {
pub fn new() -> Result<Self> {
let cache_dir = paths::user::bin_dir();
let version = FRAMEWORK_VERSION.to_string();
let target = mecha10_binary_naming::detect_target()?;
Ok(Self {
cache_dir,
version,
target,
})
}
#[allow(dead_code)]
pub fn with_version(version: String) -> Result<Self> {
let cache_dir = paths::user::bin_dir();
let target = mecha10_binary_naming::detect_target()?;
Ok(Self {
cache_dir,
version,
target,
})
}
pub fn is_prebuilt_available(&self) -> bool {
mecha10_binary_naming::is_prebuilt_available(&self.target)
}
#[allow(dead_code)]
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
#[allow(dead_code)]
pub fn version(&self) -> &str {
&self.version
}
#[allow(dead_code)]
pub fn target(&self) -> &str {
&self.target
}
pub async fn resolve(&self, node_name: &str) -> Result<PathBuf> {
self.resolve_with_version(node_name, None).await
}
pub async fn resolve_with_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
if let Some(v) = version {
if let Some(path) = self.find_cached_version(node_name, v) {
tracing::debug!("Using cached binary: {}", path.display());
return Ok(path);
}
} else if let Some(cached) = self.find_cached(node_name) {
tracing::debug!("Using cached binary: {}", cached.display());
return Ok(cached);
}
if self.is_prebuilt_available() {
let result = if let Some(v) = version {
self.download_version(node_name, v).await
} else {
self.download(node_name).await
};
match result {
Ok(path) => {
tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
return Ok(path);
}
Err(e) => {
tracing::warn!("Failed to download binary for {}: {}", node_name, e);
if self.target.ends_with("-apple-darwin") {
println!("⚠️ macOS binaries for '{}' v{} not found.", node_name, self.version);
println!(" Compiling from source instead (this may take a few minutes)...");
}
}
}
} else {
tracing::info!("Pre-built binary not available for {} on {}", node_name, self.target);
}
self.cargo_install(node_name).await
}
fn find_cached_version(&self, node_name: &str, version: &str) -> Option<PathBuf> {
mecha10_binary_naming::find_cached_version(&self.cache_dir, node_name, version, &self.target)
}
fn find_cached(&self, node_name: &str) -> Option<PathBuf> {
mecha10_binary_naming::find_cached(&self.cache_dir, node_name, &self.target)
}
fn binary_name_with_version(&self, node_name: &str, version: &str) -> String {
mecha10_binary_naming::binary_name(node_name, version, &self.target)
}
fn binary_name(&self, node_name: &str) -> String {
self.binary_name_with_version(node_name, &self.version)
}
fn tarball_name_with_version(&self, node_name: &str, version: &str) -> String {
mecha10_binary_naming::tarball_name(node_name, version, &self.target)
}
#[allow(dead_code)]
fn tarball_name(&self, node_name: &str) -> String {
self.tarball_name_with_version(node_name, &self.version)
}
async fn download(&self, node_name: &str) -> Result<PathBuf> {
self.download_version(node_name, &self.version.clone()).await
}
async fn download_version(&self, node_name: &str, version: &str) -> Result<PathBuf> {
let tarball = self.tarball_name_with_version(node_name, version);
let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
let url = format!(
"{}/downloads/node/{}?os={}&arch={}&version={}",
paths::urls::mecha10_api_base_url(),
node_name,
os,
arch,
version
);
println!("📦 Downloading {} v{}...", node_name, version);
tracing::info!("Downloading binary from mecha10-api: {}", url);
let client = Self::build_client()?;
let response = client
.get(&url)
.send()
.await
.context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Binary '{}' not found: HTTP {}\n\
URL: {}\n\
Falling back to cargo install...",
tarball,
response.status(),
url
));
}
let size = response.content_length().unwrap_or(0);
self.download_and_extract_versioned(node_name, version, response, size)
.await
}
async fn download_and_extract_versioned(
&self,
node_name: &str,
version: &str,
response: reqwest::Response,
size: u64,
) -> Result<PathBuf> {
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let temp_dir = tempfile::tempdir()?;
let temp_file = temp_dir.path().join("binary.tar.gz");
let mut file = tokio::fs::File::create(&temp_file).await?;
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Error downloading chunk")?;
file.write_all(&chunk).await?;
pb.inc(chunk.len() as u64);
}
file.flush().await?;
pb.finish_with_message("Download complete");
tokio::fs::create_dir_all(&self.cache_dir).await?;
let tar_gz = std::fs::File::open(&temp_file)?;
let tar = flate2::read::GzDecoder::new(tar_gz);
let mut archive = tar::Archive::new(tar);
let extract_dir = temp_dir.path().join("extract");
std::fs::create_dir_all(&extract_dir)?;
archive.unpack(&extract_dir)?;
let extracted_binary = extract_dir.join(node_name);
if !extracted_binary.exists() {
return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
}
let binary_name = self.binary_name_with_version(node_name, version);
let dest_path = self.cache_dir.join(&binary_name);
tokio::fs::copy(&extracted_binary, &dest_path).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&dest_path, perms).await?;
}
let symlink_path = self.cache_dir.join(node_name);
if symlink_path.exists() || symlink_path.is_symlink() {
tokio::fs::remove_file(&symlink_path).await.ok();
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
}
println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
Ok(dest_path)
}
async fn cargo_install(&self, node_name: &str) -> Result<PathBuf> {
println!(
"⚠️ Pre-built binary not available for {} on {}",
node_name, self.target
);
println!("🔨 Compiling via cargo install...");
let crate_name = format!("mecha10-nodes-{}", node_name);
let output = tokio::process::Command::new("cargo")
.args([
"install",
&crate_name,
"--version",
&self.version,
"--root",
&self.cache_dir.to_string_lossy(),
])
.output()
.await
.context("Failed to run cargo install")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("cargo install failed for {}: {}", crate_name, stderr));
}
let binary_path = self.cache_dir.join("bin").join(node_name);
if !binary_path.exists() {
return Err(anyhow::anyhow!(
"Binary not found after cargo install: {}",
binary_path.display()
));
}
println!("✅ Compiled {} via cargo install", node_name);
Ok(binary_path)
}
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("mecha10-cli")
.build()
.context("Failed to build HTTP client")
}
pub async fn remove(&self, node_name: &str) -> Result<()> {
let binary_name = self.binary_name(node_name);
let binary_path = self.cache_dir.join(&binary_name);
let symlink_path = self.cache_dir.join(node_name);
if binary_path.exists() {
tokio::fs::remove_file(&binary_path).await?;
println!("Removed {}", binary_path.display());
}
if symlink_path.exists() || symlink_path.is_symlink() {
tokio::fs::remove_file(&symlink_path).await?;
println!("Removed symlink {}", symlink_path.display());
}
Ok(())
}
pub async fn clear_cache(&self) -> Result<()> {
if self.cache_dir.exists() {
tokio::fs::remove_dir_all(&self.cache_dir).await?;
println!("Cleared binary cache at {}", self.cache_dir.display());
}
Ok(())
}
}
impl Default for BinaryManager {
fn default() -> Self {
Self::new().expect("Failed to create BinaryManager")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_binary_name() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-apple-darwin".to_string(),
};
assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
}
#[test]
fn test_tarball_name() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "x86_64-apple-darwin".to_string(),
};
assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
}
#[test]
fn test_prebuilt_available() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-apple-darwin".to_string(),
};
assert!(manager.is_prebuilt_available());
let manager_arm_linux = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-unknown-linux-gnu".to_string(),
};
assert!(manager_arm_linux.is_prebuilt_available());
let manager_unsupported = BinaryManager {
cache_dir: PathBuf::from("/tmp"),
version: "0.1.44".to_string(),
target: "aarch64-unknown-linux-musl".to_string(),
};
assert!(!manager_unsupported.is_prebuilt_available());
}
}