#![allow(dead_code)]
use crate::paths;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use mecha10_cli_core::utils::build_client;
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> {
mecha10_binary_naming::try_resolution_tiers!(node_name, {
"cached binary" => match version {
Some(v) => self.find_cached_version(node_name, v),
None => self.find_cached(node_name),
}
.inspect(|path| {
tracing::debug!("Using cached binary: {}", path.display());
})
.ok_or_else(|| "no cached binary found for this version/target".to_string()),
"mecha10-api download" => {
if !self.is_prebuilt_available() {
Err(format!(
"No pre-built binary available for '{}' on platform '{}' (supported targets: {}). \
Binaries are resolved solely via mecha10-api downloads ({}); there is no \
cargo-install or build-from-source fallback.",
node_name,
self.target,
mecha10_binary_naming::SUPPORTED_TARGETS.join(", "),
paths::urls::mecha10_api_base_url(),
))
} else {
let result = if let Some(v) = version {
self.download_version(node_name, Some(v)).await
} else {
self.download(node_name).await
};
result
.inspect(|path| {
tracing::info!("Downloaded binary for {}: {}", node_name, path.display());
})
.map_err(|e| {
format!(
"Failed to resolve binary for '{}' via mecha10-api download: {:#}",
node_name, e
)
})
}
},
})
.map_err(|e| anyhow::anyhow!(e))
}
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, None).await
}
async fn download_version(&self, node_name: &str, version: Option<&str>) -> Result<PathBuf> {
let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
let mut url = format!(
"{}/downloads/node/{}?os={}&arch={}",
paths::urls::mecha10_api_base_url(),
node_name,
os,
arch
);
if let Some(v) = version {
url.push_str(&format!("&version={}", v));
}
println!(
"📦 Downloading {} {}...",
node_name,
version.map(|v| format!("v{v}")).unwrap_or_else(|| "latest".to_string())
);
tracing::info!("Downloading binary from mecha10-api: {}", url);
let client = 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: {}",
node_name,
response.status(),
url
));
}
let resolved_version = version
.map(|v| v.to_string())
.or_else(|| self.parse_resolved_version(node_name, &response))
.ok_or_else(|| {
anyhow::anyhow!(
"mecha10-api did not report which version it resolved for '{}' \
(missing/unparseable Content-Disposition header)",
node_name
)
})?;
let size = response.content_length().unwrap_or(0);
self.download_and_extract_versioned(node_name, &resolved_version, response, size)
.await
}
fn parse_resolved_version(&self, node_name: &str, response: &reqwest::Response) -> Option<String> {
let header = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)?
.to_str()
.ok()?;
let filename = header.split("filename=\"").nth(1)?.strip_suffix('"')?;
filename
.strip_prefix(&format!("{node_name}-"))?
.strip_suffix(&format!("-{}.tar.gz", self.target))
.map(str::to_string)
}
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)
}
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");
}
#[tokio::test]
async fn resolve_errors_immediately_on_unsupported_platform_with_no_cargo_install_fallback() {
let manager = BinaryManager {
cache_dir: PathBuf::from("/tmp/mecha10-binary-manager-test-does-not-exist"),
version: "0.1.44".to_string(),
target: "aarch64-unknown-linux-musl".to_string(),
};
let err = manager
.resolve("speaker")
.await
.expect_err("unsupported platform must error, not silently fall back");
let message = err.to_string();
assert!(
message.contains("No pre-built binary available"),
"error should name the missing source, got: {message}"
);
assert!(
message.contains("no cargo-install or build-from-source fallback"),
"error must make clear there is no fallback path, got: {message}"
);
}
#[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());
}
}