use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use crate::error::{BuildError, Result};
#[derive(Debug, Clone)]
pub struct ToolchainSpec {
pub language: String,
pub version: String,
pub install_dir: String,
pub path_dirs: Vec<String>,
pub env: HashMap<String, String>,
}
impl ToolchainSpec {
#[must_use]
pub fn go(version: &str) -> Self {
let mut env = HashMap::new();
env.insert("GOROOT".to_string(), "/usr/local/go".to_string());
env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
Self {
language: "go".to_string(),
version: version.to_string(),
install_dir: "/usr/local/go".to_string(),
path_dirs: vec!["/usr/local/go/bin".to_string()],
env,
}
}
#[must_use]
pub fn node(version: &str) -> Self {
Self {
language: "node".to_string(),
version: version.to_string(),
install_dir: "/usr/local/node".to_string(),
path_dirs: vec!["/usr/local/node/bin".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn rust(version: &str) -> Self {
let mut env = HashMap::new();
env.insert("CARGO_HOME".to_string(), "/usr/local/cargo".to_string());
env.insert("RUSTUP_HOME".to_string(), "/usr/local/rustup".to_string());
Self {
language: "rust".to_string(),
version: version.to_string(),
install_dir: "/usr/local/rust".to_string(),
path_dirs: vec![
"/usr/local/cargo/bin".to_string(),
"/usr/local/rust/bin".to_string(),
],
env,
}
}
#[must_use]
pub fn python(version: &str) -> Self {
Self {
language: "python".to_string(),
version: version.to_string(),
install_dir: "/usr/local/python".to_string(),
path_dirs: vec!["/usr/local/python/bin".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn deno(version: &str) -> Self {
Self {
language: "deno".to_string(),
version: version.to_string(),
install_dir: "/usr/local/deno".to_string(),
path_dirs: vec!["/usr/local/deno/bin".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn bun(version: &str) -> Self {
Self {
language: "bun".to_string(),
version: version.to_string(),
install_dir: "/usr/local/bun".to_string(),
path_dirs: vec!["/usr/local/bun/bin".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn java(version: &str) -> Self {
let mut env = HashMap::new();
env.insert("JAVA_HOME".to_string(), "/usr/local/java".to_string());
Self {
language: "java".to_string(),
version: version.to_string(),
install_dir: "/usr/local/java".to_string(),
path_dirs: vec!["/usr/local/java/bin".to_string()],
env,
}
}
#[must_use]
pub fn zig(version: &str) -> Self {
Self {
language: "zig".to_string(),
version: version.to_string(),
install_dir: "/usr/local/zig".to_string(),
path_dirs: vec!["/usr/local/zig".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn swift(version: &str) -> Self {
Self {
language: "swift".to_string(),
version: version.to_string(),
install_dir: "/usr/local/swift".to_string(),
path_dirs: vec!["/usr/local/swift/usr/bin".to_string()],
env: HashMap::new(),
}
}
#[must_use]
pub fn graalvm(version: &str) -> Self {
let mut env = HashMap::new();
env.insert("JAVA_HOME".to_string(), "/usr/local/graalvm".to_string());
env.insert("GRAALVM_HOME".to_string(), "/usr/local/graalvm".to_string());
Self {
language: "graalvm".to_string(),
version: version.to_string(),
install_dir: "/usr/local/graalvm".to_string(),
path_dirs: vec!["/usr/local/graalvm/bin".to_string()],
env,
}
}
}
#[must_use]
pub fn detect_toolchain(image_ref: &str) -> Option<ToolchainSpec> {
let (name, tag) = split_image_name_tag(image_ref);
let base_name = name.rsplit('/').next().unwrap_or(&name);
let version = extract_version_from_tag(&tag);
match base_name {
"golang" | "go" => Some(ToolchainSpec::go(&version)),
"node" => Some(ToolchainSpec::node(&version)),
"rust" => Some(ToolchainSpec::rust(&version)),
"python" | "python3" => Some(ToolchainSpec::python(&version)),
"deno" => Some(ToolchainSpec::deno(&version)),
"bun" => Some(ToolchainSpec::bun(&version)),
"eclipse-temurin" | "amazoncorretto" | "openjdk" => Some(ToolchainSpec::java(&version)),
"zig" => Some(ToolchainSpec::zig(&version)),
"swift" => Some(ToolchainSpec::swift(&version)),
name if name.contains("graalvm") => Some(ToolchainSpec::graalvm(&version)),
_ => None,
}
}
fn split_image_name_tag(image_ref: &str) -> (String, String) {
if let Some((name, tag)) = image_ref.rsplit_once(':') {
(name.to_string(), tag.to_string())
} else {
(image_ref.to_string(), "latest".to_string())
}
}
pub(crate) fn extract_version_from_tag(tag: &str) -> String {
if tag == "latest" {
return "latest".to_string();
}
let version_part: String = tag
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if version_part.is_empty() {
"latest".to_string()
} else {
version_part.trim_end_matches('.').to_string()
}
}
#[must_use]
pub fn host_arch() -> &'static str {
if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
}
}
pub async fn provision_toolchain(
spec: &ToolchainSpec,
rootfs_dir: &Path,
cache_dir: &Path,
tmp_dir: &Path,
) -> Result<()> {
if spec.language == "swift" {
return provision_swift_from_host(spec, rootfs_dir, cache_dir).await;
}
let arch = host_arch();
let cache_key = format!("{}-{}-{}", spec.language, spec.version, arch);
let cached_toolchain = cache_dir.join(&cache_key);
if cached_toolchain.exists() {
info!(
"Symlinking cached {} {} toolchain from {}",
spec.language,
spec.version,
cached_toolchain.display()
);
copy_toolchain_to_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
return Ok(());
}
let (resolved_version, url) = resolve_download_url(spec, arch).await?;
info!(
"Downloading {} {} (resolved: {}) from {}",
spec.language, spec.version, resolved_version, url
);
let download_path = tmp_dir.join(format!("toolchain-{cache_key}.tar.gz"));
download_file(&url, &download_path).await?;
tokio::fs::create_dir_all(&cached_toolchain).await?;
extract_toolchain(&spec.language, &download_path, &cached_toolchain).await?;
copy_toolchain_to_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
let _ = tokio::fs::remove_file(&download_path).await;
info!(
"Provisioned {} {} into rootfs",
spec.language, resolved_version
);
Ok(())
}
async fn resolve_download_url(spec: &ToolchainSpec, arch: &str) -> Result<(String, String)> {
match spec.language.as_str() {
"go" => resolve_go(&spec.version, arch).await,
"node" => resolve_node(&spec.version, arch).await,
"rust" => resolve_rust(&spec.version, arch).await,
"python" => resolve_python(&spec.version, arch).await,
"deno" => resolve_deno(&spec.version, arch).await,
"bun" => resolve_bun(&spec.version, arch).await,
"zig" => resolve_zig(&spec.version, arch).await,
"java" => resolve_java(&spec.version, arch).await,
"graalvm" => resolve_graalvm(&spec.version, arch).await,
"swift" => Err(BuildError::RegistryError {
message: "Swift is provisioned from the host system, not downloaded. \
This code path should not be reached."
.to_string(),
}),
other => Err(BuildError::RegistryError {
message: format!(
"No toolchain provisioner for '{other}'. \
Supported: go, node, rust, python, deno, bun, zig, java, graalvm, swift. \
Use a pre-built zlayer/ base image or specify a toolchain URL."
),
}),
}
}
#[allow(clippy::too_many_lines)]
async fn provision_swift_from_host(
spec: &ToolchainSpec,
rootfs_dir: &Path,
cache_dir: &Path,
) -> Result<()> {
let xcrun_output = tokio::process::Command::new("xcrun")
.args(["--find", "swiftc"])
.output()
.await
.map_err(|e| BuildError::RegistryError {
message: format!(
"Failed to run 'xcrun --find swiftc'. \
Ensure Xcode Command Line Tools are installed (xcode-select --install): {e}"
),
})?;
if !xcrun_output.status.success() {
let stderr = String::from_utf8_lossy(&xcrun_output.stderr);
return Err(BuildError::RegistryError {
message: format!(
"xcrun --find swiftc failed: {stderr}. \
Install Xcode Command Line Tools with: xcode-select --install"
),
});
}
let swiftc_path = PathBuf::from(
String::from_utf8_lossy(&xcrun_output.stdout)
.trim()
.to_string(),
);
debug!("Found swiftc at: {}", swiftc_path.display());
let toolchain_root = swiftc_path
.parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .ok_or_else(|| BuildError::RegistryError {
message: format!(
"Could not determine Swift toolchain root from swiftc path: {}",
swiftc_path.display()
),
})?;
info!("Swift toolchain root: {}", toolchain_root.display());
let version_output = tokio::process::Command::new(&swiftc_path)
.arg("--version")
.output()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to run 'swiftc --version': {e}"),
})?;
let version_str = String::from_utf8_lossy(&version_output.stdout);
let detected_version = extract_swift_version(&version_str);
info!("Detected host Swift version: {detected_version}");
let cache_key = format!("swift-{}-{}", detected_version, host_arch());
let cached_toolchain = cache_dir.join(&cache_key);
if cached_toolchain.exists() {
info!(
"Using cached Swift {} toolchain from {}",
detected_version,
cached_toolchain.display()
);
} else {
info!(
"Copying host Swift toolchain into cache at {}",
cached_toolchain.display()
);
tokio::fs::create_dir_all(&cached_toolchain).await?;
let usr_src = toolchain_root.join("usr");
let usr_dest = cached_toolchain.join("usr");
let cp_output = tokio::process::Command::new("cp")
.args(["-R"])
.arg(format!("{}/", usr_src.display()))
.arg(usr_dest.display().to_string())
.output()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to copy Swift toolchain: {e}"),
})?;
if !cp_output.status.success() {
let stderr = String::from_utf8_lossy(&cp_output.stderr);
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&cached_toolchain)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&cached_toolchain)
.status()
.await;
return Err(BuildError::RegistryError {
message: format!("Failed to copy Swift toolchain: {stderr}"),
});
}
let lib_src = toolchain_root.join("lib");
if lib_src.exists() {
let lib_dest = cached_toolchain.join("lib");
let _ = tokio::process::Command::new("cp")
.args(["-R"])
.arg(format!("{}/", lib_src.display()))
.arg(lib_dest.display().to_string())
.output()
.await;
}
info!("Cached Swift toolchain at {}", cached_toolchain.display());
}
copy_toolchain_to_rootfs(&cached_toolchain, rootfs_dir, spec).await?;
info!("Provisioned Swift {} (host) into rootfs", detected_version);
Ok(())
}
fn extract_swift_version(version_output: &str) -> String {
if let Some(pos) = version_output.find("Swift version ") {
let after = &version_output[pos + "Swift version ".len()..];
let version: String = after
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let version = version.trim_end_matches('.');
if !version.is_empty() {
return version.to_string();
}
}
"unknown".to_string()
}
async fn resolve_go(version: &str, arch: &str) -> Result<(String, String)> {
let resolved = if version == "latest" {
resolve_go_version_from_api(version).await?
} else if version.matches('.').count() < 2 {
resolve_go_version_from_api(version)
.await
.unwrap_or_else(|_| format!("{version}.0"))
} else {
version.to_string()
};
let url = format!("https://go.dev/dl/go{resolved}.darwin-{arch}.tar.gz");
Ok((resolved, url))
}
async fn resolve_go_version_from_api(version_prefix: &str) -> Result<String> {
let api_url = "https://go.dev/dl/?mode=json";
let response = reqwest::get(api_url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Go versions from {api_url}: {e}"),
})?;
let releases: Vec<GoRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse Go versions JSON: {e}"),
})?;
if version_prefix == "latest" {
return releases
.first()
.map(|r| {
r.version
.strip_prefix("go")
.unwrap_or(&r.version)
.to_string()
})
.ok_or_else(|| BuildError::RegistryError {
message: "No Go releases found".to_string(),
});
}
let prefix_dot = format!("go{version_prefix}.");
let prefix_exact = format!("go{version_prefix}");
for release in &releases {
if (release.version.starts_with(&prefix_dot) || release.version == prefix_exact)
&& release.stable
{
return Ok(release
.version
.strip_prefix("go")
.unwrap_or(&release.version)
.to_string());
}
}
for release in &releases {
if release.version.starts_with(&prefix_dot) || release.version == prefix_exact {
return Ok(release
.version
.strip_prefix("go")
.unwrap_or(&release.version)
.to_string());
}
}
Err(BuildError::RegistryError {
message: format!("No Go release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct GoRelease {
version: String,
stable: bool,
}
async fn resolve_node(version: &str, arch: &str) -> Result<(String, String)> {
let node_arch = match arch {
"arm64" => "arm64",
_ => "x64",
};
let resolved = if version == "latest" || !version.contains('.') {
resolve_node_version_from_api(version).await?
} else {
version.to_string()
};
let url =
format!("https://nodejs.org/dist/v{resolved}/node-v{resolved}-darwin-{node_arch}.tar.gz");
Ok((resolved, url))
}
async fn resolve_node_version_from_api(version_prefix: &str) -> Result<String> {
let api_url = "https://nodejs.org/dist/index.json";
let response = reqwest::get(api_url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
})?;
let releases: Vec<NodeRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse Node.js versions JSON: {e}"),
})?;
if version_prefix == "latest" {
return releases
.first()
.map(|r| {
r.version
.strip_prefix('v')
.unwrap_or(&r.version)
.to_string()
})
.ok_or_else(|| BuildError::RegistryError {
message: "No Node.js releases found".to_string(),
});
}
let prefix = format!("v{version_prefix}");
for release in &releases {
if release.version.starts_with(&prefix)
&& release
.version
.chars()
.nth(prefix.len())
.is_none_or(|c| c == '.')
{
return Ok(release
.version
.strip_prefix('v')
.unwrap_or(&release.version)
.to_string());
}
}
Err(BuildError::RegistryError {
message: format!("No Node.js release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct NodeRelease {
version: String,
}
async fn resolve_rust(version: &str, arch: &str) -> Result<(String, String)> {
let rust_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
let resolved = if version == "latest" {
resolve_rust_latest_version().await?
} else if version.matches('.').count() < 2 {
format!("{version}.0")
} else {
version.to_string()
};
let url = format!("https://static.rust-lang.org/dist/rust-{resolved}-{rust_target}.tar.gz");
Ok((resolved, url))
}
async fn resolve_rust_latest_version() -> Result<String> {
let channel_url = "https://static.rust-lang.org/dist/channel-rust-stable.toml";
let response = reqwest::get(channel_url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
})?;
let body = response
.text()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to read Rust stable channel response: {e}"),
})?;
let pkg_rust_pos = body
.find("[pkg.rust]")
.ok_or_else(|| BuildError::RegistryError {
message: "Rust stable channel TOML missing [pkg.rust] section".to_string(),
})?;
let after_pkg = &body[pkg_rust_pos..];
let version_prefix = "version = \"";
let ver_start = after_pkg
.find(version_prefix)
.ok_or_else(|| BuildError::RegistryError {
message: "No version field found in [pkg.rust] section".to_string(),
})?
+ version_prefix.len();
let ver_str: String = after_pkg[ver_start..]
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if ver_str.is_empty() {
return Err(BuildError::RegistryError {
message: "Failed to parse Rust version from stable channel".to_string(),
});
}
debug!("Resolved Rust latest stable version: {ver_str}");
Ok(ver_str)
}
async fn resolve_python(version: &str, arch: &str) -> Result<(String, String)> {
let python_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
resolve_python_from_github(version, python_target).await
}
async fn resolve_python_from_github(
version_prefix: &str,
target: &str,
) -> Result<(String, String)> {
let api_url =
"https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Python releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Python releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let target_suffix = format!("{target}-install_only_stripped.tar.gz");
if version_prefix == "latest" {
for release in &releases {
for asset in &release.assets {
if asset.name.starts_with("cpython-")
&& asset.name.ends_with(&target_suffix)
&& asset.name.contains("install_only")
{
let py_version = extract_python_version_from_asset(&asset.name);
if !py_version.is_empty() {
debug!("Resolved Python latest to {py_version}");
return Ok((py_version, asset.browser_download_url.clone()));
}
}
}
}
return Err(BuildError::RegistryError {
message: format!(
"No Python release found for target '{target}' in recent GitHub releases"
),
});
}
let exact_prefix = format!("cpython-{version_prefix}+");
let partial_prefix = format!("cpython-{version_prefix}.");
for release in &releases {
for asset in &release.assets {
if !asset.name.ends_with(&target_suffix) {
continue;
}
if asset.name.starts_with(&exact_prefix) {
let py_version = extract_python_version_from_asset(&asset.name);
debug!("Resolved Python {version_prefix} to {py_version} (exact)");
return Ok((py_version, asset.browser_download_url.clone()));
}
if asset.name.starts_with(&partial_prefix) {
let py_version = extract_python_version_from_asset(&asset.name);
debug!("Resolved Python {version_prefix} to {py_version} (partial)");
return Ok((py_version, asset.browser_download_url.clone()));
}
}
}
Err(BuildError::RegistryError {
message: format!("No Python release found matching version '{version_prefix}'"),
})
}
fn extract_python_version_from_asset(asset_name: &str) -> String {
asset_name
.strip_prefix("cpython-")
.and_then(|s| s.split('+').next())
.unwrap_or("")
.to_string()
}
#[derive(serde::Deserialize)]
struct GitHubRelease {
tag_name: Option<String>,
assets: Vec<GitHubAsset>,
}
#[derive(serde::Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}
async fn resolve_deno(version: &str, arch: &str) -> Result<(String, String)> {
let deno_target = match arch {
"arm64" => "aarch64-apple-darwin",
_ => "x86_64-apple-darwin",
};
if version == "latest" || !version.contains('.') {
resolve_deno_from_github(version, deno_target).await
} else {
let url = format!(
"https://github.com/denoland/deno/releases/download/v{version}/deno-{deno_target}.zip"
);
Ok((version.to_string(), url))
}
}
async fn resolve_deno_from_github(version_prefix: &str, target: &str) -> Result<(String, String)> {
let api_url = "https://api.github.com/repos/denoland/deno/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Deno releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Deno releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let asset_name = format!("deno-{target}.zip");
if version_prefix == "latest" {
for release in &releases {
for asset in &release.assets {
if asset.name == asset_name {
let tag = release
.tag_name
.as_deref()
.unwrap_or("")
.strip_prefix('v')
.unwrap_or(release.tag_name.as_deref().unwrap_or(""));
if !tag.is_empty() {
debug!("Resolved Deno latest to {tag}");
return Ok((tag.to_string(), asset.browser_download_url.clone()));
}
}
}
}
return Err(BuildError::RegistryError {
message: format!(
"No Deno release found for target '{target}' in recent GitHub releases"
),
});
}
let tag_prefix = format!("v{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
for asset in &release.assets {
if asset.name == asset_name {
let ver = tag.strip_prefix('v').unwrap_or(tag);
debug!("Resolved Deno {version_prefix} to {ver} (partial)");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
}
Err(BuildError::RegistryError {
message: format!("No Deno release found matching version '{version_prefix}'"),
})
}
async fn resolve_bun(version: &str, arch: &str) -> Result<(String, String)> {
let bun_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
if version == "latest" || !version.contains('.') {
resolve_bun_from_github(version, bun_arch).await
} else {
let url = format!(
"https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-darwin-{bun_arch}.zip"
);
Ok((version.to_string(), url))
}
}
async fn resolve_bun_from_github(version_prefix: &str, bun_arch: &str) -> Result<(String, String)> {
let api_url = "https://api.github.com/repos/oven-sh/bun/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Bun releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching Bun releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
let asset_name = format!("bun-darwin-{bun_arch}.zip");
if version_prefix == "latest" {
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
let ver = tag
.strip_prefix("bun-v")
.unwrap_or(tag.strip_prefix('v').unwrap_or(tag));
if ver.is_empty() {
continue;
}
for asset in &release.assets {
if asset.name == asset_name {
debug!("Resolved Bun latest to {ver}");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
return Err(BuildError::RegistryError {
message: format!(
"No Bun release found for arch '{bun_arch}' in recent GitHub releases"
),
});
}
let tag_prefix = format!("bun-v{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
for asset in &release.assets {
if asset.name == asset_name {
let ver = tag.strip_prefix("bun-v").unwrap_or(tag);
debug!("Resolved Bun {version_prefix} to {ver} (partial)");
return Ok((ver.to_string(), asset.browser_download_url.clone()));
}
}
}
}
Err(BuildError::RegistryError {
message: format!("No Bun release found matching version '{version_prefix}'"),
})
}
#[derive(serde::Deserialize)]
struct ZigDownloadInfo {
tarball: String,
}
async fn resolve_zig(version: &str, arch: &str) -> Result<(String, String)> {
let platform_key = match arch {
"arm64" => "aarch64-macos",
_ => "x86_64-macos",
};
let index_url = "https://ziglang.org/download/index.json";
let response = reqwest::get(index_url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Zig download index from {index_url}: {e}"),
})?;
let index: HashMap<String, serde_json::Value> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse Zig download index JSON: {e}"),
})?;
let resolved = if version == "latest" {
let mut versions: Vec<&String> = index.keys().filter(|k| *k != "master").collect();
versions.sort_by(|a, b| compare_version_strings(b, a));
versions
.first()
.map(|v| (*v).clone())
.ok_or_else(|| BuildError::RegistryError {
message: "No stable Zig versions found in download index".to_string(),
})?
} else if index.contains_key(version) {
version.to_string()
} else {
let prefix = format!("{version}.");
let mut candidates: Vec<&String> = index
.keys()
.filter(|k| *k != "master" && k.starts_with(&prefix))
.collect();
candidates.sort_by(|a, b| compare_version_strings(b, a));
candidates
.first()
.map(|v| (*v).clone())
.ok_or_else(|| BuildError::RegistryError {
message: format!("No Zig version found matching '{version}'"),
})?
};
let version_data = index
.get(&resolved)
.ok_or_else(|| BuildError::RegistryError {
message: format!("Zig version '{resolved}' not found in download index"),
})?;
let platform_data =
version_data
.get(platform_key)
.ok_or_else(|| BuildError::RegistryError {
message: format!(
"No Zig download found for platform '{platform_key}' in version '{resolved}'"
),
})?;
let info: ZigDownloadInfo =
serde_json::from_value(platform_data.clone()).map_err(|e| BuildError::RegistryError {
message: format!(
"Failed to parse Zig download info for {platform_key}/{resolved}: {e}"
),
})?;
debug!("Resolved Zig {version} to {resolved}: {}", info.tarball);
Ok((resolved, info.tarball))
}
fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
let a_parts: Vec<&str> = a.split('.').collect();
let b_parts: Vec<&str> = b.split('.').collect();
for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
(Ok(an), Ok(bn)) => an.cmp(&bn),
_ => ap.cmp(bp),
};
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
a_parts.len().cmp(&b_parts.len())
}
#[derive(serde::Deserialize)]
struct AdoptiumAvailableReleases {
most_recent_lts: u32,
}
async fn resolve_java(version: &str, arch: &str) -> Result<(String, String)> {
let adoptium_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
let feature_version = if version == "latest" {
resolve_java_latest_lts().await?
} else {
version.split('.').next().unwrap_or(version).to_string()
};
let url = format!(
"https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/mac/{adoptium_arch}/jdk/hotspot/normal/eclipse"
);
Ok((feature_version, url))
}
async fn resolve_java_latest_lts() -> Result<String> {
let api_url = "https://api.adoptium.net/v3/info/available_releases";
let response = reqwest::get(api_url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch Adoptium available releases from {api_url}: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"Adoptium API returned status {} fetching available releases",
response.status()
),
});
}
let releases: AdoptiumAvailableReleases =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse Adoptium available releases JSON: {e}"),
})?;
let version = releases.most_recent_lts.to_string();
debug!("Resolved Java latest LTS to feature version {version}");
Ok(version)
}
async fn resolve_graalvm(version: &str, arch: &str) -> Result<(String, String)> {
let graalvm_arch = match arch {
"arm64" => "aarch64",
_ => "x64",
};
if version == "latest" || !version.contains('.') {
resolve_graalvm_from_github(version, graalvm_arch).await
} else {
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
jdk-{version}/graalvm-community-jdk-{version}_macos-{graalvm_arch}_bin.tar.gz"
);
Ok((version.to_string(), url))
}
}
async fn resolve_graalvm_from_github(
version_prefix: &str,
graalvm_arch: &str,
) -> Result<(String, String)> {
let api_url = "https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=25";
let client = reqwest::Client::builder()
.user_agent("zlayer")
.build()
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to build HTTP client: {e}"),
})?;
let response = client
.get(api_url)
.send()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to fetch GraalVM releases from GitHub: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!(
"GitHub API returned status {} fetching GraalVM releases",
response.status()
),
});
}
let releases: Vec<GitHubRelease> =
response
.json()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to parse GitHub releases JSON: {e}"),
})?;
if version_prefix == "latest" {
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if let Some(jdk_version) = tag.strip_prefix("jdk-") {
if jdk_version.is_empty() {
continue;
}
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
{tag}/graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz"
);
debug!("Resolved GraalVM latest to {jdk_version}");
return Ok((jdk_version.to_string(), url));
}
}
return Err(BuildError::RegistryError {
message: "No GraalVM CE release found in recent GitHub releases".to_string(),
});
}
let tag_prefix = format!("jdk-{version_prefix}.");
for release in &releases {
let tag = release.tag_name.as_deref().unwrap_or("");
if tag.starts_with(&tag_prefix) {
if let Some(jdk_version) = tag.strip_prefix("jdk-") {
let url = format!(
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
{tag}/graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz"
);
debug!("Resolved GraalVM {version_prefix} to {jdk_version} (partial)");
return Ok((jdk_version.to_string(), url));
}
}
}
Err(BuildError::RegistryError {
message: format!("No GraalVM CE release found matching version '{version_prefix}'"),
})
}
async fn download_file(url: &str, dest: &Path) -> Result<()> {
let response = reqwest::get(url)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to download {url}: {e}"),
})?;
if !response.status().is_success() {
return Err(BuildError::RegistryError {
message: format!("Download failed with status {}: {url}", response.status()),
});
}
let bytes = response
.bytes()
.await
.map_err(|e| BuildError::RegistryError {
message: format!("Failed to read response body from {url}: {e}"),
})?;
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(dest, &bytes).await?;
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
let archive_str = archive.display().to_string();
let target_str = target_dir.display().to_string();
let output = match language {
"go" => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?
}
"node" => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?
}
"rust" => {
let extract_tmp = target_dir.join("_extract");
tokio::fs::create_dir_all(&extract_tmp).await?;
let extract_tmp_str = extract_tmp.display().to_string();
let tar_out = tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&extract_tmp_str,
"--strip-components=1",
])
.output()
.await?;
if !tar_out.status.success() {
let stderr = String::from_utf8_lossy(&tar_out.stderr);
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&extract_tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&extract_tmp)
.status()
.await;
return Err(BuildError::RegistryError {
message: format!("Failed to extract Rust tarball: {stderr}"),
});
}
let install_sh = extract_tmp.join("install.sh");
let install_out = tokio::process::Command::new("sh")
.arg(install_sh.display().to_string())
.arg(format!("--prefix={target_str}"))
.arg("--disable-ldconfig")
.output()
.await?;
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&extract_tmp)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&extract_tmp)
.status()
.await;
if !install_out.status.success() {
let stderr = String::from_utf8_lossy(&install_out.stderr);
return Err(BuildError::RegistryError {
message: format!("Rust install.sh failed: {stderr}"),
});
}
return Ok(());
}
"deno" => {
let out = tokio::process::Command::new("unzip")
.args(["-o", &archive_str, "-d", &target_str])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
let deno_binary = target_dir.join("deno");
if deno_binary.exists() {
tokio::fs::rename(&deno_binary, bin_dir.join("deno")).await?;
}
}
out
}
"zig" => {
let out = tokio::process::Command::new("tar")
.args([
"xJf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
let zig_binary = target_dir.join("zig");
if zig_binary.exists() && !bin_dir.join("zig").exists() {
tokio::fs::symlink(Path::new("../zig"), &bin_dir.join("zig")).await?;
}
}
out
}
"java" | "graalvm" => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=3",
])
.output()
.await?
}
"bun" => {
let out = tokio::process::Command::new("unzip")
.args(["-o", &archive_str, "-d", &target_str])
.output()
.await?;
if out.status.success() {
let bin_dir = target_dir.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
if let Ok(mut entries) = tokio::fs::read_dir(target_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.file_name().to_string_lossy().starts_with("bun-") {
let bun_binary = entry.path().join("bun");
if bun_binary.exists() {
tokio::fs::rename(&bun_binary, bin_dir.join("bun")).await?;
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(entry.path())
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(entry.path())
.status()
.await;
}
}
}
}
}
out
}
_ => {
tokio::process::Command::new("tar")
.args([
"xzf",
&archive_str,
"-C",
&target_str,
"--strip-components=1",
])
.output()
.await?
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BuildError::RegistryError {
message: format!("Failed to extract {language} toolchain: {stderr}"),
});
}
Ok(())
}
async fn copy_toolchain_to_rootfs(
cached: &Path,
rootfs_dir: &Path,
spec: &ToolchainSpec,
) -> Result<()> {
let install_target = rootfs_dir.join(
spec.install_dir
.strip_prefix('/')
.unwrap_or(&spec.install_dir),
);
if let Some(parent) = install_target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if install_target.exists() || install_target.symlink_metadata().is_ok() {
let _ = tokio::process::Command::new("chmod")
.args(["-R", "u+w"])
.arg(&install_target)
.status()
.await;
let _ = tokio::process::Command::new("rm")
.args(["-rf"])
.arg(&install_target)
.status()
.await;
}
tokio::fs::symlink(cached, &install_target)
.await
.map_err(|e| {
BuildError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to symlink toolchain {} → {}: {e}",
cached.display(),
install_target.display()
),
))
})?;
let bin_dir = rootfs_dir.join("usr/local/bin");
tokio::fs::create_dir_all(&bin_dir).await?;
let toolchain_bin = install_target.join("bin");
if toolchain_bin.exists() {
if let Ok(mut entries) = tokio::fs::read_dir(&toolchain_bin).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let link_path = bin_dir.join(&name);
if !link_path.exists() {
let target = format!(
"../{}/bin/{}",
spec.install_dir
.strip_prefix("/usr/local/")
.unwrap_or(&spec.install_dir),
name.to_string_lossy()
);
let _ = tokio::fs::symlink(&target, &link_path).await;
}
}
}
}
Ok(())
}
pub async fn ensure_base_rootfs(rootfs_dir: &Path) -> Result<()> {
for dir in [
"bin",
"usr/bin",
"usr/local/bin",
"usr/local/lib",
"etc/ssl",
"tmp",
"var/tmp",
"var/log",
"opt",
] {
tokio::fs::create_dir_all(rootfs_dir.join(dir)).await?;
}
let bin_binaries = [
"sh", "bash", "cat", "cp", "echo", "ls", "mkdir", "mv", "rm", "sleep", "chmod", "ln",
"test", "expr", "date", "dd", "ps", "kill", "hostname",
];
for bin in &bin_binaries {
let src = PathBuf::from("/bin").join(bin);
if src.exists() {
let _ = tokio::fs::copy(&src, rootfs_dir.join("bin").join(bin)).await;
}
}
let usr_binaries = [
"env", "which", "xargs", "tar", "gzip", "curl", "git", "make", "true", "false", "head",
"tail", "grep", "sed", "awk", "sort", "uniq", "wc", "find", "tee", "touch", "cut", "tr",
"dirname", "basename", "install", "id", "whoami", "zip", "unzip", "openssl",
];
for bin in &usr_binaries {
let src = PathBuf::from("/usr/bin").join(bin);
if src.exists() {
let _ = tokio::fs::copy(&src, rootfs_dir.join("usr/bin").join(bin)).await;
}
}
let ssl_certs = PathBuf::from("/etc/ssl/certs");
if ssl_certs.exists() {
let target = rootfs_dir.join("etc/ssl/certs");
tokio::fs::create_dir_all(&target).await?;
let output = tokio::process::Command::new("cp")
.args(["-R"])
.arg(format!("{}/.", ssl_certs.display()))
.arg(target.display().to_string())
.output()
.await;
if let Err(e) = output {
debug!("Could not copy SSL certs: {e}");
}
}
let cert_pem = PathBuf::from("/etc/ssl/cert.pem");
if cert_pem.exists() {
let _ = tokio::fs::copy(&cert_pem, rootfs_dir.join("etc/ssl/cert.pem")).await;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_golang() {
let spec = detect_toolchain("golang:1.23-alpine").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23");
}
#[test]
fn test_detect_golang_exact() {
let spec = detect_toolchain("golang:1.23.6").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23.6");
}
#[test]
fn test_detect_node() {
let spec = detect_toolchain("node:20-slim").unwrap();
assert_eq!(spec.language, "node");
assert_eq!(spec.version, "20");
}
#[test]
fn test_detect_node_latest() {
let spec = detect_toolchain("node:latest").unwrap();
assert_eq!(spec.language, "node");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_python() {
let spec = detect_toolchain("python:3.12-bookworm").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.12");
}
#[test]
fn test_detect_rust() {
let spec = detect_toolchain("rust:1.82-alpine").unwrap();
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "1.82");
}
#[test]
fn test_detect_alpine_no_toolchain() {
assert!(detect_toolchain("alpine:latest").is_none());
}
#[test]
fn test_detect_unknown_no_toolchain() {
assert!(detect_toolchain("myapp:v2").is_none());
}
#[test]
fn test_detect_docker_hub_qualified() {
let spec = detect_toolchain("docker.io/library/golang:1.23-alpine").unwrap();
assert_eq!(spec.language, "go");
assert_eq!(spec.version, "1.23");
}
#[test]
fn test_extract_version_from_tag_with_suffix() {
assert_eq!(extract_version_from_tag("1.23-alpine"), "1.23");
assert_eq!(extract_version_from_tag("20-slim"), "20");
assert_eq!(extract_version_from_tag("3.12.1-bookworm"), "3.12.1");
}
#[test]
fn test_extract_version_from_tag_bare() {
assert_eq!(extract_version_from_tag("1.23.6"), "1.23.6");
assert_eq!(extract_version_from_tag("20"), "20");
}
#[test]
fn test_extract_version_from_tag_latest() {
assert_eq!(extract_version_from_tag("latest"), "latest");
}
#[test]
fn test_extract_version_from_tag_no_version() {
assert_eq!(extract_version_from_tag("alpine"), "latest");
assert_eq!(extract_version_from_tag("slim"), "latest");
}
#[test]
fn test_go_spec_env() {
let spec = ToolchainSpec::go("1.23");
assert_eq!(spec.env.get("GOROOT"), Some(&"/usr/local/go".to_string()));
assert_eq!(
spec.env.get("GOFLAGS"),
Some(&"-buildvcs=false".to_string())
);
}
#[test]
fn test_java_spec_env() {
let spec = ToolchainSpec::java("21");
assert_eq!(
spec.env.get("JAVA_HOME"),
Some(&"/usr/local/java".to_string())
);
}
#[test]
fn test_rust_spec_env() {
let spec = ToolchainSpec::rust("1.82.0");
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "1.82.0");
assert_eq!(spec.install_dir, "/usr/local/rust");
assert_eq!(
spec.env.get("CARGO_HOME"),
Some(&"/usr/local/cargo".to_string())
);
assert_eq!(
spec.env.get("RUSTUP_HOME"),
Some(&"/usr/local/rustup".to_string())
);
assert!(spec.path_dirs.contains(&"/usr/local/cargo/bin".to_string()));
assert!(spec.path_dirs.contains(&"/usr/local/rust/bin".to_string()));
}
#[test]
fn test_detect_rust_exact() {
let spec = detect_toolchain("rust:1.82.0").unwrap();
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "1.82.0");
}
#[test]
fn test_detect_rust_latest() {
let spec = detect_toolchain("rust:latest").unwrap();
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_rust_no_tag() {
let spec = detect_toolchain("rust").unwrap();
assert_eq!(spec.language, "rust");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_python_spec() {
let spec = ToolchainSpec::python("3.12");
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.12");
assert_eq!(spec.install_dir, "/usr/local/python");
assert!(spec
.path_dirs
.contains(&"/usr/local/python/bin".to_string()));
assert!(spec.env.is_empty());
}
#[test]
fn test_detect_python_partial() {
let spec = detect_toolchain("python:3.12-bookworm").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.12");
}
#[test]
fn test_detect_python_exact() {
let spec = detect_toolchain("python:3.12.1").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.12.1");
}
#[test]
fn test_detect_python3_alias() {
let spec = detect_toolchain("python3:3.11-slim").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "3.11");
}
#[test]
fn test_detect_python_latest() {
let spec = detect_toolchain("python:latest").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_python_no_tag() {
let spec = detect_toolchain("python").unwrap();
assert_eq!(spec.language, "python");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_extract_python_version_from_asset_name() {
assert_eq!(
extract_python_version_from_asset(
"cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
),
"3.12.8"
);
assert_eq!(
extract_python_version_from_asset(
"cpython-3.11.11+20250106-x86_64-apple-darwin-install_only_stripped.tar.gz"
),
"3.11.11"
);
assert_eq!(
extract_python_version_from_asset(
"cpython-3.13.1+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
),
"3.13.1"
);
}
#[test]
fn test_extract_python_version_from_asset_edge_cases() {
assert_eq!(extract_python_version_from_asset("not-a-cpython-asset"), "");
assert_eq!(extract_python_version_from_asset("cpython-"), "");
}
#[test]
fn test_deno_spec() {
let spec = ToolchainSpec::deno("2.1.4");
assert_eq!(spec.language, "deno");
assert_eq!(spec.version, "2.1.4");
assert_eq!(spec.install_dir, "/usr/local/deno");
assert!(spec.path_dirs.contains(&"/usr/local/deno/bin".to_string()));
assert!(spec.env.is_empty());
}
#[test]
fn test_detect_deno() {
let spec = detect_toolchain("deno:2.1.4").unwrap();
assert_eq!(spec.language, "deno");
assert_eq!(spec.version, "2.1.4");
}
#[test]
fn test_detect_deno_partial() {
let spec = detect_toolchain("deno:2-alpine").unwrap();
assert_eq!(spec.language, "deno");
assert_eq!(spec.version, "2");
}
#[test]
fn test_detect_deno_latest() {
let spec = detect_toolchain("deno:latest").unwrap();
assert_eq!(spec.language, "deno");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_deno_no_tag() {
let spec = detect_toolchain("deno").unwrap();
assert_eq!(spec.language, "deno");
assert_eq!(spec.version, "latest");
}
#[tokio::test]
async fn test_resolve_deno_exact_url() {
let (version, url) = resolve_deno("2.1.4", "arm64").await.unwrap();
assert_eq!(version, "2.1.4");
assert_eq!(
url,
"https://github.com/denoland/deno/releases/download/v2.1.4/deno-aarch64-apple-darwin.zip"
);
}
#[tokio::test]
async fn test_resolve_deno_exact_url_amd64() {
let (version, url) = resolve_deno("1.46.3", "amd64").await.unwrap();
assert_eq!(version, "1.46.3");
assert_eq!(
url,
"https://github.com/denoland/deno/releases/download/v1.46.3/deno-x86_64-apple-darwin.zip"
);
}
#[test]
fn test_bun_spec() {
let spec = ToolchainSpec::bun("1.2.3");
assert_eq!(spec.language, "bun");
assert_eq!(spec.version, "1.2.3");
assert_eq!(spec.install_dir, "/usr/local/bun");
assert!(spec.path_dirs.contains(&"/usr/local/bun/bin".to_string()));
assert!(spec.env.is_empty());
}
#[test]
fn test_detect_bun() {
let spec = detect_toolchain("bun:1.2.3").unwrap();
assert_eq!(spec.language, "bun");
assert_eq!(spec.version, "1.2.3");
}
#[test]
fn test_detect_bun_partial() {
let spec = detect_toolchain("bun:1-alpine").unwrap();
assert_eq!(spec.language, "bun");
assert_eq!(spec.version, "1");
}
#[test]
fn test_detect_bun_latest() {
let spec = detect_toolchain("bun:latest").unwrap();
assert_eq!(spec.language, "bun");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_bun_no_tag() {
let spec = detect_toolchain("bun").unwrap();
assert_eq!(spec.language, "bun");
assert_eq!(spec.version, "latest");
}
#[tokio::test]
async fn test_resolve_bun_exact_url() {
let (version, url) = resolve_bun("1.2.3", "arm64").await.unwrap();
assert_eq!(version, "1.2.3");
assert_eq!(
url,
"https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-darwin-aarch64.zip"
);
}
#[tokio::test]
async fn test_resolve_bun_exact_url_amd64() {
let (version, url) = resolve_bun("1.1.0", "amd64").await.unwrap();
assert_eq!(version, "1.1.0");
assert_eq!(
url,
"https://github.com/oven-sh/bun/releases/download/bun-v1.1.0/bun-darwin-x64.zip"
);
}
#[test]
fn test_zig_spec() {
let spec = ToolchainSpec::zig("0.14.0");
assert_eq!(spec.language, "zig");
assert_eq!(spec.version, "0.14.0");
assert_eq!(spec.install_dir, "/usr/local/zig");
assert!(spec.path_dirs.contains(&"/usr/local/zig".to_string()));
assert!(spec.env.is_empty());
}
#[test]
fn test_detect_zig() {
let spec = detect_toolchain("zig:0.14.0").unwrap();
assert_eq!(spec.language, "zig");
assert_eq!(spec.version, "0.14.0");
}
#[test]
fn test_detect_zig_partial() {
let spec = detect_toolchain("zig:0.14-alpine").unwrap();
assert_eq!(spec.language, "zig");
assert_eq!(spec.version, "0.14");
}
#[test]
fn test_detect_zig_latest() {
let spec = detect_toolchain("zig:latest").unwrap();
assert_eq!(spec.language, "zig");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_zig_no_tag() {
let spec = detect_toolchain("zig").unwrap();
assert_eq!(spec.language, "zig");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_compare_version_strings() {
use std::cmp::Ordering;
assert_eq!(
compare_version_strings("0.14.0", "0.13.0"),
Ordering::Greater
);
assert_eq!(compare_version_strings("0.13.0", "0.14.0"), Ordering::Less);
assert_eq!(compare_version_strings("0.14.0", "0.14.0"), Ordering::Equal);
assert_eq!(
compare_version_strings("1.0.0", "0.14.0"),
Ordering::Greater
);
assert_eq!(
compare_version_strings("0.14.1", "0.14.0"),
Ordering::Greater
);
}
#[test]
fn test_java_spec() {
let spec = ToolchainSpec::java("21");
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "21");
assert_eq!(spec.install_dir, "/usr/local/java");
assert!(spec.path_dirs.contains(&"/usr/local/java/bin".to_string()));
assert_eq!(
spec.env.get("JAVA_HOME"),
Some(&"/usr/local/java".to_string())
);
}
#[test]
fn test_detect_eclipse_temurin() {
let spec = detect_toolchain("eclipse-temurin:21-jdk").unwrap();
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "21");
}
#[test]
fn test_detect_amazoncorretto() {
let spec = detect_toolchain("amazoncorretto:17").unwrap();
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "17");
}
#[test]
fn test_detect_openjdk() {
let spec = detect_toolchain("openjdk:21-slim").unwrap();
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "21");
}
#[test]
fn test_detect_openjdk_latest() {
let spec = detect_toolchain("openjdk:latest").unwrap();
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_openjdk_no_tag() {
let spec = detect_toolchain("openjdk").unwrap();
assert_eq!(spec.language, "java");
assert_eq!(spec.version, "latest");
}
#[tokio::test]
async fn test_resolve_java_exact_url() {
let (version, url) = resolve_java("21", "arm64").await.unwrap();
assert_eq!(version, "21");
assert_eq!(
url,
"https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse"
);
}
#[tokio::test]
async fn test_resolve_java_exact_url_amd64() {
let (version, url) = resolve_java("17", "amd64").await.unwrap();
assert_eq!(version, "17");
assert_eq!(
url,
"https://api.adoptium.net/v3/binary/latest/17/ga/mac/x64/jdk/hotspot/normal/eclipse"
);
}
#[tokio::test]
async fn test_resolve_java_dotted_version_strips_to_major() {
let (version, url) = resolve_java("21.0.5", "arm64").await.unwrap();
assert_eq!(version, "21");
assert!(url.contains("/21/ga/mac/aarch64/"));
}
#[test]
fn test_swift_spec() {
let spec = ToolchainSpec::swift("6.1");
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "6.1");
assert_eq!(spec.install_dir, "/usr/local/swift");
assert!(spec
.path_dirs
.contains(&"/usr/local/swift/usr/bin".to_string()));
assert!(spec.env.is_empty());
}
#[test]
fn test_detect_swift() {
let spec = detect_toolchain("swift:6.1").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "6.1");
}
#[test]
fn test_detect_swift_exact() {
let spec = detect_toolchain("swift:6.1.2").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "6.1.2");
}
#[test]
fn test_detect_swift_latest() {
let spec = detect_toolchain("swift:latest").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_swift_no_tag() {
let spec = detect_toolchain("swift").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_swift_with_suffix() {
let spec = detect_toolchain("swift:6.0-slim").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "6.0");
}
#[test]
fn test_detect_swift_registry_qualified() {
let spec = detect_toolchain("docker.io/library/swift:6.1").unwrap();
assert_eq!(spec.language, "swift");
assert_eq!(spec.version, "6.1");
}
#[test]
fn test_extract_swift_version_standard() {
let output =
"Apple Swift version 6.1 (swiftlang-6.1.0.110.21 clang-1700.0.13.3)\nTarget: arm64-apple-macosx15.0\n";
assert_eq!(extract_swift_version(output), "6.1");
}
#[test]
fn test_extract_swift_version_patch() {
let output =
"Apple Swift version 5.10.1 (swift-5.10.1-RELEASE)\nTarget: x86_64-apple-macosx14.0\n";
assert_eq!(extract_swift_version(output), "5.10.1");
}
#[test]
fn test_extract_swift_version_unknown() {
assert_eq!(extract_swift_version("not a version string"), "unknown");
assert_eq!(extract_swift_version(""), "unknown");
}
#[test]
fn test_graalvm_spec() {
let spec = ToolchainSpec::graalvm("21.0.5");
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "21.0.5");
assert_eq!(spec.install_dir, "/usr/local/graalvm");
assert!(spec
.path_dirs
.contains(&"/usr/local/graalvm/bin".to_string()));
assert_eq!(
spec.env.get("JAVA_HOME"),
Some(&"/usr/local/graalvm".to_string())
);
assert_eq!(
spec.env.get("GRAALVM_HOME"),
Some(&"/usr/local/graalvm".to_string())
);
}
#[test]
fn test_detect_graalvm() {
let spec = detect_toolchain("graalvm/graalvm-ce:21.0.5").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "21.0.5");
}
#[test]
fn test_detect_graalvm_ce() {
let spec = detect_toolchain("graalvm-ce:17").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "17");
}
#[test]
fn test_detect_graalvm_ghcr() {
let spec = detect_toolchain("ghcr.io/graalvm/graalvm-community:21").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "21");
}
#[test]
fn test_detect_graalvm_latest() {
let spec = detect_toolchain("graalvm-ce:latest").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_graalvm_no_tag() {
let spec = detect_toolchain("graalvm-ce").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "latest");
}
#[test]
fn test_detect_graalvm_with_suffix() {
let spec = detect_toolchain("graalvm-ce:21-slim").unwrap();
assert_eq!(spec.language, "graalvm");
assert_eq!(spec.version, "21");
}
#[tokio::test]
async fn test_resolve_graalvm_exact_url() {
let (version, url) = resolve_graalvm("21.0.5", "arm64").await.unwrap();
assert_eq!(version, "21.0.5");
assert_eq!(
url,
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
jdk-21.0.5/graalvm-community-jdk-21.0.5_macos-aarch64_bin.tar.gz"
);
}
#[tokio::test]
async fn test_resolve_graalvm_exact_url_amd64() {
let (version, url) = resolve_graalvm("17.0.12", "amd64").await.unwrap();
assert_eq!(version, "17.0.12");
assert_eq!(
url,
"https://github.com/graalvm/graalvm-ce-builds/releases/download/\
jdk-17.0.12/graalvm-community-jdk-17.0.12_macos-x64_bin.tar.gz"
);
}
}