use std::str::FromStr;
use guess_target::{Abi, Arch, GuessTarget, Target};
pub const KNOWN_TARGETS: &[&str] = &[
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"armv7-unknown-linux-gnueabihf",
"armv7-unknown-linux-musleabihf",
"arm-unknown-linux-gnueabihf",
"arm-unknown-linux-musleabihf",
"i686-unknown-linux-gnu",
"i686-unknown-linux-musl",
"riscv64gc-unknown-linux-gnu",
"riscv64gc-unknown-linux-musl",
"loongarch64-unknown-linux-gnu",
"loongarch64-unknown-linux-musl",
"powerpc64le-unknown-linux-gnu",
"s390x-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-pc-windows-gnu",
"i686-pc-windows-msvc",
"i686-pc-windows-gnu",
"aarch64-pc-windows-msvc",
"aarch64-pc-windows-gnullvm",
"aarch64-linux-android",
"armv7-linux-androideabi",
"i686-linux-android",
"x86_64-linux-android",
"x86_64-unknown-freebsd",
"x86_64-unknown-netbsd",
];
pub const SUPPORTED_SUFFIXES: &[&str] = &[
".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tbz2", ".zip", ".gz", ".exe",
];
pub fn is_installable_asset(file_name: &str) -> bool {
let lower = file_name.to_ascii_lowercase();
if SUPPORTED_SUFFIXES
.iter()
.any(|suffix| lower.ends_with(suffix))
{
return true;
}
!lower.contains('.')
}
pub fn arch_precision(file_name: &str, target: &str) -> usize {
let Ok(target) = Target::from_str(target) else {
return 0;
};
let arch = target.arch();
let lower = file_name.to_ascii_lowercase();
match ALIASES
.iter()
.filter(|(alias, _)| lower.contains(alias))
.max_by_key(|(alias, _)| alias.len())
{
Some((alias, a)) if *a == arch => alias.len(),
_ => 0,
}
}
const ALIASES: &[(&str, Arch)] = &[
("x86_64", Arch::X86_64),
("x86-64", Arch::X86_64),
("amd64", Arch::X86_64),
("x64", Arch::X86_64),
("i686", Arch::I686),
("i386", Arch::I686),
("ia32", Arch::I686),
("386", Arch::I686),
("x86", Arch::I686),
("aarch64", Arch::Aarch64),
("arm64", Arch::Aarch64),
("armv8", Arch::Aarch64),
("armv7l", Arch::Armv7),
("armv7", Arch::Armv7),
("armhf", Arch::Armv7),
("armv6", Arch::Arm),
("armel", Arch::Arm),
("arm", Arch::Arm),
("riscv64gc", Arch::Riscv64gc),
("riscv64", Arch::Riscv64gc),
("loongarch64", Arch::Loongarch64),
("powerpc64le", Arch::Powerpc64le),
("ppc64le", Arch::Powerpc64le),
("powerpc64", Arch::Powerpc64),
("ppc64", Arch::Powerpc64),
("s390x", Arch::S390x),
];
pub const MIN_RANK: u32 = 5;
pub fn candidates(file_name: &str) -> Vec<GuessTarget> {
if !is_installable_asset(file_name) {
return Vec::new();
}
guess_target::guess_target(file_name)
.into_iter()
.filter(|guess| is_known(guess.target))
.collect()
}
fn is_known(target: Target) -> bool {
KNOWN_TARGETS.contains(&target.to_str())
}
pub fn rank_for(file_name: &str, target: &str) -> u32 {
candidates(file_name)
.iter()
.filter(|guess| guess.target.to_str() == target)
.map(|guess| guess.rank)
.max()
.unwrap_or(0)
}
pub fn best_asset<'a, I>(files: I, target: &str, min_rank: u32) -> Option<(&'a str, u32)>
where
I: IntoIterator<Item = &'a str>,
{
let mut best: Option<(&'a str, u32, usize)> = None;
for file in files {
let rank = rank_for(file, target);
if rank < min_rank {
continue;
}
let score = (rank, arch_precision(file, target));
if best.is_none_or(|(_, best_rank, best_precision)| score > (best_rank, best_precision)) {
best = Some((file, rank, score.1));
}
}
best.map(|(file, rank, _)| (file, rank))
}
pub fn guess_target(file_name: &str) -> Option<&'static str> {
candidates(file_name)
.first()
.map(|guess| guess.target.to_str())
}
pub fn guess_targets(file_name: &str) -> Vec<&'static str> {
let candidates = candidates(file_name);
let Some(top_rank) = candidates.iter().map(|guess| guess.rank).max() else {
return Vec::new();
};
let mut targets: Vec<&'static str> = candidates
.iter()
.filter(|guess| guess.rank == top_rank)
.map(|guess| guess.target.to_str())
.collect();
targets.dedup();
targets
}
pub fn guess_binary_name(file_name: &str) -> Option<String> {
candidates(file_name)
.first()
.map(|guess| guess.name.clone())
.filter(|name| !name.is_empty())
}
pub fn belongs_to(file_name: &str, name: &str) -> bool {
candidates(file_name).iter().any(|guess| guess.name == name)
}
pub fn program_names<'a, I>(files: I) -> Vec<String>
where
I: IntoIterator<Item = &'a str>,
{
let mut names: Vec<String> = files
.into_iter()
.flat_map(candidates)
.map(|guess| guess.name)
.filter(|name| !name.is_empty())
.collect();
names.sort_unstable();
names.dedup();
names
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramCoverage {
pub name: String,
pub targets: usize,
}
pub fn program_coverage<'a>(files: impl IntoIterator<Item = &'a str>) -> Vec<ProgramCoverage> {
let files: Vec<&str> = files.into_iter().collect();
let mut coverage: Vec<ProgramCoverage> = program_names(files.iter().copied())
.into_iter()
.map(|name| {
let owned: Vec<&str> = files
.iter()
.copied()
.filter(|file| belongs_to(file, &name))
.collect();
let targets = KNOWN_TARGETS
.iter()
.filter(|target| best_asset(owned.iter().copied(), target, MIN_RANK).is_some())
.count();
ProgramCoverage { name, targets }
})
.collect();
coverage.sort_by(|a, b| b.targets.cmp(&a.targets).then_with(|| a.name.cmp(&b.name)));
coverage.retain(|entry| entry.targets > 0);
coverage
}
pub fn compatible_targets(target: &str) -> Vec<&'static str> {
let Ok(target) = Target::from_str(target) else {
return Vec::new();
};
KNOWN_TARGETS
.iter()
.copied()
.filter(|other| {
let Ok(other) = Target::from_str(other) else {
return false;
};
other != target
&& other.arch() == target.arch()
&& other.os() == target.os()
&& is_compatible_abi(other.abi(), target.abi())
})
.collect()
}
fn is_compatible_abi(a: Option<Abi>, b: Option<Abi>) -> bool {
matches!(
(a, b),
(Some(Abi::Musl), Some(Abi::Gnu))
| (Some(Abi::Gnu), Some(Abi::Musl))
| (Some(Abi::Musleabi), Some(Abi::Gnueabi))
| (Some(Abi::Gnueabi), Some(Abi::Musleabi))
| (Some(Abi::Musleabihf), Some(Abi::Gnueabihf))
| (Some(Abi::Gnueabihf), Some(Abi::Musleabihf))
| (Some(Abi::Msvc), Some(Abi::Gnu))
| (Some(Abi::Gnu), Some(Abi::Msvc))
| (Some(Abi::Msvc), Some(Abi::Gnullvm))
| (Some(Abi::Gnullvm), Some(Abi::Msvc))
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_known_target_is_a_real_target() {
for target in KNOWN_TARGETS {
assert!(
Target::from_str(target).is_ok(),
"{target} is not a target triple recognised by guess-target"
);
}
}
#[test]
fn known_targets_are_unique() {
let mut sorted = KNOWN_TARGETS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), KNOWN_TARGETS.len(), "duplicate entries");
}
#[test]
fn guesses_canonical_triples() {
let cases = [
("ei-x86_64-pc-windows-gnu.zip", "x86_64-pc-windows-gnu"),
(
"tool-aarch64-unknown-linux-musl.tar.gz",
"aarch64-unknown-linux-musl",
),
("tool-aarch64-apple-darwin.tar.gz", "aarch64-apple-darwin"),
(
"tool_armv7-unknown-linux-gnueabihf.tar.gz",
"armv7-unknown-linux-gnueabihf",
),
(
"tool-x86_64-unknown-linux-musl",
"x86_64-unknown-linux-musl",
),
(
"deno-x86_64-unknown-linux-gnu.zip",
"x86_64-unknown-linux-gnu",
),
(
"tool-i686-unknown-linux-musl.tar.gz",
"i686-unknown-linux-musl",
),
(
"tool-aarch64-pc-windows-gnullvm.zip",
"aarch64-pc-windows-gnullvm",
),
(
"tool-s390x-unknown-linux-gnu.tar.gz",
"s390x-unknown-linux-gnu",
),
(
"tool-powerpc64le-unknown-linux-gnu.tar.gz",
"powerpc64le-unknown-linux-gnu",
),
("tool-x86_64-unknown-netbsd.tar.gz", "x86_64-unknown-netbsd"),
];
for (file, expected) in cases {
assert_eq!(guess_target(file), Some(expected), "for {file}");
}
}
#[test]
fn guesses_loosely_named_assets() {
let cases = [
("tool-linux-amd64.tar.gz", "x86_64-unknown-linux-gnu"),
("tool-linux-x86_64-musl.zip", "x86_64-unknown-linux-musl"),
("jq-linux-arm64", "aarch64-unknown-linux-gnu"),
("tool-darwin-arm64.tar.gz", "aarch64-apple-darwin"),
("tool-macos-x64.zip", "x86_64-apple-darwin"),
("biome-darwin-arm64", "aarch64-apple-darwin"),
("tool-win64.zip", "x86_64-pc-windows-gnu"),
("jq-windows-arm64.exe", "aarch64-pc-windows-msvc"),
("tool-windows-x86_64-gnu.zip", "x86_64-pc-windows-gnu"),
("jq-linux-i386", "i686-unknown-linux-gnu"),
("jq-linux-riscv64", "riscv64gc-unknown-linux-gnu"),
("jq-linux-s390x", "s390x-unknown-linux-gnu"),
("mytool_1.2.3_windows_x86_64.zip", "x86_64-pc-windows-gnu"),
(
"mpv-v0.41.0-x86_64-pc-windows-msvc.zip",
"x86_64-pc-windows-msvc",
),
];
for (file, expected) in cases {
assert_eq!(guess_target(file), Some(expected), "for {file}");
}
}
#[test]
fn ignores_non_binary_assets() {
for file in [
"source.tar.gz",
"checksums.txt",
"tool.spdx.json",
"tool.md5",
"custom-name.bin",
] {
assert_eq!(guess_target(file), None, "for {file}");
assert_eq!(rank_for(file, "x86_64-unknown-linux-gnu"), 0, "for {file}");
}
}
#[test]
fn never_returns_an_undetectable_target() {
for guess in candidates("jq-linux-amd64") {
assert!(
KNOWN_TARGETS.contains(&guess.target.to_str()),
"{} leaked into the candidate list",
guess.target.to_str()
);
}
}
#[test]
fn universal_darwin_covers_both_architectures() {
for file in [
"tool-darwin-universal.tar.gz",
"tool-universal2-apple-darwin.tar.gz",
] {
let mut targets = guess_targets(file);
targets.sort_unstable();
assert_eq!(targets, vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]);
}
}
#[test]
fn ties_between_abis_are_reported_together() {
let mut targets = guess_targets("jq-linux-amd64");
targets.sort_unstable();
assert_eq!(
targets,
vec!["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]
);
for file in [
"tool-x86_64-unknown-linux-musl.tar.gz",
"lo-linux-musl-x64.gz",
"jq-macos-arm64",
"tool-darwin-arm64.tar.gz",
"tool-x86_64-pc-windows-gnu.zip",
"tool-i686-unknown-linux-gnu.tar.gz",
] {
assert_eq!(guess_targets(file).len(), 1, "for {file}");
}
let mut targets = guess_targets("jq-windows-arm64.exe");
targets.sort_unstable();
assert_eq!(
targets,
vec!["aarch64-pc-windows-gnullvm", "aarch64-pc-windows-msvc"]
);
}
#[test]
fn picks_the_best_asset_per_target() {
let files = ["lo-linux-x64.gz", "lo-linux-musl-x64.gz"];
assert_eq!(
best_asset(files, "x86_64-unknown-linux-musl", MIN_RANK)
.unwrap()
.0,
"lo-linux-musl-x64.gz"
);
assert_eq!(
best_asset(files, "x86_64-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"lo-linux-x64.gz"
);
let files = ["jq-linux-amd64", "jq-linux-arm64"];
assert_eq!(
best_asset(files, "aarch64-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"jq-linux-arm64"
);
assert_eq!(
best_asset(files, "x86_64-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"jq-linux-amd64"
);
let files = [
"jq-win64.exe",
"jq-windows-amd64.exe",
"jq-windows-arm64.exe",
];
assert_eq!(
best_asset(files, "aarch64-pc-windows-msvc", MIN_RANK)
.unwrap()
.0,
"jq-windows-arm64.exe"
);
assert_eq!(
best_asset(files, "x86_64-pc-windows-msvc", MIN_RANK)
.unwrap()
.0,
"jq-windows-amd64.exe"
);
}
#[test]
fn ties_keep_the_first_asset() {
let files = [
"tool-x86_64-unknown-linux-musl.zip",
"tool-x86_64-unknown-linux-musl.tar.gz",
];
assert_eq!(
best_asset(files, "x86_64-unknown-linux-musl", MIN_RANK)
.unwrap()
.0,
"tool-x86_64-unknown-linux-musl.zip"
);
}
#[test]
fn prefers_the_name_that_spells_out_the_architecture() {
let files = ["qjs-linux-x86", "qjs-linux-x86_64"];
assert_eq!(
best_asset(files, "x86_64-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"qjs-linux-x86_64"
);
assert_eq!(
best_asset(files, "i686-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"qjs-linux-x86"
);
let files = ["qjs-windows-x86.exe", "qjs-windows-x86_64.exe"];
assert_eq!(
best_asset(files, "x86_64-pc-windows-msvc", MIN_RANK)
.unwrap()
.0,
"qjs-windows-x86_64.exe"
);
assert_eq!(
best_asset(files, "i686-pc-windows-msvc", MIN_RANK)
.unwrap()
.0,
"qjs-windows-x86.exe"
);
let files = ["tool-linux-arm", "tool-linux-arm64"];
assert_eq!(
best_asset(files, "aarch64-unknown-linux-gnu", MIN_RANK)
.unwrap()
.0,
"tool-linux-arm64"
);
assert_eq!(
best_asset(files, "arm-unknown-linux-gnueabihf", MIN_RANK)
.unwrap()
.0,
"tool-linux-arm"
);
}
#[test]
fn arch_precision_reads_the_longest_alias() {
assert!(arch_precision("qjs-linux-x86_64", "x86_64-unknown-linux-gnu") > 0);
assert_eq!(
arch_precision("qjs-linux-x86", "x86_64-unknown-linux-gnu"),
0
);
assert!(arch_precision("qjs-linux-x86", "i686-unknown-linux-gnu") > 0);
assert_eq!(
arch_precision("tool-arm64", "arm-unknown-linux-gnueabihf"),
0
);
assert!(arch_precision("tool-arm64", "aarch64-unknown-linux-gnu") > 0);
assert!(arch_precision("tool-arm", "arm-unknown-linux-gnueabihf") > 0);
assert_eq!(arch_precision("tool-linux", "x86_64-unknown-linux-gnu"), 0);
}
#[test]
fn ignores_vague_matches_below_the_threshold() {
for target in [
"x86_64-unknown-freebsd",
"x86_64-unknown-netbsd",
"x86_64-linux-android",
"x86_64-apple-darwin",
] {
assert_eq!(best_asset(["jq-osx-amd64"], target, MIN_RANK), None);
}
assert_eq!(
best_asset(
["jq-linux-armel"],
"powerpc64le-unknown-linux-gnu",
MIN_RANK
),
None
);
assert_eq!(
best_asset(["jq-osx-amd64"], "x86_64-apple-darwin", 1)
.unwrap()
.0,
"jq-osx-amd64"
);
}
#[test]
fn reports_the_tool_name() {
assert_eq!(guess_binary_name("jq-linux-amd64").as_deref(), Some("jq"));
assert_eq!(
guess_binary_name("starship-x86_64-unknown-linux-musl.tar.gz").as_deref(),
Some("starship")
);
assert_eq!(guess_binary_name("checksums.txt"), None);
}
#[test]
fn a_program_name_is_not_a_prefix() {
assert!(belongs_to("crash-x86_64-unknown-linux-gnu.tar.gz", "crash"));
assert!(!belongs_to(
"crash-full-x86_64-unknown-linux-gnu.tar.xz",
"crash"
));
assert!(belongs_to(
"crash-full-x86_64-unknown-linux-gnu.tar.xz",
"crash-full"
));
assert!(!belongs_to(
"crash-x86_64-unknown-linux-gnu.tar.gz",
"crash-full"
));
}
#[test]
fn a_program_name_must_match_exactly() {
assert!(!belongs_to(
"Crash-x86_64-unknown-linux-gnu.tar.gz",
"crash"
));
assert!(!belongs_to("crash-x86_64-unknown-linux-gnu.tar.gz", "cr"));
assert!(!belongs_to(
"crash-x86_64-unknown-linux-gnu.tar.gz",
"crashfull"
));
}
#[test]
fn assets_without_a_platform_belong_to_nothing() {
for file in ["country.mmdb.tar.gz", "yacd.tar.gz", "geoip.dat.tar.gz"] {
assert!(!belongs_to(file, "crash"), "for {file}");
assert!(!belongs_to(file, "country"), "for {file}");
}
}
#[test]
fn lists_the_programs_a_release_mentions() {
let files = [
"crash-x86_64-unknown-linux-gnu.tar.gz",
"crash-full-x86_64-unknown-linux-gnu.tar.xz",
"clash-linux-amd64.tar.gz",
"mihomo-linux-amd64.tar.gz",
"country.mmdb.tar.gz",
];
assert_eq!(
program_names(files),
vec!["clash", "crash", "crash-full", "mihomo"]
);
}
#[test]
fn a_release_with_no_recognisable_assets_lists_no_programs() {
assert!(program_names(["checksums.txt", "yacd.tar.gz"]).is_empty());
}
#[test]
fn accepts_only_unpackable_formats() {
for file in [
"tool-x86_64-unknown-linux-musl.tar.gz",
"tool-x86_64-unknown-linux-gnu.tgz",
"tool-x86_64-unknown-linux-musl.tar.xz",
"tool-x86_64-pc-windows-gnu.zip",
"tool-win64.exe",
"lo-linux-x64.gz",
"jq-linux-amd64",
"biome-darwin-arm64",
"starship",
] {
assert!(is_installable_asset(file), "should accept {file}");
}
}
#[test]
fn rejects_sidecars_patches_and_system_packages() {
for file in [
"deno-x86_64-unknown-linux-gnu.zip.sha256sum",
"starship-x86_64-apple-darwin.tar.gz.sha256",
"tool-x86_64-unknown-linux-gnu.tar.gz.asc",
"lib.deno.d.ts",
"jq-attestation.json",
"deno-aarch64-apple-darwin.from-2.9.5.bsdiff",
"starship-x86_64-pc-windows-msvc.msi",
"ripgrep_15.2.0-1_amd64.deb",
"tool-1.0.0.x86_64.rpm",
"tool-1.0.0.dmg",
"tool-1.0.0.AppImage",
] {
assert!(!is_installable_asset(file), "should reject {file}");
assert_eq!(rank_for(file, "x86_64-unknown-linux-gnu"), 0, "for {file}");
}
}
#[test]
fn keeps_abi_fallbacks_within_one_platform() {
assert_eq!(
compatible_targets("x86_64-unknown-linux-musl"),
vec!["x86_64-unknown-linux-gnu"]
);
assert_eq!(
compatible_targets("x86_64-pc-windows-msvc"),
vec!["x86_64-pc-windows-gnu"]
);
assert_eq!(
compatible_targets("armv7-unknown-linux-gnueabihf"),
vec!["armv7-unknown-linux-musleabihf"]
);
assert_eq!(
compatible_targets("aarch64-apple-darwin"),
Vec::<&str>::new()
);
}
#[test]
fn never_falls_back_across_architectures() {
assert!(
!compatible_targets("x86_64-unknown-linux-gnu").contains(&"i686-unknown-linux-gnu")
);
assert!(
!compatible_targets("arm-unknown-linux-gnueabihf")
.contains(&"armv7-unknown-linux-gnueabihf")
);
assert!(!compatible_targets("aarch64-pc-windows-msvc").contains(&"x86_64-pc-windows-msvc"));
}
}