use crate::paths;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::PathBuf;
const GITHUB_REPO: &str = paths::urls::USER_TOOLS_REPO;
const ASSET_NAME: &str = "mecha10-simulation.tar.gz";
pub struct SimulationAssetsService {
cache_dir: PathBuf,
}
#[allow(dead_code)]
impl SimulationAssetsService {
pub fn new() -> Self {
let cache_dir = paths::user::mecha10_dir();
Self { cache_dir }
}
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
pub fn current_assets_path(&self) -> Option<PathBuf> {
let current = paths::user::simulation_current();
if current.exists() {
std::fs::canonicalize(¤t).ok()
} else {
None
}
}
pub fn godot_project_path(&self) -> Option<PathBuf> {
self.current_assets_path()
.map(|p| p.join("godot-project"))
.filter(|p| p.exists())
}
pub fn models_path(&self) -> Option<PathBuf> {
self.current_assets_path()
.map(|p| p.join("models"))
.filter(|p| p.exists())
}
pub fn environments_path(&self) -> Option<PathBuf> {
self.current_assets_path()
.map(|p| p.join("environments"))
.filter(|p| p.exists())
}
pub fn is_installed(&self) -> bool {
self.godot_project_path().is_some()
}
pub fn installed_version(&self) -> Option<String> {
let version_file = paths::user::simulation_dir().join("version");
std::fs::read_to_string(version_file).ok()
}
pub async fn ensure_assets(&self) -> Result<PathBuf> {
if let Some(path) = self.current_assets_path() {
tracing::debug!("Simulation assets already installed at {:?}", path);
return Ok(path);
}
println!("📦 Simulation assets not found locally");
println!(" Downloading from GitHub releases...");
self.download_latest().await
}
fn build_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("mecha10-cli")
.build()
.context("Failed to build HTTP client")
}
fn github_token() -> Option<String> {
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
return Some(token);
}
if let Ok(token) = std::env::var("GH_TOKEN") {
return Some(token);
}
Self::get_gh_cli_token()
}
fn get_gh_cli_token() -> Option<String> {
let output = std::process::Command::new("gh").args(["auth", "token"]).output().ok()?;
if output.status.success() {
let token = String::from_utf8(output.stdout).ok()?;
let token = token.trim();
if !token.is_empty() {
tracing::debug!("Using GitHub token from `gh auth token`");
return Some(token.to_string());
}
}
None
}
pub async fn download_latest(&self) -> Result<PathBuf> {
let client = Self::build_client()?;
let token = Self::github_token();
let release_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
println!(" Checking latest release...");
let mut request = client.get(&release_url);
if let Some(ref token) = token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.send()
.await
.context("Failed to fetch release info from GitHub")?;
if !response.status().is_success() {
let status = response.status();
let hint = if status.as_u16() == 404 && token.is_none() {
"\n\nHint: The repository may be private. Set GITHUB_TOKEN or GH_TOKEN environment variable."
} else {
""
};
return Err(anyhow::anyhow!(
"Failed to get release info: HTTP {}.{}\n\
The simulation assets may not be published yet.\n\n\
Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone:\n\
export MECHA10_FRAMEWORK_PATH=/path/to/mecha10",
status,
hint
));
}
let release: serde_json::Value = response.json().await?;
let tag_name = release["tag_name"].as_str().unwrap_or("unknown").to_string();
let assets = release["assets"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("No assets in release"))?;
let asset = assets
.iter()
.find(|a| a["name"].as_str().map(|n| n == ASSET_NAME).unwrap_or(false))
.ok_or_else(|| {
anyhow::anyhow!(
"Simulation asset '{}' not found in release {}.\n\n\
The release may not include simulation assets yet.\n\
Alternative: Set MECHA10_FRAMEWORK_PATH to your local mecha10 clone.",
ASSET_NAME,
tag_name
)
})?;
let download_url = if token.is_some() {
asset["url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No API URL for asset"))?
} else {
asset["browser_download_url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No download URL for asset"))?
};
let size = asset["size"].as_u64().unwrap_or(0);
println!(" Release: {}", tag_name);
println!(" Size: {:.1} MB", size as f64 / 1024.0 / 1024.0);
self.download_and_extract(download_url, &tag_name, size, token).await
}
async fn download_and_extract(
&self,
url: &str,
version: &str,
size: u64,
token: Option<String>,
) -> Result<PathBuf> {
let client = Self::build_client()?;
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("#>-"),
);
println!(" Downloading...");
let mut request = client.get(url);
if let Some(ref token) = token {
request = request
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/octet-stream");
}
let response = request.send().await.context("Failed to download simulation assets")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Download failed: HTTP {}", response.status()));
}
let temp_dir = tempfile::tempdir()?;
let temp_file = temp_dir.path().join("simulation.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");
println!(" Extracting...");
let simulation_dir = paths::user::simulation_dir();
let version_dir = simulation_dir.join(version);
tokio::fs::create_dir_all(&version_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);
archive.unpack(&version_dir)?;
let current_link = simulation_dir.join("current");
if current_link.exists() {
tokio::fs::remove_file(¤t_link).await.ok();
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&version_dir, ¤t_link)?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(&version_dir, ¤t_link)?;
}
let version_file = simulation_dir.join("version");
tokio::fs::write(&version_file, version).await?;
println!("✅ Simulation assets installed to {:?}", version_dir);
Ok(version_dir)
}
pub async fn remove(&self) -> Result<()> {
let simulation_dir = paths::user::simulation_dir();
if simulation_dir.exists() {
tokio::fs::remove_dir_all(&simulation_dir).await?;
println!("✅ Simulation assets removed");
} else {
println!("No simulation assets installed");
}
Ok(())
}
pub async fn update(&self) -> Result<PathBuf> {
self.remove().await.ok();
self.download_latest().await
}
}
impl Default for SimulationAssetsService {
fn default() -> Self {
Self::new()
}
}