use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use mecha10_cli_core::paths::urls::mecha10_api_base_url;
use mecha10_cli_core::utils::build_client;
use std::fs;
use std::path::Path;
use tar::Archive;
#[derive(Clone, Default)]
pub struct AssetsDownloadService;
impl AssetsDownloadService {
pub fn new() -> Self {
Self
}
pub async fn fetch_and_extract(&self) -> Result<tempfile::TempDir> {
self.fetch_and_extract_from(&mecha10_api_base_url()).await
}
async fn fetch_and_extract_from(&self, api_base_url: &str) -> Result<tempfile::TempDir> {
let url = format!("{}/downloads/assets", api_base_url);
let client = build_client()?;
let response = client
.get(&url)
.send()
.await
.context("Failed to connect to mecha10-api for mecha10-assets")?;
if !response.status().is_success() {
anyhow::bail!(
"Failed to download mecha10-assets: HTTP {}\nURL: {}",
response.status(),
url
);
}
let bytes = response
.bytes()
.await
.context("Failed to read mecha10-assets archive")?;
let temp_dir = tempfile::tempdir().context("Failed to create temp directory for mecha10-assets")?;
let decoder = GzDecoder::new(&bytes[..]);
let mut archive = Archive::new(decoder);
archive
.unpack(temp_dir.path())
.context("Failed to extract mecha10-assets archive")?;
tracing::info!("mecha10-assets extracted to {:?}", temp_dir.path());
Ok(temp_dir)
}
}
fn is_junk_entry(file_name: &std::ffi::OsStr) -> bool {
let name = file_name.to_string_lossy();
name.starts_with("._") || name == ".DS_Store"
}
pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).with_context(|| format!("Failed to create directory {:?}", dst))?;
for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory {:?}", src))? {
let entry = entry?;
if is_junk_entry(&entry.file_name()) {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)
.with_context(|| format!("Failed to copy {:?} to {:?}", src_path, dst_path))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_dir_recursive_copies_nested_files_and_skips_junk_entries() {
let src = tempfile::tempdir().unwrap();
fs::create_dir_all(src.path().join("@mecha10/idle-wander")).unwrap();
fs::write(
src.path().join("@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander"}"#,
)
.unwrap();
fs::write(src.path().join("@mecha10/idle-wander/._config.json"), b"junk").unwrap();
fs::write(src.path().join(".DS_Store"), b"junk").unwrap();
let dst = tempfile::tempdir().unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("@mecha10/idle-wander/config.json")).unwrap(),
r#"{"name": "idle-wander"}"#
);
assert!(!dst.path().join("@mecha10/idle-wander/._config.json").exists());
assert!(!dst.path().join(".DS_Store").exists());
}
#[tokio::test]
async fn fetch_and_extract_errors_clearly_when_mecha10_api_is_unreachable() {
let service = AssetsDownloadService::new();
let err = service
.fetch_and_extract_from("http://127.0.0.1:9/api")
.await
.expect_err("unreachable mecha10-api must produce an error, not panic");
assert!(
format!("{err:#}").contains("mecha10-assets"),
"error should name what was being fetched, got: {err:#}"
);
}
}