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, 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> {
227 self.download_version(node_name, &self.version.clone()).await
228 }
229
230 async fn download_version(&self, node_name: &str, version: &str) -> Result<PathBuf> {
232 let tarball = self.tarball_name_with_version(node_name, version);
233 let (os, arch) = mecha10_binary_naming::os_arch_query(&self.target)?;
234 let url = format!(
235 "{}/downloads/node/{}?os={}&arch={}&version={}",
236 paths::urls::mecha10_api_base_url(),
237 node_name,
238 os,
239 arch,
240 version
241 );
242
243 println!("📦 Downloading {} v{}...", node_name, version);
244 tracing::info!("Downloading binary from mecha10-api: {}", url);
245
246 let client = build_client()?;
247 let response = client
248 .get(&url)
249 .send()
250 .await
251 .context(format!("Failed to connect to mecha10-api for binary '{}'", node_name))?;
252
253 if !response.status().is_success() {
254 return Err(anyhow::anyhow!(
255 "Binary '{}' not found: HTTP {}\n\
256 URL: {}",
257 tarball,
258 response.status(),
259 url
260 ));
261 }
262
263 let size = response.content_length().unwrap_or(0);
264 self.download_and_extract_versioned(node_name, version, response, size)
265 .await
266 }
267
268 async fn download_and_extract_versioned(
270 &self,
271 node_name: &str,
272 version: &str,
273 response: reqwest::Response,
274 size: u64,
275 ) -> Result<PathBuf> {
276 let pb = ProgressBar::new(size);
277 pb.set_style(
278 ProgressStyle::default_bar()
279 .template("{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
280 .unwrap()
281 .progress_chars("#>-"),
282 );
283
284 let temp_dir = tempfile::tempdir()?;
285 let temp_file = temp_dir.path().join("binary.tar.gz");
286
287 let mut file = tokio::fs::File::create(&temp_file).await?;
288 let mut stream = response.bytes_stream();
289
290 use futures_util::StreamExt;
291 use tokio::io::AsyncWriteExt;
292
293 while let Some(chunk) = stream.next().await {
294 let chunk = chunk.context("Error downloading chunk")?;
295 file.write_all(&chunk).await?;
296 pb.inc(chunk.len() as u64);
297 }
298
299 file.flush().await?;
300 pb.finish_with_message("Download complete");
301
302 tokio::fs::create_dir_all(&self.cache_dir).await?;
303
304 let tar_gz = std::fs::File::open(&temp_file)?;
305 let tar = flate2::read::GzDecoder::new(tar_gz);
306 let mut archive = tar::Archive::new(tar);
307
308 let extract_dir = temp_dir.path().join("extract");
309 std::fs::create_dir_all(&extract_dir)?;
310 archive.unpack(&extract_dir)?;
311
312 let extracted_binary = extract_dir.join(node_name);
313 if !extracted_binary.exists() {
314 return Err(anyhow::anyhow!("Binary '{}' not found in tarball", node_name));
315 }
316
317 let binary_name = self.binary_name_with_version(node_name, version);
318 let dest_path = self.cache_dir.join(&binary_name);
319
320 tokio::fs::copy(&extracted_binary, &dest_path).await?;
321
322 #[cfg(unix)]
323 {
324 use std::os::unix::fs::PermissionsExt;
325 let mut perms = tokio::fs::metadata(&dest_path).await?.permissions();
326 perms.set_mode(0o755);
327 tokio::fs::set_permissions(&dest_path, perms).await?;
328 }
329
330 let symlink_path = self.cache_dir.join(node_name);
331 if symlink_path.exists() || symlink_path.is_symlink() {
332 tokio::fs::remove_file(&symlink_path).await.ok();
333 }
334
335 #[cfg(unix)]
336 {
337 std::os::unix::fs::symlink(&binary_name, &symlink_path)?;
338 }
339
340 #[cfg(windows)]
341 {
342 std::os::windows::fs::symlink_file(&dest_path, &symlink_path)?;
343 }
344
345 println!("✅ Installed {} v{} to {}", node_name, version, dest_path.display());
346
347 Ok(dest_path)
348 }
349
350 pub async fn remove(&self, node_name: &str) -> Result<()> {
352 let binary_name = self.binary_name(node_name);
353 let binary_path = self.cache_dir.join(&binary_name);
354 let symlink_path = self.cache_dir.join(node_name);
355
356 if binary_path.exists() {
357 tokio::fs::remove_file(&binary_path).await?;
358 println!("Removed {}", binary_path.display());
359 }
360
361 if symlink_path.exists() || symlink_path.is_symlink() {
362 tokio::fs::remove_file(&symlink_path).await?;
363 println!("Removed symlink {}", symlink_path.display());
364 }
365
366 Ok(())
367 }
368
369 pub async fn clear_cache(&self) -> Result<()> {
371 if self.cache_dir.exists() {
372 tokio::fs::remove_dir_all(&self.cache_dir).await?;
373 println!("Cleared binary cache at {}", self.cache_dir.display());
374 }
375 Ok(())
376 }
377}
378
379impl Default for BinaryManager {
380 fn default() -> Self {
381 Self::new().expect("Failed to create BinaryManager")
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_binary_name() {
391 let manager = BinaryManager {
392 cache_dir: PathBuf::from("/tmp"),
393 version: "0.1.44".to_string(),
394 target: "aarch64-apple-darwin".to_string(),
395 };
396
397 assert_eq!(manager.binary_name("speaker"), "speaker-0.1.44-aarch64-apple-darwin");
398 }
399
400 #[test]
401 fn test_tarball_name() {
402 let manager = BinaryManager {
403 cache_dir: PathBuf::from("/tmp"),
404 version: "0.1.44".to_string(),
405 target: "x86_64-apple-darwin".to_string(),
406 };
407
408 assert_eq!(manager.tarball_name("motor"), "motor-0.1.44-x86_64-apple-darwin.tar.gz");
409 }
410
411 #[tokio::test]
412 async fn resolve_errors_immediately_on_unsupported_platform_with_no_cargo_install_fallback() {
413 let manager = BinaryManager {
414 cache_dir: PathBuf::from("/tmp/mecha10-binary-manager-test-does-not-exist"),
415 version: "0.1.44".to_string(),
416 target: "aarch64-unknown-linux-musl".to_string(),
417 };
418
419 let err = manager
420 .resolve("speaker")
421 .await
422 .expect_err("unsupported platform must error, not silently fall back");
423
424 let message = err.to_string();
425 assert!(
426 message.contains("No pre-built binary available"),
427 "error should name the missing source, got: {message}"
428 );
429 assert!(
430 message.contains("no cargo-install or build-from-source fallback"),
431 "error must make clear there is no fallback path, got: {message}"
432 );
433 }
434
435 #[test]
436 fn test_prebuilt_available() {
437 let manager = BinaryManager {
438 cache_dir: PathBuf::from("/tmp"),
439 version: "0.1.44".to_string(),
440 target: "aarch64-apple-darwin".to_string(),
441 };
442 assert!(manager.is_prebuilt_available());
443
444 let manager_arm_linux = BinaryManager {
445 cache_dir: PathBuf::from("/tmp"),
446 version: "0.1.44".to_string(),
447 target: "aarch64-unknown-linux-gnu".to_string(),
448 };
449 assert!(manager_arm_linux.is_prebuilt_available());
450
451 let manager_unsupported = BinaryManager {
452 cache_dir: PathBuf::from("/tmp"),
453 version: "0.1.44".to_string(),
454 target: "aarch64-unknown-linux-musl".to_string(),
455 };
456 assert!(!manager_unsupported.is_prebuilt_available());
457 }
458}