use nono::{NonoError, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformInfo {
pub os: Os,
pub linux: Option<LinuxInfo>,
pub macos: Option<MacosInfo>,
pub windows: Option<WindowsInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Os {
Linux,
Macos,
Windows,
Unknown(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LinuxInfo {
pub distro: Option<String>,
pub distro_like: Vec<String>,
pub version_id: Option<String>,
pub variant_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MacosInfo {
pub product_version: String,
pub build_version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WindowsInfo {
pub product_name: String,
pub version: String,
pub edition: Option<String>,
}
pub fn current() -> &'static PlatformInfo {
static CURRENT: OnceLock<PlatformInfo> = OnceLock::new();
CURRENT.get_or_init(detect)
}
pub fn current_os_name() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
}
}
fn detect() -> PlatformInfo {
if cfg!(target_os = "linux") {
PlatformInfo {
os: Os::Linux,
linux: Some(detect_linux()),
macos: None,
windows: None,
}
} else if cfg!(target_os = "macos") {
PlatformInfo {
os: Os::Macos,
linux: None,
macos: Some(detect_macos()),
windows: None,
}
} else if cfg!(target_os = "windows") {
PlatformInfo {
os: Os::Windows,
linux: None,
macos: None,
windows: Some(detect_windows()),
}
} else {
PlatformInfo {
os: Os::Unknown(current_os_name().to_string()),
linux: None,
macos: None,
windows: None,
}
}
}
fn detect_linux() -> LinuxInfo {
match std::fs::read_to_string("/etc/os-release") {
Ok(content) => parse_os_release(&content),
Err(_) => LinuxInfo::default(),
}
}
fn detect_macos() -> MacosInfo {
MacosInfo {
product_version: run_sw_vers("-productVersion").unwrap_or_default(),
build_version: run_sw_vers("-buildVersion").unwrap_or_default(),
}
}
fn detect_windows() -> WindowsInfo {
WindowsInfo {
product_name: query_windows_registry_value("ProductName").unwrap_or_default(),
version: detect_windows_version(),
edition: query_windows_registry_value("EditionID"),
}
}
fn detect_windows_version() -> String {
let major = query_windows_registry_value("CurrentMajorVersionNumber");
let minor = query_windows_registry_value("CurrentMinorVersionNumber");
let build = query_windows_registry_value("CurrentBuildNumber");
match (major, minor, build) {
(Some(major), Some(minor), Some(build)) => format!("{major}.{minor}.{build}"),
(Some(major), None, Some(build)) => format!("{major}.0.{build}"),
_ => query_windows_registry_value("CurrentVersion").unwrap_or_default(),
}
}
fn query_windows_registry_value(name: &str) -> Option<String> {
let output = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v",
name,
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_windows_registry_value(&String::from_utf8(output.stdout).ok()?, name)
}
fn parse_windows_registry_value(output: &str, name: &str) -> Option<String> {
for line in output.lines() {
let mut parts = line.split_whitespace();
if parts.next() != Some(name) {
continue;
}
let kind = parts.next()?;
let value = parts.collect::<Vec<_>>().join(" ");
if !value.is_empty() {
if kind == "REG_DWORD"
&& let Some(hex) = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
&& let Ok(number) = u64::from_str_radix(hex, 16)
{
return Some(number.to_string());
}
return Some(value);
}
}
None
}
fn run_sw_vers(arg: &str) -> Option<String> {
let output = std::process::Command::new("sw_vers")
.arg(arg)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn parse_os_release(content: &str) -> LinuxInfo {
let mut info = LinuxInfo::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, raw_value)) = line.split_once('=') else {
continue;
};
let value = unquote_os_release_value(raw_value.trim());
match key {
"ID" => info.distro = Some(value),
"ID_LIKE" => {
info.distro_like = value
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>();
}
"VERSION_ID" => info.version_id = Some(value),
"VARIANT_ID" => info.variant_id = Some(value),
_ => {}
}
}
info
}
fn unquote_os_release_value(value: &str) -> String {
let bytes = value.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
return value[1..value.len() - 1].to_string();
}
value.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct When {
predicates: Vec<Predicate>,
}
impl When {
pub(crate) fn parse(input: &str) -> Result<Self> {
Ok(Self {
predicates: vec![Predicate::parse(input)?],
})
}
pub fn matches(&self, platform: &PlatformInfo) -> bool {
self.predicates
.iter()
.any(|predicate| predicate.matches(platform))
}
}
impl Serialize for When {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let rendered = self
.predicates
.iter()
.map(Predicate::as_str)
.collect::<Vec<_>>();
if rendered.len() == 1 {
rendered[0].serialize(serializer)
} else {
rendered.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for When {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawWhen {
One(String),
Any(Vec<String>),
}
let raw = RawWhen::deserialize(deserializer)?;
let values = match raw {
RawWhen::One(value) => vec![value],
RawWhen::Any(values) => values,
};
if values.is_empty() {
return Err(serde::de::Error::custom(
"when predicate array must not be empty",
));
}
if values.len() == 1 {
return Self::parse(&values[0]).map_err(serde::de::Error::custom);
}
let mut predicates = Vec::with_capacity(values.len());
for value in values {
predicates.push(Predicate::parse(&value).map_err(serde::de::Error::custom)?);
}
Ok(Self { predicates })
}
}
pub fn when_matches_current(when: Option<&When>) -> Result<bool> {
Ok(when.is_none_or(|predicate| predicate.matches(current())))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Predicate {
raw: String,
negated: bool,
os: PredicateOs,
id: Option<String>,
version: Option<VersionConstraint>,
build: Option<VersionConstraint>,
variant: Option<String>,
}
impl Predicate {
fn parse(input: &str) -> Result<Self> {
let raw = input.trim();
if raw.is_empty() {
return Err(NonoError::ConfigParse(
"when predicate must not be empty".to_string(),
));
}
let (negated, body) = match raw.strip_prefix('!') {
Some(rest) if !rest.is_empty() => (true, rest),
Some(_) => {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': negation requires a predicate"
)));
}
None => (false, raw),
};
if body.contains('!') {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': '!' is only allowed at the start"
)));
}
let parts = body.split(':').collect::<Vec<_>>();
if parts.iter().any(|part| part.is_empty()) {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': empty ':' segment"
)));
}
let os = PredicateOs::from_token(parts[0])?;
let mut id = None;
let mut version = None;
let mut build = None;
let mut variant = None;
match os {
PredicateOs::Linux => match parts.len() {
1 => {}
2 => id = Some(validate_id(parts[1], raw)?.to_string()),
3 => {
id = Some(validate_id(parts[1], raw)?.to_string());
version = Some(VersionConstraint::parse(parts[2], raw)?);
}
4 => {
id = Some(validate_id(parts[1], raw)?.to_string());
version = Some(VersionConstraint::parse(parts[2], raw)?);
variant = Some(validate_id(parts[3], raw)?.to_string());
}
_ => {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': too many ':' segments"
)));
}
},
PredicateOs::Macos => match parts.len() {
1 => {}
2 => version = Some(VersionConstraint::parse(parts[1], raw)?),
_ => {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': too many ':' segments"
)));
}
},
PredicateOs::Windows => match parts.len() {
1 => {}
2 => version = Some(VersionConstraint::parse(parts[1], raw)?),
3 => {
version = Some(VersionConstraint::parse(parts[1], raw)?);
build = Some(VersionConstraint::parse(parts[2], raw)?);
}
_ => {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': too many ':' segments"
)));
}
},
PredicateOs::Unknown(_) => {
if parts.len() > 1 {
for part in &parts[1..] {
validate_id(part, raw)?;
}
}
}
}
Ok(Self {
raw: raw.to_string(),
negated,
os,
id,
version,
build,
variant,
})
}
fn as_str(&self) -> &str {
&self.raw
}
fn matches(&self, platform: &PlatformInfo) -> bool {
let matched = match &self.os {
PredicateOs::Linux => self.matches_linux(platform),
PredicateOs::Macos => self.matches_macos(platform),
PredicateOs::Windows => self.matches_windows(platform),
PredicateOs::Unknown(_) => false,
};
if self.negated { !matched } else { matched }
}
fn matches_linux(&self, platform: &PlatformInfo) -> bool {
if platform.os != Os::Linux {
return false;
}
let Some(info) = &platform.linux else {
return false;
};
if let Some(id) = &self.id {
if let Some(base) = id.strip_suffix("-like") {
if info.distro.as_deref() != Some(base)
&& !info.distro_like.iter().any(|like| like == base)
{
return false;
}
} else if info.distro.as_deref() != Some(id.as_str()) {
return false;
}
}
if let Some(version) = &self.version {
let Some(version_id) = info.version_id.as_deref() else {
return false;
};
if !version.matches(version_id) {
return false;
}
}
if let Some(variant) = &self.variant
&& info.variant_id.as_deref() != Some(variant.as_str())
{
return false;
}
true
}
fn matches_macos(&self, platform: &PlatformInfo) -> bool {
if platform.os != Os::Macos {
return false;
}
let Some(info) = &platform.macos else {
return false;
};
match &self.version {
Some(version) => version.matches(&info.product_version),
None => true,
}
}
fn matches_windows(&self, platform: &PlatformInfo) -> bool {
if platform.os != Os::Windows {
return false;
}
let Some(info) = &platform.windows else {
return false;
};
if let Some(version) = &self.version
&& !version.matches(&info.version)
{
return false;
}
if let Some(build) = &self.build {
let build_version = info.version.rsplit('.').next().map_or("", |part| part);
if !build.matches(build_version) {
return false;
}
}
true
}
}
fn validate_id<'a>(value: &'a str, raw: &str) -> Result<&'a str> {
if value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
Ok(value)
} else {
Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': invalid identifier segment '{value}'"
)))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PredicateOs {
Linux,
Macos,
Windows,
Unknown(String),
}
impl PredicateOs {
fn from_token(token: &str) -> Result<Self> {
validate_id(token, token)?;
Ok(match token {
"linux" => Self::Linux,
"macos" => Self::Macos,
"windows" => Self::Windows,
other => Self::Unknown(other.to_string()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct VersionConstraint {
op: VersionOp,
version: String,
}
impl VersionConstraint {
fn parse(value: &str, raw: &str) -> Result<Self> {
let (op, version) = if let Some(rest) = value.strip_prefix("==") {
(VersionOp::Eq, rest)
} else if let Some(rest) = value.strip_prefix(">=") {
(VersionOp::Gte, rest)
} else if let Some(rest) = value.strip_prefix("<=") {
(VersionOp::Lte, rest)
} else if let Some(rest) = value.strip_prefix('>') {
(VersionOp::Gt, rest)
} else if let Some(rest) = value.strip_prefix('<') {
(VersionOp::Lt, rest)
} else if value.starts_with(|c: char| !c.is_ascii_alphanumeric()) {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': unsupported version comparator in '{value}'"
)));
} else {
(VersionOp::Eq, value)
};
if version.is_empty() {
return Err(NonoError::ConfigParse(format!(
"invalid when predicate '{raw}': missing version"
)));
}
validate_id(version, raw)?;
Ok(Self {
op,
version: version.to_string(),
})
}
fn matches(&self, actual: &str) -> bool {
let ordering = compare_versions(actual, &self.version);
match self.op {
VersionOp::Eq => ordering == Ordering::Equal,
VersionOp::Gte => matches!(ordering, Ordering::Greater | Ordering::Equal),
VersionOp::Gt => ordering == Ordering::Greater,
VersionOp::Lte => matches!(ordering, Ordering::Less | Ordering::Equal),
VersionOp::Lt => ordering == Ordering::Less,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VersionOp {
Eq,
Gte,
Gt,
Lte,
Lt,
}
fn compare_versions(left: &str, right: &str) -> Ordering {
let left_parts = left.split('.').collect::<Vec<_>>();
let right_parts = right.split('.').collect::<Vec<_>>();
for (left_part, right_part) in left_parts.iter().zip(right_parts.iter()) {
let ordering = match (left_part.parse::<u64>(), right_part.parse::<u64>()) {
(Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num),
_ if left_part == right_part => Ordering::Equal,
_ => Ordering::Less,
};
if ordering != Ordering::Equal {
return ordering;
}
}
left_parts.len().cmp(&right_parts.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn fedora() -> PlatformInfo {
PlatformInfo {
os: Os::Linux,
linux: Some(LinuxInfo {
distro: Some("fedora".to_string()),
distro_like: vec!["rhel".to_string()],
version_id: Some("43".to_string()),
variant_id: Some("workstation".to_string()),
}),
macos: None,
windows: None,
}
}
fn macos() -> PlatformInfo {
PlatformInfo {
os: Os::Macos,
linux: None,
macos: Some(MacosInfo {
product_version: "15.6.1".to_string(),
build_version: "24G90".to_string(),
}),
windows: None,
}
}
#[test]
fn parse_os_release_handles_quotes_and_id_like() {
let info = parse_os_release(
r#"
ID=fedora
ID_LIKE="rhel centos"
VERSION_ID="43"
VARIANT_ID=workstation
"#,
);
assert_eq!(info.distro.as_deref(), Some("fedora"));
assert_eq!(info.distro_like, vec!["rhel", "centos"]);
assert_eq!(info.version_id.as_deref(), Some("43"));
assert_eq!(info.variant_id.as_deref(), Some("workstation"));
}
#[test]
fn linux_predicates_match_distro_like_version_and_variant() {
let platform = fedora();
assert!(When::parse("linux").expect("parse").matches(&platform));
assert!(
When::parse("linux:fedora")
.expect("parse")
.matches(&platform)
);
assert!(
When::parse("linux:rhel-like")
.expect("parse")
.matches(&platform)
);
assert!(
When::parse("linux:fedora:>=42")
.expect("parse")
.matches(&platform)
);
assert!(
When::parse("linux:fedora:43:workstation")
.expect("parse")
.matches(&platform)
);
assert!(
!When::parse("linux:ubuntu")
.expect("parse")
.matches(&platform)
);
}
#[test]
fn macos_version_predicates_match() {
let platform = macos();
assert!(When::parse("macos").expect("parse").matches(&platform));
assert!(When::parse("macos:>=15").expect("parse").matches(&platform));
assert!(!When::parse("macos:<15").expect("parse").matches(&platform));
}
#[test]
fn windows_build_predicate_parses_and_matches() {
let platform = PlatformInfo {
os: Os::Windows,
linux: None,
macos: None,
windows: Some(WindowsInfo {
product_name: "Windows 11 Pro".to_string(),
version: "10.0.22631".to_string(),
edition: Some("Professional".to_string()),
}),
};
assert!(
When::parse("windows:>=10:>=22000")
.expect("parse")
.matches(&platform)
);
assert!(
When::parse("windows:>=10:22631")
.expect("parse")
.matches(&platform)
);
assert!(
!When::parse("windows:>=10:>30000")
.expect("parse")
.matches(&platform)
);
}
#[test]
fn windows_registry_dword_values_are_decimalized() {
let output = r#"
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion
CurrentMajorVersionNumber REG_DWORD 0xa
"#;
assert_eq!(
parse_windows_registry_value(output, "CurrentMajorVersionNumber").as_deref(),
Some("10")
);
}
#[test]
fn negation_and_any_of_work() {
let platform = fedora();
assert!(When::parse("!macos").expect("parse").matches(&platform));
let when: When = serde_json::from_str(r#"["macos", "linux:fedora:>=43"]"#).expect("parse");
assert!(when.matches(&platform));
}
#[test]
fn unknown_os_is_false_not_error() {
let platform = fedora();
assert!(!When::parse("freebsd").expect("parse").matches(&platform));
}
#[test]
fn unknown_comparator_is_error() {
assert!(When::parse("linux:fedora:~=43").is_err());
}
#[test]
fn version_segments_compare_numerically_when_possible() {
assert_eq!(compare_versions("24.10", "24.4"), Ordering::Greater);
assert_eq!(compare_versions("24", "24.04"), Ordering::Less);
assert_eq!(compare_versions("unstable", "25.05"), Ordering::Less);
assert_eq!(compare_versions("unstable", "unstable"), Ordering::Equal);
}
}