use crate::backend::platform_target::PlatformTarget;
use crate::file;
use crate::hash;
use crate::http::HTTP;
use crate::toolset::ToolVersion;
use crate::toolset::ToolVersionOptions;
use crate::ui::progress_report::SingleReport;
use eyre::{Result, bail};
use indexmap::IndexSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
static VERSION_PATTERN: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"[-_]v?\d+(\.\d+)*(-[a-zA-Z0-9]+(\.\d+)?)?$").unwrap());
pub async fn fetch_checksum_from_shasums(shasums_url: &str, filename: &str) -> Option<String> {
match HTTP.get_text_cached(shasums_url).await {
Ok(shasums_content) => {
let shasums = hash::parse_shasums(&shasums_content);
shasums.get(filename).map(|h| format!("sha256:{h}"))
}
Err(e) => {
debug!("Failed to fetch SHASUMS from {}: {e}", shasums_url);
None
}
}
}
pub async fn fetch_checksum_from_file(checksum_url: &str, algo: &str) -> Option<String> {
match HTTP.get_text(checksum_url).await {
Ok(content) => {
content
.split_whitespace()
.next()
.map(|h| format!("{algo}:{}", h.trim()))
}
Err(e) => {
debug!("Failed to fetch checksum from {}: {e}", checksum_url);
None
}
}
}
const OS_PATTERNS: &[&str] = &[
"linux", "darwin", "macos", "windows", "win", "freebsd", "openbsd", "netbsd", "android",
"unknown",
];
const ARCH_PATTERNS: &[&str] = &[
"x86_64", "aarch64", "ppc64le", "ppc64", "armv7", "armv6", "arm64", "amd64", "mipsel",
"riscv64", "s390x", "i686", "i386", "x64", "mips", "arm", "x86",
];
pub trait VerifiableError: Sized + Send + Sync + 'static {
fn is_not_found(&self) -> bool;
fn into_eyre(self) -> eyre::Report;
}
impl VerifiableError for eyre::Report {
fn is_not_found(&self) -> bool {
self.chain().any(|cause| {
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
err.status() == Some(reqwest::StatusCode::NOT_FOUND)
} else {
false
}
})
}
fn into_eyre(self) -> eyre::Report {
self
}
}
impl VerifiableError for anyhow::Error {
fn is_not_found(&self) -> bool {
if self.to_string().contains("404") {
return true;
}
self.chain().any(|cause| {
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
err.status() == Some(reqwest::StatusCode::NOT_FOUND)
} else {
false
}
})
}
fn into_eyre(self) -> eyre::Report {
eyre::eyre!(self)
}
}
pub async fn try_with_v_prefix<F, Fut, T, E>(
version: &str,
version_prefix: Option<&str>,
resolver: F,
) -> Result<T>
where
F: Fn(String) -> Fut,
Fut: Future<Output = std::result::Result<T, E>>,
E: VerifiableError,
{
try_with_v_prefix_and_repo(version, version_prefix, None, resolver).await
}
pub async fn try_with_v_prefix_and_repo<F, Fut, T, E>(
version: &str,
version_prefix: Option<&str>,
repo: Option<&str>,
resolver: F,
) -> Result<T>
where
F: Fn(String) -> Fut,
Fut: Future<Output = std::result::Result<T, E>>,
E: VerifiableError,
{
let mut errors = vec![];
let mut candidates = if let Some(prefix) = version_prefix {
if version.starts_with(prefix) {
vec![
version.to_string(),
version.trim_start_matches(prefix).to_string(),
]
} else {
vec![format!("{}{}", prefix, version), version.to_string()]
}
} else if version == "latest" {
vec![version.to_string()]
} else if version.starts_with('v') {
vec![
version.to_string(),
version.trim_start_matches('v').to_string(),
]
} else {
vec![format!("v{version}"), version.to_string()]
};
if version_prefix.is_none()
&& version != "latest"
&& let Some(full_repo) = repo
{
if let Some(short_name) = full_repo.split('/').next_back() {
candidates.push(format!("{}@{}", short_name, version));
}
candidates.push(format!("{}@{}", full_repo, version));
}
for candidate in candidates {
match resolver(candidate).await {
Ok(res) => return Ok(res),
Err(e) => {
if e.is_not_found() {
errors.push(e.into_eyre());
} else {
return Err(e.into_eyre());
}
}
}
}
Err(errors
.pop()
.unwrap_or_else(|| eyre::eyre!("No matching release found for {version}")))
}
pub fn platform_aliases() -> Vec<(String, String)> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let mut aliases = vec![];
let os_aliases = match os {
"macos" | "darwin" => vec!["macos", "darwin"],
"linux" => vec!["linux"],
"windows" => vec!["windows"],
_ => vec![os],
};
let arch_aliases = match arch {
"x86_64" | "amd64" => vec!["x64", "amd64", "x86_64"],
"aarch64" | "arm64" => vec!["arm64", "aarch64"],
_ => vec![arch],
};
for os in &os_aliases {
for arch in &arch_aliases {
aliases.push((os.to_string(), arch.to_string()));
}
}
aliases
}
pub fn lookup_platform_key(opts: &ToolVersionOptions, key_type: &str) -> Option<String> {
for (os, arch) in platform_aliases() {
for prefix in ["platforms", "platform"] {
let nested_key = format!("{prefix}.{os}-{arch}.{key_type}");
if let Some(val) = opts.get_nested_string(&nested_key) {
return Some(val);
}
let flat_key = format!("{prefix}_{os}_{arch}_{key_type}");
if let Some(val) = opts.get(&flat_key) {
return Some(val.to_string());
}
}
}
None
}
pub fn lookup_with_fallback(opts: &ToolVersionOptions, key: &str) -> Option<String> {
lookup_platform_key(opts, key).or_else(|| opts.get(key).map(|s| s.to_string()))
}
fn target_platform_aliases(target: &PlatformTarget) -> Vec<(String, String)> {
let os = target.os_name();
let arch = target.arch_name();
let mut aliases = vec![];
let os_aliases = match os {
"macos" | "darwin" => vec!["macos", "darwin"],
"linux" => vec!["linux"],
"windows" => vec!["windows"],
_ => vec![os],
};
let arch_aliases = match arch {
"x64" | "amd64" | "x86_64" => vec!["x64", "amd64", "x86_64"],
"arm64" | "aarch64" => vec!["arm64", "aarch64"],
_ => vec![arch],
};
for os in &os_aliases {
for arch in &arch_aliases {
aliases.push((os.to_string(), arch.to_string()));
}
}
aliases
}
pub fn lookup_platform_key_for_target(
opts: &ToolVersionOptions,
key_type: &str,
target: &PlatformTarget,
) -> Option<String> {
for (os, arch) in target_platform_aliases(target) {
for prefix in ["platforms", "platform"] {
let nested_key = format!("{prefix}.{os}-{arch}.{key_type}");
if let Some(val) = opts.get_nested_string(&nested_key) {
return Some(val);
}
let flat_key = format!("{prefix}_{os}_{arch}_{key_type}");
if let Some(val) = opts.get(&flat_key) {
return Some(val.to_string());
}
}
}
None
}
pub fn list_available_platforms_with_key(opts: &ToolVersionOptions, key_type: &str) -> Vec<String> {
let mut set = IndexSet::new();
for (k, _) in opts.iter() {
if let Some(rest) = k
.strip_prefix("platforms_")
.or_else(|| k.strip_prefix("platform_"))
&& let Some(platform_part) = rest.strip_suffix(&format!("_{}", key_type))
{
let platform_key = if let Some((os_part, rest)) = platform_part.split_once('_') {
format!("{os_part}-{rest}")
} else {
platform_part.to_string()
};
set.insert(platform_key);
}
}
for os in OS_PATTERNS {
for arch in ARCH_PATTERNS {
for prefix in ["platforms", "platform"] {
let nested_key = format!("{prefix}.{os}-{arch}.{key_type}");
if opts.contains_key(&nested_key) {
set.insert(format!("{os}-{arch}"));
}
}
}
}
set.into_iter().collect()
}
pub fn template_string(template: &str, tv: &ToolVersion) -> String {
if template.contains("{version}") && !template.contains("{{version}}") {
deprecated_at!(
"2026.3.0",
"2027.3.0",
"legacy-version-template",
"Use {{{{ version }}}} instead of {{version}} in URL templates"
);
return template.replace("{version}", &tv.version);
}
let mut ctx = crate::tera::BASE_CONTEXT.clone();
ctx.insert("version", &tv.version);
match crate::tera::get_tera(None).render_str(template, &ctx) {
Ok(rendered) => rendered,
Err(e) => {
warn!("Failed to render template '{}': {}", template, e);
template.to_string()
}
}
}
pub fn get_filename_from_url(url_str: &str) -> String {
let filename = if let Ok(url) = url::Url::parse(url_str) {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.map(|s| s.to_string())
.unwrap_or_else(|| url_str.to_string())
} else {
url_str
.split('/')
.next_back()
.unwrap_or(url_str)
.to_string()
};
urlencoding::decode(&filename)
.map(|s| s.to_string())
.unwrap_or(filename)
}
pub fn install_artifact(
tv: &crate::toolset::ToolVersion,
file_path: &Path,
opts: &ToolVersionOptions,
pr: Option<&dyn SingleReport>,
) -> eyre::Result<()> {
let install_path = tv.install_path();
let mut strip_components = lookup_platform_key(opts, "strip_components")
.or_else(|| opts.get("strip_components").map(|s| s.to_string()))
.and_then(|s| s.parse().ok());
file::remove_all(&install_path)?;
file::create_dir_all(&install_path)?;
let format = if let Some(format_opt) = lookup_with_fallback(opts, "format") {
file::TarFormat::from_ext(&format_opt)
} else {
file::TarFormat::from_file_name(
&file_path.file_name().unwrap_or_default().to_string_lossy(),
)
};
let file_name = file_path.file_name().unwrap().to_string_lossy();
if !format.is_archive() && format != file::TarFormat::Raw {
let ext = Path::new(&*file_name)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let decompressed_name = file_name.trim_end_matches(&format!(".{}", ext));
let dest = if let Some(bin_path_template) = lookup_with_fallback(opts, "bin_path") {
let bin_path = template_string(&bin_path_template, tv);
let bin_dir = install_path.join(&bin_path);
file::create_dir_all(&bin_dir)?;
bin_dir.join(decompressed_name)
} else if let Some(bin_name) = lookup_with_fallback(opts, "bin") {
install_path.join(&bin_name)
} else {
let cleaned_name = clean_binary_name(decompressed_name, Some(&tv.ba().tool_name));
install_path.join(cleaned_name)
};
file::untar(
file_path,
&dest,
&file::TarOptions {
pr,
..file::TarOptions::new(format)
},
)?;
file::make_executable(&dest)?;
} else if format == file::TarFormat::Raw {
if let Some(bin_path_template) = lookup_with_fallback(opts, "bin_path") {
let bin_path = template_string(&bin_path_template, tv);
let bin_dir = install_path.join(&bin_path);
file::create_dir_all(&bin_dir)?;
let dest = bin_dir.join(file_path.file_name().unwrap());
file::copy(file_path, &dest)?;
file::make_executable(&dest)?;
} else if let Some(bin_name) = lookup_with_fallback(opts, "bin") {
let dest = install_path.join(&bin_name);
file::copy(file_path, &dest)?;
file::make_executable(&dest)?;
} else {
let original_name = file_path.file_name().unwrap().to_string_lossy();
let cleaned_name = clean_binary_name(&original_name, Some(&tv.ba().tool_name));
let dest = install_path.join(cleaned_name);
file::copy(file_path, &dest)?;
file::make_executable(&dest)?;
}
} else {
if strip_components.is_none()
&& lookup_platform_key(opts, "bin_path")
.or_else(|| opts.get("bin_path").map(|s| s.to_string()))
.is_none()
&& let Ok(should_strip) = file::should_strip_components(file_path, format)
&& should_strip
{
debug!("Auto-detected single directory archive, extracting with strip_components=1");
strip_components = Some(1);
}
let tar_opts = file::TarOptions {
strip_components: strip_components.unwrap_or(0),
pr,
..file::TarOptions::new(format)
};
file::untar(file_path, &install_path, &tar_opts)?;
let full_tool_name = tv.ba().tool_name.as_str();
let tool_name = full_tool_name.rsplit('/').next().unwrap_or(full_tool_name);
let explicit_bin_path = lookup_with_fallback(opts, "bin_path")
.map(|t| install_path.join(template_string(&t, tv)));
if let Some(bin_name) = lookup_with_fallback(opts, "bin") {
let search_dir = explicit_bin_path.as_deref().unwrap_or(&install_path);
rename_executable_in_dir(search_dir, &bin_name, Some(tool_name))?;
}
if let Some(rename_to) = lookup_with_fallback(opts, "rename_exe") {
let search_dir = if let Some(ref dir) = explicit_bin_path {
dir.clone()
} else {
let bin_dir = install_path.join("bin");
if bin_dir.is_dir() {
bin_dir
} else {
install_path.clone()
}
};
rename_executable_in_dir(&search_dir, &rename_to, Some(tool_name))?;
}
}
Ok(())
}
pub fn verify_artifact(
_tv: &crate::toolset::ToolVersion,
file_path: &Path,
opts: &crate::toolset::ToolVersionOptions,
pr: Option<&dyn SingleReport>,
) -> Result<()> {
let checksum = lookup_with_fallback(opts, "checksum");
if let Some(checksum) = checksum {
verify_checksum_str(file_path, &checksum, pr)?;
}
let size_str = lookup_with_fallback(opts, "size");
if let Some(size_str) = size_str {
let expected_size: u64 = size_str.parse()?;
let actual_size = file_path.metadata()?.len();
if actual_size != expected_size {
bail!(
"Size mismatch: expected {}, got {}",
expected_size,
actual_size
);
}
}
Ok(())
}
pub fn verify_checksum_str(
file_path: &Path,
checksum: &str,
pr: Option<&dyn SingleReport>,
) -> Result<()> {
if let Some((algo, hash_str)) = checksum.split_once(':') {
hash::ensure_checksum(file_path, hash_str, pr, algo)?;
} else {
bail!("Invalid checksum format: {}", checksum);
}
Ok(())
}
const SKIP_EXTENSIONS: &[&str] = &[".txt", ".md", ".json", ".yml", ".yaml"];
const SKIP_FILE_NAMES: &[&str] = &["LICENSE", "README"];
fn should_skip_file(file_name: &str, strict: bool) -> bool {
if file_name.starts_with('.') {
return true;
}
if SKIP_EXTENSIONS.iter().any(|ext| file_name.ends_with(ext)) {
return true;
}
if strict {
let upper = file_name.to_uppercase();
if SKIP_FILE_NAMES.iter().any(|name| upper == *name) || upper.starts_with("README.") {
return true;
}
}
false
}
pub fn rename_executable_in_dir(
dir: &Path,
new_name: &str,
tool_name: Option<&str>,
) -> eyre::Result<()> {
let target_path = dir.join(new_name);
if target_path.is_file() && file::is_executable(&target_path) {
return Ok(());
}
let contents_macos = dir.join("Contents").join("MacOS");
if contents_macos.is_dir() {
let target_in_macos = contents_macos.join(new_name);
if rename_executable_in_app_bundle(&contents_macos, &target_in_macos, tool_name)? {
return Ok(());
}
}
for entry in file::ls(dir)? {
if entry.is_dir() {
let dir_name = entry.file_name().unwrap().to_string_lossy();
if dir_name.ends_with(".app") {
let macos_dir = entry.join("Contents").join("MacOS");
if macos_dir.is_dir() {
if rename_executable_in_app_bundle(&macos_dir, &target_path, tool_name)? {
return Ok(());
}
}
}
}
}
let mut substring_match: Option<PathBuf> = None;
let mut fallback_path: Option<PathBuf> = None;
for path in file::ls(dir)? {
if path.is_file() && file::is_executable(&path) {
let file_name = path.file_name().unwrap().to_string_lossy();
if should_skip_file(&file_name, false) {
continue;
}
if let Some(tool_name) = tool_name {
if *file_name == *tool_name {
let target_path_with_extension =
keep_required_extensions(dir, &file_name, new_name, target_path);
file::rename(&path, &target_path_with_extension)?;
debug!(
"Renamed {} to {}",
path.display(),
target_path_with_extension.display()
);
return Ok(());
}
if file_name.contains(tool_name) {
if substring_match.is_none() {
substring_match = Some(path);
}
} else if fallback_path.is_none() {
fallback_path = Some(path);
}
} else {
let target_path_with_extension =
keep_required_extensions(dir, &file_name, new_name, target_path);
file::rename(&path, &target_path_with_extension)?;
debug!(
"Renamed {} to {}",
path.display(),
target_path_with_extension.display()
);
return Ok(());
}
}
}
let best_match = substring_match.or(fallback_path);
if let Some(path) = best_match {
let file_name = path.file_name().unwrap().to_string_lossy();
let target_path_with_extension =
keep_required_extensions(dir, &file_name, new_name, target_path.clone());
file::rename(&path, &target_path_with_extension)?;
debug!(
"Renamed {} to {} (fallback)",
path.display(),
target_path_with_extension.display()
);
return Ok(());
}
if let Some(tool_name) = tool_name {
for path in file::ls(dir)? {
if path.is_file() {
let file_name = path.file_name().unwrap().to_string_lossy();
if should_skip_file(&file_name, true) {
continue;
}
if file_name.contains(tool_name) || *file_name == *new_name {
let target_path_with_extension =
keep_required_extensions(dir, &file_name, new_name, target_path);
file::make_executable(&path)?;
file::rename(&path, &target_path_with_extension)?;
debug!(
"Found and renamed {} to {} (added exec permissions)",
path.display(),
target_path_with_extension.display()
);
return Ok(());
}
}
}
}
Ok(())
}
fn rename_executable_in_app_bundle(
macos_dir: &Path,
target_path: &Path,
tool_name: Option<&str>,
) -> eyre::Result<bool> {
for path in file::ls(macos_dir)? {
if path.is_file() && file::is_executable(&path) {
let file_name = path.file_name().unwrap().to_string_lossy();
if should_skip_file(&file_name, false) {
continue;
}
file::rename(&path, target_path)?;
debug!(
"Renamed .app bundle executable {} to {}",
path.display(),
target_path.display()
);
return Ok(true);
}
}
if let Some(tool_name) = tool_name {
for path in file::ls(macos_dir)? {
if path.is_file() {
let file_name = path.file_name().unwrap().to_string_lossy();
if should_skip_file(&file_name, true) {
continue;
}
if file_name.to_lowercase().contains(&tool_name.to_lowercase()) {
file::make_executable(&path)?;
file::rename(&path, target_path)?;
debug!(
"Found and renamed .app bundle file {} to {} (added exec permissions)",
path.display(),
target_path.display()
);
return Ok(true);
}
}
}
}
Ok(false)
}
fn keep_required_extensions(
dir: &Path,
file_name: &str,
new_name: &str,
target_path: PathBuf,
) -> PathBuf {
if cfg!(windows) {
return keep_extensions(
dir,
file_name,
new_name,
target_path,
&[".exe", ".cmd", ".bat"],
);
}
target_path
}
fn keep_extensions(
dir: &Path,
file_name: &str,
new_name: &str,
target_path: PathBuf,
exts: &[&str],
) -> PathBuf {
for ext in exts {
if file_name.to_lowercase().ends_with(ext) && !new_name.to_lowercase().ends_with(ext) {
return dir.join(format!("{}{}", new_name, ext));
}
}
target_path
}
pub fn clean_binary_name(name: &str, tool_name: Option<&str>) -> String {
let (name_without_ext, extension) = if let Some(pos) = name.rfind('.') {
let potential_ext = &name[pos + 1..];
let executable_extensions = [
"exe", "bat", "cmd", "sh", "ps1", "app", "AppImage", "run", "bin",
];
if executable_extensions.contains(&potential_ext) {
(&name[..pos], Some(&name[pos..]))
} else {
(name, None)
}
} else {
(name, None)
};
let with_ext = |s: String| -> String {
match extension {
Some(ext) => format!("{}{}", s, ext),
None => s,
}
};
let mut cleaned = name_without_ext.to_string();
for os in OS_PATTERNS {
for arch in ARCH_PATTERNS {
let patterns = [
format!("-{os}-{arch}"),
format!("-{os}_{arch}"),
format!("_{os}-{arch}"),
format!("_{os}_{arch}"),
format!("-{arch}-{os}"), format!("_{arch}_{os}"),
];
for pattern in &patterns {
if let Some(pos) = cleaned.rfind(pattern) {
cleaned = cleaned[..pos].to_string();
let result = clean_version_suffix(&cleaned, tool_name);
return with_ext(result);
}
}
}
}
for os in OS_PATTERNS {
let patterns = [format!("-{os}"), format!("_{os}")];
for pattern in &patterns {
if let Some(pos) = cleaned.rfind(pattern.as_str()) {
let after = &cleaned[pos + pattern.len()..];
if after.is_empty() || after.starts_with('-') || after.starts_with('_') {
let before = &cleaned[..pos];
if !before.is_empty() {
cleaned = before.to_string();
let result = clean_version_suffix(&cleaned, tool_name);
return with_ext(result);
}
}
}
}
}
for arch in ARCH_PATTERNS {
let patterns = [format!("-{arch}"), format!("_{arch}")];
for pattern in &patterns {
if let Some(pos) = cleaned.rfind(pattern.as_str()) {
let after = &cleaned[pos + pattern.len()..];
if after.is_empty() || after.starts_with('-') || after.starts_with('_') {
let before = &cleaned[..pos];
if !before.is_empty() {
cleaned = before.to_string();
let result = clean_version_suffix(&cleaned, tool_name);
return with_ext(result);
}
}
}
}
}
let cleaned = clean_version_suffix(&cleaned, tool_name);
with_ext(cleaned)
}
fn clean_version_suffix(name: &str, tool_name: Option<&str>) -> String {
if let Some(tool) = tool_name {
if let Some(m) = VERSION_PATTERN.find(name) {
let without_version = &name[..m.start()];
if without_version == tool
|| tool.contains(without_version)
|| without_version.contains(tool)
{
return without_version.to_string();
}
}
} else {
if let Some(m) = VERSION_PATTERN.find(name) {
let without_version = &name[..m.start()];
if !without_version.is_empty()
&& !without_version.ends_with('-')
&& !without_version.ends_with('_')
{
return without_version.to_string();
}
}
}
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::toolset::ToolVersionOptions;
use indexmap::IndexMap;
#[test]
fn test_clean_binary_name() {
assert_eq!(
clean_binary_name("docker-compose-linux-x86_64", None),
"docker-compose"
);
assert_eq!(
clean_binary_name("docker-compose-linux-x86_64.exe", None),
"docker-compose.exe"
);
assert_eq!(clean_binary_name("tool-darwin-arm64", None), "tool");
assert_eq!(
clean_binary_name("mytool-v1.2.3-windows-amd64", None),
"mytool"
);
assert_eq!(clean_binary_name("app_linux_amd64", None), "app");
assert_eq!(clean_binary_name("app-linux_x64", None), "app");
assert_eq!(clean_binary_name("app_darwin-arm64", None), "app");
assert_eq!(clean_binary_name("tool-x86_64-linux", None), "tool");
assert_eq!(clean_binary_name("tool_amd64_windows", None), "tool");
assert_eq!(
clean_binary_name("docker-compose-linux-x86_64", Some("docker-compose")),
"docker-compose"
);
assert_eq!(
clean_binary_name("compose-linux-x86_64", Some("compose")),
"compose"
);
assert_eq!(clean_binary_name("binary-linux", None), "binary");
assert_eq!(clean_binary_name("binary-x86_64", None), "binary");
assert_eq!(clean_binary_name("binary_arm64", None), "binary");
assert_eq!(clean_binary_name("tool-v1.2.3", None), "tool");
assert_eq!(clean_binary_name("app-2.0.0", None), "app");
assert_eq!(clean_binary_name("binary_v3.2.1", None), "binary");
assert_eq!(clean_binary_name("tool-1.0.0-alpha", None), "tool");
assert_eq!(clean_binary_name("app-v2.0.0-rc1", None), "app");
assert_eq!(
clean_binary_name("docker-compose-v2.29.1", Some("docker-compose")),
"docker-compose"
);
assert_eq!(
clean_binary_name("compose-2.29.1", Some("compose")),
"compose"
);
assert_eq!(clean_binary_name("simple-tool", None), "simple-tool");
assert_eq!(clean_binary_name("app-linux-x64.exe", None), "app.exe");
assert_eq!(
clean_binary_name("tool-v1.2.3-windows.bat", None),
"tool.bat"
);
assert_eq!(
clean_binary_name("script-darwin-arm64.sh", None),
"script.sh"
);
assert_eq!(
clean_binary_name("app-linux.AppImage", None),
"app.AppImage"
);
assert_eq!(clean_binary_name("linux", None), "linux"); assert_eq!(clean_binary_name("", None), "");
}
#[test]
fn test_keep_extensions() {
let dir = Path::new("/tmp");
let initial_target = dir.join("new_tool");
assert_eq!(
keep_extensions(
dir,
"mytool.sh",
"new_tool",
initial_target.clone(),
&[".exe"]
),
initial_target
);
assert_eq!(
keep_extensions(
dir,
"mytool.sh",
"new_tool",
initial_target.clone(),
&[".sh"]
),
dir.join("new_tool.sh")
);
assert_eq!(
keep_extensions(
dir,
"mytool.SH",
"new_tool",
initial_target.clone(),
&[".sh"]
),
dir.join("new_tool.sh")
);
assert_eq!(
keep_extensions(
dir,
"mytool.exe",
"new_tool.exe",
dir.join("new_tool.exe"),
&[".exe"]
),
dir.join("new_tool.exe")
);
}
#[test]
fn test_keep_required_extensions() {
let dir = Path::new("/tmp");
let initial_target = dir.join("new_tool");
if cfg!(windows) {
assert_eq!(
keep_required_extensions(dir, "mytool.exe", "new_tool", initial_target.clone()),
dir.join("new_tool.exe")
);
assert_eq!(
keep_required_extensions(dir, "mytool.cmd", "new_tool", initial_target.clone()),
dir.join("new_tool.cmd")
);
assert_eq!(
keep_required_extensions(dir, "MYTOOL.BAT", "new_tool", initial_target.clone()),
dir.join("new_tool.bat")
);
} else {
assert_eq!(
keep_required_extensions(dir, "mytool.exe", "new_tool", initial_target.clone()),
initial_target
);
}
}
#[test]
fn test_list_available_platforms_with_key_flat_preserves_arch_underscore() {
let mut opts = IndexMap::new();
opts.insert(
"platforms_macos_x86_64_url".to_string(),
toml::Value::String("https://example.com/macos-x86_64.tar.gz".to_string()),
);
opts.insert(
"platforms_linux_x64_url".to_string(),
toml::Value::String("https://example.com/linux-x64.tar.gz".to_string()),
);
opts.insert(
"platform_windows_arm64_url".to_string(),
toml::Value::String("https://example.com/windows-arm64.zip".to_string()),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let platforms = list_available_platforms_with_key(&tool_opts, "url");
assert!(platforms.contains(&"macos-x86_64".to_string()));
assert!(!platforms.contains(&"macos-x86-64".to_string()));
assert!(platforms.contains(&"linux-x64".to_string()));
assert!(platforms.contains(&"windows-arm64".to_string()));
}
#[test]
fn test_verify_artifact_platform_specific() {
let mut opts = IndexMap::new();
opts.insert(
"platforms".to_string(),
toml::Value::String(
r#"
[macos-x64]
checksum = "blake3:abc123"
size = "1024"
[macos-arm64]
checksum = "blake3:jkl012"
size = "4096"
[linux-x64]
checksum = "blake3:def456"
size = "2048"
[linux-arm64]
checksum = "blake3:mno345"
size = "5120"
[windows-x64]
checksum = "blake3:ghi789"
size = "3072"
[windows-arm64]
checksum = "blake3:mno345"
size = "5120"
"#
.to_string(),
),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let checksum = lookup_platform_key(&tool_opts, "checksum");
let size = lookup_platform_key(&tool_opts, "size");
if checksum.is_none() || size.is_none() {
eprintln!(
"Skipping test_verify_artifact_platform_specific: current platform not supported in test data"
);
return;
}
assert!(checksum.is_some());
assert!(size.is_some());
}
#[test]
fn test_verify_artifact_fallback_to_generic() {
let mut opts = IndexMap::new();
opts.insert(
"checksum".to_string(),
toml::Value::String("blake3:generic123".to_string()),
);
opts.insert("size".to_string(), toml::Value::String("512".to_string()));
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let checksum = lookup_platform_key(&tool_opts, "checksum")
.or_else(|| tool_opts.get("checksum").map(|s| s.to_string()));
let size = lookup_with_fallback(&tool_opts, "size");
assert_eq!(checksum, Some("blake3:generic123".to_string()));
assert_eq!(size, Some("512".to_string()));
}
#[test]
fn test_lookup_platform_key_bin_path() {
let mut opts = IndexMap::new();
opts.insert(
"platform".to_string(),
toml::Value::String(
r#"
[macos-arm64]
bin_path = "CMake.app/Contents/bin"
[linux-x64]
bin_path = "bin"
[windows-x64]
bin_path = "."
"#
.to_string(),
),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let bin_path = lookup_platform_key(&tool_opts, "bin_path");
if let Some(bp) = bin_path {
assert!(
bp == "CMake.app/Contents/bin" || bp == "bin" || bp == ".",
"Expected platform-specific bin_path, got: {}",
bp
);
}
}
#[test]
fn test_lookup_platform_key_bin() {
let mut opts = IndexMap::new();
opts.insert(
"platforms".to_string(),
toml::Value::String(
r#"
[macos-arm64]
bin = "xmake"
[linux-x64]
bin = "xmake"
[windows-x64]
bin = "xmake.exe"
"#
.to_string(),
),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let bin = lookup_platform_key(&tool_opts, "bin");
if let Some(b) = bin {
assert!(
b == "xmake" || b == "xmake.exe",
"Expected platform-specific bin, got: {}",
b
);
}
}
#[test]
fn test_lookup_platform_key_bin_with_fallback() {
let mut opts = IndexMap::new();
opts.insert(
"bin".to_string(),
toml::Value::String("generic-tool".to_string()),
);
opts.insert(
"platforms".to_string(),
toml::Value::String(
r#"
[windows-x64]
bin = "tool.exe"
"#
.to_string(),
),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let bin = lookup_with_fallback(&tool_opts, "bin");
assert!(bin.is_some());
let bin_value = bin.unwrap();
assert!(
bin_value == "tool.exe" || bin_value == "generic-tool",
"Expected platform-specific or generic bin, got: {}",
bin_value
);
}
#[test]
fn test_lookup_platform_key_inline_format() {
let mut opts = IndexMap::new();
opts.insert(
"platforms_windows_x64_bin".to_string(),
toml::Value::String("xmake.exe".to_string()),
);
opts.insert(
"platforms_linux_x64_bin".to_string(),
toml::Value::String("xmake".to_string()),
);
opts.insert(
"platforms_macos_arm64_bin".to_string(),
toml::Value::String("xmake".to_string()),
);
let tool_opts = ToolVersionOptions {
opts,
..Default::default()
};
let bin = lookup_platform_key(&tool_opts, "bin");
if let Some(b) = bin {
assert!(
b == "xmake" || b == "xmake.exe",
"Expected platform-specific bin from flat format, got: {}",
b
);
}
}
}