use std::path::Path;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use smol_str::SmolStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
Linux(Linux),
Macos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Linux {
pub distro: Distro,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distro {
Fedora(Fedora),
Ubuntu(Ubuntu),
Arch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fedora {
pub version: FedoraVersion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedoraVersion {
F42,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Ubuntu {
pub version: UbuntuVersion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UbuntuVersion {
U2404,
}
impl Os {
pub fn current() -> Result<Self, Error> {
match std::env::consts::OS {
"macos" => Ok(Os::Macos),
"linux" => detect_linux(),
other => Err(Error::UnsupportedPlatform(other)),
}
}
}
impl std::fmt::Display for Os {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Os::Macos => f.write_str("macos"),
Os::Linux(linux) => match linux.distro {
Distro::Fedora(Fedora {
version: FedoraVersion::F42,
}) => f.write_str("fedora 42"),
Distro::Ubuntu(Ubuntu {
version: UbuntuVersion::U2404,
}) => f.write_str("ubuntu 24.04"),
Distro::Arch => f.write_str("arch"),
},
}
}
}
const OS_RELEASE_PATH: &str = "/etc/os-release";
fn detect_linux() -> Result<Os, Error> {
let body = std::fs::read_to_string(Path::new(OS_RELEASE_PATH)).map_err(Error::OsReleaseRead)?;
let (id, version_id) = parse_os_release(&body)?;
let distro = match (id.as_str(), version_id.as_deref()) {
("fedora", Some("42")) => Distro::Fedora(Fedora {
version: FedoraVersion::F42,
}),
("ubuntu", Some("24.04")) => Distro::Ubuntu(Ubuntu {
version: UbuntuVersion::U2404,
}),
("arch", _) => Distro::Arch,
_ => {
return Err(Error::UnsupportedHost {
id,
version_id: version_id.unwrap_or_default(),
});
}
};
Ok(Os::Linux(Linux { distro }))
}
fn parse_os_release(body: &str) -> Result<(String, Option<String>), Error> {
let mut id: Option<String> = None;
let mut version_id: Option<String> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches('"').to_string();
match key.trim() {
"ID" => id = Some(value),
"VERSION_ID" => version_id = Some(value),
_ => {}
}
}
let id = id
.filter(|s| !s.is_empty())
.ok_or(Error::OsReleaseMissingField("ID"))?;
let version_id = version_id.filter(|s| !s.is_empty());
Ok((id, version_id))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsPattern {
Linux,
LinuxDistro(DistroPattern),
Macos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DistroPattern {
Fedora,
FedoraVersion(FedoraVersion),
Ubuntu,
UbuntuVersion(UbuntuVersion),
Arch,
}
impl OsPattern {
pub fn matches(&self, host: &Os) -> bool {
match (self, host) {
(OsPattern::Linux, Os::Linux(_)) => true,
(OsPattern::Macos, Os::Macos) => true,
(OsPattern::LinuxDistro(dp), Os::Linux(l)) => dp.matches(&l.distro),
(OsPattern::Linux, Os::Macos)
| (OsPattern::Macos, Os::Linux(_))
| (OsPattern::LinuxDistro(_), Os::Macos) => false,
}
}
}
impl DistroPattern {
pub fn matches(&self, host: &Distro) -> bool {
match (self, host) {
(DistroPattern::Fedora, Distro::Fedora(_)) => true,
(DistroPattern::FedoraVersion(want), Distro::Fedora(f)) => *want == f.version,
(DistroPattern::Ubuntu, Distro::Ubuntu(_)) => true,
(DistroPattern::UbuntuVersion(want), Distro::Ubuntu(u)) => *want == u.version,
(DistroPattern::Arch, Distro::Arch) => true,
(DistroPattern::Fedora, Distro::Ubuntu(_) | Distro::Arch)
| (DistroPattern::FedoraVersion(_), Distro::Ubuntu(_) | Distro::Arch)
| (DistroPattern::Ubuntu, Distro::Fedora(_) | Distro::Arch)
| (DistroPattern::UbuntuVersion(_), Distro::Fedora(_) | Distro::Arch)
| (DistroPattern::Arch, Distro::Fedora(_) | Distro::Ubuntu(_)) => false,
}
}
}
const SUPPORTED_TOKENS: &[&str] = &[
"linux",
"macos",
"fedora",
"fedora-42",
"ubuntu",
"ubuntu-24.04",
"arch",
];
impl OsPattern {
fn from_token(s: &str) -> Result<Self, String> {
match s {
"linux" => Ok(OsPattern::Linux),
"macos" => Ok(OsPattern::Macos),
"fedora" => Ok(OsPattern::LinuxDistro(DistroPattern::Fedora)),
"fedora-42" => Ok(OsPattern::LinuxDistro(DistroPattern::FedoraVersion(
FedoraVersion::F42,
))),
"ubuntu" => Ok(OsPattern::LinuxDistro(DistroPattern::Ubuntu)),
"ubuntu-24.04" => Ok(OsPattern::LinuxDistro(DistroPattern::UbuntuVersion(
UbuntuVersion::U2404,
))),
"arch" => Ok(OsPattern::LinuxDistro(DistroPattern::Arch)),
other => Err(format!(
"unknown os value '{other}'; supported: {}",
SUPPORTED_TOKENS
.iter()
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn to_token(self) -> &'static str {
match self {
OsPattern::Linux => "linux",
OsPattern::Macos => "macos",
OsPattern::LinuxDistro(DistroPattern::Fedora) => "fedora",
OsPattern::LinuxDistro(DistroPattern::FedoraVersion(FedoraVersion::F42)) => "fedora-42",
OsPattern::LinuxDistro(DistroPattern::Ubuntu) => "ubuntu",
OsPattern::LinuxDistro(DistroPattern::UbuntuVersion(UbuntuVersion::U2404)) => {
"ubuntu-24.04"
}
OsPattern::LinuxDistro(DistroPattern::Arch) => "arch",
}
}
}
impl<'de> Deserialize<'de> for OsPattern {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s: SmolStr = SmolStr::deserialize(d)?;
OsPattern::from_token(s.as_str()).map_err(de::Error::custom)
}
}
impl Serialize for OsPattern {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.to_token())
}
}
impl schemars::JsonSchema for OsPattern {
fn schema_name() -> std::borrow::Cow<'static, str> {
"OsPattern".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": SUPPORTED_TOKENS,
"description": "Host pattern. 'linux' / 'macos' match any supported version; 'fedora' matches any supported Fedora release; '<distro>-<version>' (e.g. 'fedora-42') pins an exact release.",
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Unsupported platform: {0}")]
UnsupportedPlatform(&'static str),
#[error("Unsupported host: ID={id} VERSION_ID={version_id}")]
UnsupportedHost {
id: String,
version_id: String,
},
#[error("Failed to read /etc/os-release: {0}")]
OsReleaseRead(std::io::Error),
#[error("/etc/os-release is missing required field: {0}")]
OsReleaseMissingField(&'static str),
}
impl PartialEq for Error {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::UnsupportedPlatform(a), Self::UnsupportedPlatform(b)) => a == b,
(
Self::UnsupportedHost {
id: a,
version_id: b,
},
Self::UnsupportedHost {
id: c,
version_id: d,
},
) => a == c && b == d,
(Self::OsReleaseRead(a), Self::OsReleaseRead(b)) => a.kind() == b.kind(),
(Self::OsReleaseMissingField(a), Self::OsReleaseMissingField(b)) => a == b,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fedora42() -> Os {
Os::Linux(Linux {
distro: Distro::Fedora(Fedora {
version: FedoraVersion::F42,
}),
})
}
fn arch_host() -> Os {
Os::Linux(Linux {
distro: Distro::Arch,
})
}
#[test]
fn parse_os_release_real_fedora_sample() {
let body = r#"NAME="Fedora Linux"
VERSION="42 (Cloud Edition)"
RELEASE_TYPE=stable
ID=fedora
VERSION_ID=42
VERSION_CODENAME=""
PLATFORM_ID="platform:f42"
PRETTY_NAME="Fedora Linux 42 (Cloud Edition)"
"#;
let (id, version_id) = parse_os_release(body).unwrap();
assert_eq!(id, "fedora");
assert_eq!(version_id.as_deref(), Some("42"));
}
#[test]
fn parse_os_release_strips_double_quotes() {
let body = "ID=\"fedora\"\nVERSION_ID=\"42\"\n";
let (id, version_id) = parse_os_release(body).unwrap();
assert_eq!(id, "fedora");
assert_eq!(version_id.as_deref(), Some("42"));
}
#[test]
fn parse_os_release_missing_id_errors() {
assert_eq!(
parse_os_release("VERSION_ID=42\n").unwrap_err(),
Error::OsReleaseMissingField("ID"),
);
}
#[test]
fn parse_os_release_missing_version_id_returns_none() {
let (id, version_id) = parse_os_release("ID=arch\nBUILD_ID=rolling\n").unwrap();
assert_eq!(id, "arch");
assert_eq!(version_id, None);
}
#[test]
fn parse_os_release_empty_version_id_treated_as_none() {
let (id, version_id) = parse_os_release("ID=foo\nVERSION_ID=\"\"\n").unwrap();
assert_eq!(id, "foo");
assert_eq!(version_id, None);
}
#[test]
fn parse_os_release_empty_id_treated_as_missing() {
assert_eq!(
parse_os_release("ID=\"\"\nVERSION_ID=42\n").unwrap_err(),
Error::OsReleaseMissingField("ID"),
);
}
#[test]
fn parse_os_release_arch_sample() {
let body = r#"NAME="Arch Linux"
PRETTY_NAME="Arch Linux"
ID=arch
BUILD_ID=rolling
ANSI_COLOR="38;2;23;147;209"
HOME_URL="https://archlinux.org/"
"#;
let (id, version_id) = parse_os_release(body).unwrap();
assert_eq!(id, "arch");
assert_eq!(version_id, None);
}
#[test]
fn pattern_linux_matches_any_linux_host() {
assert!(OsPattern::Linux.matches(&fedora42()));
assert!(!OsPattern::Linux.matches(&Os::Macos));
}
#[test]
fn pattern_macos_does_not_match_linux() {
assert!(OsPattern::Macos.matches(&Os::Macos));
assert!(!OsPattern::Macos.matches(&fedora42()));
}
#[test]
fn pattern_fedora_matches_any_fedora_version() {
let p = OsPattern::LinuxDistro(DistroPattern::Fedora);
assert!(p.matches(&fedora42()));
assert!(!p.matches(&Os::Macos));
}
#[test]
fn pattern_arch_matches_arch_host_only() {
let p = OsPattern::LinuxDistro(DistroPattern::Arch);
assert!(p.matches(&arch_host()));
assert!(!p.matches(&fedora42()));
assert!(!p.matches(&Os::Macos));
}
#[test]
fn pattern_linux_matches_arch_host_too() {
assert!(OsPattern::Linux.matches(&arch_host()));
}
#[test]
fn display_renders_arch_without_version() {
assert_eq!(arch_host().to_string(), "arch");
}
#[test]
fn pattern_fedora_42_matches_exact_release() {
let p = OsPattern::LinuxDistro(DistroPattern::FedoraVersion(FedoraVersion::F42));
assert!(p.matches(&fedora42()));
assert!(!p.matches(&Os::Macos));
}
#[test]
fn pattern_round_trips_through_toml_string() {
for token in SUPPORTED_TOKENS {
#[derive(serde::Deserialize, serde::Serialize)]
struct Holder {
v: OsPattern,
}
let h: Holder = toml::from_str(&format!(r#"v = "{token}""#)).unwrap();
let back = toml::to_string(&h).unwrap();
assert!(
back.contains(&format!("v = \"{token}\"")),
"expected `v = \"{token}\"` in {back}"
);
}
}
#[test]
fn display_renders_macos_and_fedora_42() {
assert_eq!(Os::Macos.to_string(), "macos");
assert_eq!(fedora42().to_string(), "fedora 42");
}
#[test]
fn pattern_rejects_unknown_token() {
#[derive(Debug, serde::Deserialize)]
struct Holder {
#[allow(dead_code)]
v: OsPattern,
}
let err = toml::from_str::<Holder>(r#"v = "windows""#)
.unwrap_err()
.to_string();
assert!(err.contains("unknown os value 'windows'"), "got: {err}");
assert!(err.contains("\"linux\""), "got: {err}");
assert!(err.contains("\"fedora-42\""), "got: {err}");
}
}