use crate::config::Settings;
use crate::env;
use eyre::{Result, bail};
use std::{collections::BTreeMap, fmt, sync::LazyLock as Lazy};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub(crate) struct Platform {
pub os: String,
pub arch: String,
pub qualifier: Option<String>,
}
impl Platform {
pub(crate) fn parse(platform_str: &str) -> Result<Self> {
let parts: Vec<&str> = platform_str.split('-').collect();
match parts.len() {
0 | 1 => bail!(
"Invalid platform format '{}'. Expected 'os-arch' or 'os-arch-qualifier'",
platform_str
),
2 => Ok(Platform {
os: parts[0].to_string(),
arch: parts[1].to_string(),
qualifier: None,
}),
_ => {
let qualifier = parts[2..].join("-");
Ok(Platform {
os: parts[0].to_string(),
arch: parts[1].to_string(),
qualifier: Some(qualifier),
})
}
}
}
pub(crate) fn current() -> Self {
let settings = Settings::get();
let os = settings.os().to_string();
let qualifier = if os == "linux" {
match settings.libc() {
Some("musl") => Some("musl".to_string()),
Some("gnu") => None,
_ if is_musl_system() => Some("musl".to_string()),
_ => None,
}
} else {
None
};
Platform {
os,
arch: settings.arch().to_string(),
qualifier,
}
}
pub(crate) fn libc(&self) -> Option<&str> {
self.qualifier
.as_deref()?
.split('-')
.find_map(|part| match part {
"gnu" | "glibc" => Some("gnu"),
"musl" => Some("musl"),
_ => None,
})
}
pub(crate) fn validate(&self) -> Result<()> {
match self.os.as_str() {
"linux" | "macos" | "windows" | "android" => {}
_ => bail!(
"Unsupported OS '{}'. Supported: linux, macos, windows, android",
self.os
),
}
match self.arch.as_str() {
"x64" | "arm64" | "x86" | "loongarch64" | "riscv64" => {}
_ => bail!(
"Unsupported architecture '{}'. Supported: x64, arm64, x86, loongarch64, riscv64",
self.arch
),
}
if let Some(qualifier) = &self.qualifier {
match qualifier.as_str() {
"gnu" | "glibc" | "musl" | "msvc" | "baseline" | "musl-baseline" => {}
_ => bail!(
"Unsupported qualifier '{}'. Supported: gnu, glibc, musl, msvc, baseline, musl-baseline",
qualifier
),
}
}
Ok(())
}
pub(crate) fn to_key(&self) -> String {
match &self.qualifier {
Some(qualifier) => format!("{}-{}-{}", self.os, self.arch, qualifier),
None => format!("{}-{}", self.os, self.arch),
}
}
pub(crate) fn parse_multiple(platform_strings: &[String]) -> Result<Vec<Self>> {
let mut platforms = Vec::new();
for platform_str in platform_strings {
let platform = Self::parse(platform_str)?;
platform.validate()?;
platforms.push(platform);
}
platforms.sort();
platforms.dedup();
Ok(platforms)
}
pub(crate) fn common_platforms() -> Vec<Self> {
vec![
Platform::parse("linux-x64").unwrap(),
Platform::parse("linux-x64-musl").unwrap(),
Platform::parse("linux-arm64").unwrap(),
Platform::parse("linux-arm64-musl").unwrap(),
Platform::parse("macos-x64").unwrap(),
Platform::parse("macos-arm64").unwrap(),
Platform::parse("windows-x64").unwrap(),
]
}
pub(crate) fn is_windows(&self) -> bool {
self.os == "windows"
}
pub(crate) fn is_macos(&self) -> bool {
self.os == "macos"
}
pub(crate) fn is_linux(&self) -> bool {
self.os == "linux"
}
}
impl fmt::Display for Platform {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_key())
}
}
impl From<String> for Platform {
fn from(s: String) -> Self {
Self::parse(&s).unwrap_or_else(|_| {
Self::current()
})
}
}
impl From<&str> for Platform {
fn from(s: &str) -> Self {
Self::parse(s).unwrap_or_else(|_| {
Self::current()
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LinuxOsRelease {
pub id: String,
pub version_id: String,
pub id_like: Vec<String>,
}
impl LinuxOsRelease {
pub(crate) fn parse(content: &str) -> Option<Self> {
let mut values = BTreeMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.insert(key.trim().to_string(), parse_os_release_value(value.trim()));
}
Some(Self {
id: values.remove("ID")?,
version_id: values.remove("VERSION_ID").unwrap_or_default(),
id_like: values
.remove("ID_LIKE")
.unwrap_or_default()
.split_whitespace()
.map(str::to_string)
.collect(),
})
}
pub(crate) fn ids(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.id.as_str()).chain(self.id_like.iter().map(String::as_str))
}
}
pub(crate) fn linux_os_release() -> Option<&'static LinuxOsRelease> {
use std::sync::LazyLock;
static OS_RELEASE: LazyLock<Option<LinuxOsRelease>> =
LazyLock::new(|| read_linux_os_release("/etc/os-release"));
OS_RELEASE.as_ref()
}
fn read_linux_os_release(path: &str) -> Option<LinuxOsRelease> {
LinuxOsRelease::parse(&std::fs::read_to_string(path).ok()?)
}
fn parse_os_release_value(value: &str) -> String {
let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') else {
return value.to_string();
};
let mut parsed = String::new();
let mut chars = value[quote.len_utf8()..].chars();
while let Some(ch) = chars.next() {
if ch == quote {
break;
}
if quote == '"' && ch == '\\' {
if let Some(next) = chars.next() {
parsed.push(next);
}
} else {
parsed.push(ch);
}
}
parsed
}
#[cfg(target_os = "linux")]
pub(crate) fn detect_libc() -> Option<&'static str> {
use std::sync::LazyLock;
static DETECTED: LazyLock<Option<&'static str>> = LazyLock::new(|| {
if linux_os_release().is_some_and(linux_os_release_is_musl) {
return Some("musl");
}
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-linux-") {
return Some("gnu");
}
}
for dir in ["/lib", "/lib64"] {
if has_file_prefix(dir, "ld-musl-") {
return Some("musl");
}
}
if cfg!(target_env = "musl") {
return Some("musl");
}
if cfg!(target_env = "gnu") {
return Some("gnu");
}
None
});
*DETECTED
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn detect_libc() -> Option<&'static str> {
None
}
#[cfg(target_os = "linux")]
fn linux_os_release_is_musl(release: &LinuxOsRelease) -> bool {
const MUSL_DISTROS: &[&str] = &["alpine", "postmarketos", "chimera"];
release.ids().any(|id| MUSL_DISTROS.contains(&id))
}
#[cfg(target_os = "linux")]
fn has_file_prefix(dir: &str, prefix: &str) -> bool {
std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(prefix))
})
.unwrap_or(false)
}
fn is_musl_system() -> bool {
detect_libc() == Some("musl")
}
pub(crate) static OS: Lazy<String> = Lazy::new(|| env::consts::OS.into());
pub(crate) static ARCH: Lazy<String> = Lazy::new(|| {
match env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
_ => env::consts::ARCH,
}
.to_string()
});
pub(crate) fn normalize_os(os: &str) -> &str {
match os {
"darwin" | "macos" => "macos",
"windows" | "win" => "windows",
other => other,
}
}
pub(crate) fn normalize_arch(arch: &str) -> &str {
match arch {
"x86_64" | "amd64" | "x64" => "x64",
"aarch64" | "arm64" => "arm64",
other => other,
}
}
pub(crate) fn os_selector_matches(entry: &str) -> bool {
let (os, arch) = entry
.split_once('/')
.map_or((entry, None), |(os, arch)| (os, Some(arch)));
let os = normalize_os(os);
(os == OS.as_str() || os == env::consts::FAMILY)
&& arch.is_none_or(|arch| normalize_arch(arch) == ARCH.as_str())
}
pub(crate) fn is_os_family_selector(entry: &str) -> bool {
entry.split('/').next() == Some("unix")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_os() {
assert_eq!(normalize_os("macos"), "macos");
assert_eq!(normalize_os("darwin"), "macos");
assert_eq!(normalize_os("linux"), "linux");
assert_eq!(normalize_os("windows"), "windows");
assert_eq!(normalize_os("win"), "windows");
assert_eq!(normalize_os("freebsd"), "freebsd");
}
#[test]
fn test_os_selector_matches() {
let os = OS.as_str();
let arch = ARCH.as_str();
let other_arch = if arch == "x64" { "arm64" } else { "x64" };
assert!(os_selector_matches(os));
assert!(os_selector_matches(&format!("{os}/{arch}")));
assert!(!os_selector_matches(&format!("{os}/{other_arch}")));
assert!(!os_selector_matches("plan9"));
assert_eq!(os_selector_matches("unix"), cfg!(unix));
assert_eq!(os_selector_matches(&format!("unix/{arch}")), cfg!(unix));
assert!(!os_selector_matches(&format!("unix/{other_arch}")));
assert!(is_os_family_selector("unix"));
assert!(is_os_family_selector("unix/arm64"));
assert!(!is_os_family_selector("linux"));
}
#[test]
fn test_normalize_arch() {
assert_eq!(normalize_arch("arm64"), "arm64");
assert_eq!(normalize_arch("aarch64"), "arm64");
assert_eq!(normalize_arch("x64"), "x64");
assert_eq!(normalize_arch("x86_64"), "x64");
assert_eq!(normalize_arch("amd64"), "x64");
assert_eq!(normalize_arch("riscv64"), "riscv64");
}
#[test]
fn test_platform_parse_basic() {
let platform = Platform::parse("linux-x64").unwrap();
assert_eq!(platform.os, "linux");
assert_eq!(platform.arch, "x64");
assert_eq!(platform.qualifier, None);
}
#[test]
fn test_platform_parse_with_qualifier() {
let platform = Platform::parse("linux-x64-gnu").unwrap();
assert_eq!(platform.os, "linux");
assert_eq!(platform.arch, "x64");
assert_eq!(platform.qualifier, Some("gnu".to_string()));
}
#[test]
fn test_platform_parse_with_compound_qualifier() {
let platform = Platform::parse("linux-x64-musl-baseline").unwrap();
assert_eq!(platform.os, "linux");
assert_eq!(platform.arch, "x64");
assert_eq!(platform.qualifier, Some("musl-baseline".to_string()));
assert_eq!(platform.to_key(), "linux-x64-musl-baseline");
let reparsed = Platform::parse(&platform.to_key()).unwrap();
assert_eq!(reparsed.qualifier, Some("musl-baseline".to_string()));
}
#[test]
fn test_platform_parse_invalid() {
assert!(Platform::parse("linux").is_err());
assert!(Platform::parse("").is_err());
}
#[test]
fn test_platform_validation() {
assert!(Platform::parse("linux-x64").unwrap().validate().is_ok());
assert!(Platform::parse("macos-arm64").unwrap().validate().is_ok());
assert!(Platform::parse("windows-x64").unwrap().validate().is_ok());
assert!(Platform::parse("linux-x64-gnu").unwrap().validate().is_ok());
assert!(
Platform::parse("linux-x64-glibc")
.unwrap()
.validate()
.is_ok()
);
assert!(Platform::parse("invalid-x64").unwrap().validate().is_err());
assert!(
Platform::parse("linux-invalid")
.unwrap()
.validate()
.is_err()
);
assert!(
Platform::parse("linux-x64-invalid")
.unwrap()
.validate()
.is_err()
);
}
#[test]
fn test_platform_to_key() {
let platform1 = Platform::parse("linux-x64").unwrap();
assert_eq!(platform1.to_key(), "linux-x64");
let platform2 = Platform::parse("linux-x64-gnu").unwrap();
assert_eq!(platform2.to_key(), "linux-x64-gnu");
}
#[test]
fn test_platform_multiple_parsing() {
let platform_strings = vec![
"linux-x64".to_string(),
"macos-arm64".to_string(),
"linux-x64".to_string(), ];
let platforms = Platform::parse_multiple(&platform_strings).unwrap();
assert_eq!(platforms.len(), 2);
assert_eq!(platforms[0].to_key(), "linux-x64");
assert_eq!(platforms[1].to_key(), "macos-arm64");
}
#[test]
fn test_platform_helpers() {
let linux_platform = Platform::parse("linux-arm64").unwrap();
assert!(linux_platform.is_linux());
assert!(!linux_platform.is_windows());
let windows_platform = Platform::parse("windows-x64").unwrap();
assert!(windows_platform.is_windows());
assert!(!windows_platform.is_linux());
}
#[test]
fn test_common_platforms() {
let platforms = Platform::common_platforms();
assert_eq!(platforms.len(), 7);
let keys: Vec<String> = platforms.iter().map(|p| p.to_key()).collect();
assert!(keys.contains(&"linux-x64".to_string()));
assert!(keys.contains(&"linux-x64-musl".to_string()));
assert!(keys.contains(&"linux-arm64".to_string()));
assert!(keys.contains(&"linux-arm64-musl".to_string()));
assert!(keys.contains(&"macos-x64".to_string()));
assert!(keys.contains(&"macos-arm64".to_string()));
assert!(keys.contains(&"windows-x64".to_string()));
}
#[cfg(all(target_os = "linux", target_env = "musl"))]
#[test]
fn test_musl_binary_detects_musl() {
assert!(
is_musl_system(),
"musl-compiled binary should detect musl system"
);
}
#[cfg(all(target_os = "linux", target_env = "musl"))]
#[test]
fn test_current_platform_has_musl_qualifier() {
let platform = Platform::current();
assert_eq!(
platform.qualifier.as_deref(),
Some("musl"),
"musl-compiled binary should have musl qualifier, got: {}",
platform.to_key()
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_os_release_alpine_id_is_musl() {
let release =
LinuxOsRelease::parse("NAME=\"Alpine Linux\"\nID=alpine\nVERSION_ID=3.22.4\n").unwrap();
assert!(linux_os_release_is_musl(&release));
}
#[cfg(target_os = "linux")]
#[test]
fn test_os_release_id_like_alpine_is_musl() {
let release = LinuxOsRelease::parse("ID=postmarketos\nID_LIKE=\"alpine\"\n").unwrap();
assert!(linux_os_release_is_musl(&release));
}
#[cfg(target_os = "linux")]
#[test]
fn test_os_release_debian_returns_false() {
let release = LinuxOsRelease::parse("ID=debian\nID_LIKE=\"\"\n").unwrap();
assert!(!linux_os_release_is_musl(&release));
}
#[test]
fn test_os_release_missing_id_returns_none() {
assert_eq!(LinuxOsRelease::parse("NAME=\"Missing ID\"\n"), None);
}
#[test]
fn test_os_release_comments_and_blank_lines_do_not_short_circuit() {
let release =
LinuxOsRelease::parse("# this is a comment\n\nNAME=\"Alpine Linux\"\nID=alpine\n")
.unwrap();
assert_eq!(release.id, "alpine");
}
#[test]
fn test_os_release_whitespace_around_key_tolerated() {
let release = LinuxOsRelease::parse(" ID = alpine \n").unwrap();
assert_eq!(release.id, "alpine");
}
#[test]
fn test_linux_os_release_parse_id_version_and_id_like() {
let release = LinuxOsRelease::parse(
r#"
NAME="Ubuntu"
ID=ubuntu
VERSION_ID="24.04"
ID_LIKE="debian"
"#,
)
.unwrap();
assert_eq!(release.id, "ubuntu");
assert_eq!(release.version_id, "24.04");
assert_eq!(release.id_like, vec!["debian"]);
}
#[test]
fn test_linux_os_release_parse_quoted_escapes() {
let release = LinuxOsRelease::parse(
r#"
ID="custom\"id"
VERSION_ID='1.2'
"#,
)
.unwrap();
assert_eq!(release.id, "custom\"id");
assert_eq!(release.version_id, "1.2");
}
}