//! `OsPattern` — the matcher behind `Condition::Os { os = "…" }`.
//!
//! TOML surface (single string) is open-ended on the distro side:
//!
//! ```toml
//! os = "linux" # any Linux host
//! os = "macos" # any macOS host
//! os = "fedora" # any Fedora
//! os = "fedora-42" # Fedora 42 specifically
//! os = "ubuntu-24.04"
//! os = "arch" # also matches EndeavourOS, CachyOS, ... via ID_LIKE
//! os = "debian" # never seen before? no problem — parses, just never matches
//! os = "opensuse-tumbleweed" # IDs with dashes are fine
//! ```
//!
//! Linux/macOS are wildcard tokens matched against [`super::OsFamily`].
//! Any other string is a distro pattern: `id` is matched against the
//! host's `distro_id` *or* any entry in its `distro_id_like` chain (so
//! `os = "ubuntu"` accepts Pop!_OS). Versions, when present, must equal
//! `distro_version_id` literally — no numeric / semver interpretation.
//!
//! Splitting `<id>-<version>`: we split on the *last* `-` and treat the
//! trailing segment as a version *only* if it looks like one (digits and
//! dots). That keeps `opensuse-tumbleweed` parsing as a single id while
//! `fedora-42` splits correctly.
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use smol_str::SmolStr;
use super::{OsFamily, Platform};
/// A host pattern matched against a [`Platform`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OsPattern {
/// Matches any Linux host.
Linux,
/// Matches any macOS host.
Macos,
/// Matches a distro by id (with optional version).
///
/// `id` is checked against the host's `distro_id` first, then its
/// `distro_id_like` chain. `version`, when present, is compared
/// verbatim against `distro_version_id`.
Distro {
/// The distro identifier to match, e.g. `"fedora"`, `"ubuntu"`,
/// `"opensuse-tumbleweed"`. Stored as it was written; matches are
/// case-sensitive and the host's `distro_id` is matched literally.
id: SmolStr,
/// Optional `VERSION_ID` to match verbatim, e.g. `"42"`, `"24.04"`.
version: Option<SmolStr>,
},
}
impl OsPattern {
/// Does this pattern accept `platform`?
pub fn matches(&self, platform: &Platform) -> bool {
match self {
Self::Linux => matches!(platform.os_family(), OsFamily::Linux),
Self::Macos => matches!(platform.os_family(), OsFamily::Macos),
Self::Distro { id, version } => {
let id_str = id.as_str();
let id_matches = platform.distro_id() == Some(id_str)
|| platform
.distro_id_like()
.iter()
.any(|x| x.as_str() == id_str);
let version_matches = match version {
None => true,
Some(want) => platform.distro_version_id() == Some(want.as_str()),
};
id_matches && version_matches
}
}
}
}
/// Parse a TOML-surface token into an [`OsPattern`]. Rejects empty
/// strings; everything else parses, including distro IDs zenops has
/// never heard of (they just never match at runtime).
pub(crate) fn parse(s: &str) -> Result<OsPattern, String> {
if s.is_empty() {
return Err("os pattern must not be empty".to_string());
}
match s {
"linux" => return Ok(OsPattern::Linux),
"macos" => return Ok(OsPattern::Macos),
_ => {}
}
if let Some((id_part, ver_part)) = split_trailing_version(s) {
return Ok(OsPattern::Distro {
id: SmolStr::new(id_part),
version: Some(SmolStr::new(ver_part)),
});
}
Ok(OsPattern::Distro {
id: SmolStr::new(s),
version: None,
})
}
/// Split `<id>-<version>` when the trailing segment looks like a
/// version (digits and dots only). Returns `None` when there's no
/// `-` or the trailing segment isn't version-shaped (e.g. for
/// `"opensuse-tumbleweed"`, where `tumbleweed` is part of the id).
fn split_trailing_version(s: &str) -> Option<(&str, &str)> {
let dash = s.rfind('-')?;
let (id_part, rest) = s.split_at(dash);
let ver_part = &rest[1..];
if id_part.is_empty() {
return None;
}
if is_version_segment(ver_part) {
Some((id_part, ver_part))
} else {
None
}
}
fn is_version_segment(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.split('.')
.all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit()))
}
impl std::fmt::Display for OsPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OsPattern::Linux => f.write_str("linux"),
OsPattern::Macos => f.write_str("macos"),
OsPattern::Distro { id, version: None } => f.write_str(id),
OsPattern::Distro {
id,
version: Some(v),
} => write!(f, "{id}-{v}"),
}
}
}
impl<'de> Deserialize<'de> for OsPattern {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s: SmolStr = SmolStr::deserialize(d)?;
parse(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_string())
}
}
impl schemars::JsonSchema for OsPattern {
fn schema_name() -> std::borrow::Cow<'static, str> {
"OsPattern".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
// No `pattern`: `parse` accepts any non-empty string on purpose,
// and a stricter schema would have an editor flag config that
// loads fine — os-release IDs carry underscores (`sles_sap`) and
// uppercase in the wild.
schemars::json_schema!({
"type": "string",
"minLength": 1,
"description": "A host pattern, written as a single TOML string.\n\nMatch a whole platform with `linux` or `macos`. Match a distro family with its os-release `ID` (e.g. `fedora`, `ubuntu`, `arch`, `debian`, `opensuse-tumbleweed`); the match walks `ID_LIKE` too, so `ubuntu` accepts Pop!_OS and `arch` accepts EndeavourOS. Pin a release by appending the `VERSION_ID` after a dash: `fedora-42`, `ubuntu-24.04`. Unknown ids parse cleanly and simply never match — zenops won't refuse your config because it hasn't met your distro yet.",
"examples": ["linux", "macos", "fedora", "fedora-42", "ubuntu-24.04", "arch", "debian", "opensuse-tumbleweed"],
})
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::platform::{Platform, identity::Identity};
use crate::utils::which::SearchPath;
fn platform_with(
family: OsFamily,
id: Option<&str>,
id_like: &[&str],
version: Option<&str>,
) -> Platform {
Platform::for_test_with_identity(
PathBuf::from("/home/test"),
SearchPath::new(Vec::<PathBuf>::new()),
SmolStr::new_static("x86_64"),
family,
Identity {
distro_id: id.map(SmolStr::new),
distro_id_like: id_like.iter().map(|s| SmolStr::new(*s)).collect(),
distro_version_id: version.map(SmolStr::new),
},
SmolStr::new_static("host"),
None,
None,
Vec::new(),
)
}
#[test]
fn linux_pattern_matches_any_linux() {
let host = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
assert!(OsPattern::Linux.matches(&host));
}
#[test]
fn linux_pattern_rejects_macos() {
let host = platform_with(OsFamily::Macos, None, &[], None);
assert!(!OsPattern::Linux.matches(&host));
}
#[test]
fn macos_pattern_matches_macos_only() {
let mac = platform_with(OsFamily::Macos, None, &[], None);
let lin = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
assert!(OsPattern::Macos.matches(&mac));
assert!(!OsPattern::Macos.matches(&lin));
}
#[test]
fn linux_pattern_rejects_other_unix() {
// OsFamily::Other("freebsd") is the canonical "we don't know"
// case — `linux` must not accept it just because it's POSIX.
let host = platform_with(
OsFamily::Other(SmolStr::new_static("freebsd")),
None,
&[],
None,
);
assert!(!OsPattern::Linux.matches(&host));
assert!(!OsPattern::Macos.matches(&host));
}
#[test]
fn distro_pattern_matches_by_literal_id() {
let host = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
let pat = parse("fedora").unwrap();
assert!(pat.matches(&host));
}
#[test]
fn distro_pattern_matches_through_id_like() {
// EndeavourOS sets `ID=endeavouros ID_LIKE=arch`; `os = "arch"`
// should match.
let host = platform_with(OsFamily::Linux, Some("endeavouros"), &["arch"], None);
let pat = parse("arch").unwrap();
assert!(pat.matches(&host));
}
#[test]
fn distro_pattern_matches_through_multi_id_like() {
// Pop!_OS sets `ID=pop ID_LIKE="ubuntu debian"` — both ubuntu
// and debian patterns should match.
let host = platform_with(
OsFamily::Linux,
Some("pop"),
&["ubuntu", "debian"],
Some("22.04"),
);
assert!(parse("ubuntu").unwrap().matches(&host));
assert!(parse("debian").unwrap().matches(&host));
assert!(parse("pop").unwrap().matches(&host));
}
#[test]
fn distro_pattern_rejects_unrelated_distro() {
let host = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
assert!(!parse("ubuntu").unwrap().matches(&host));
assert!(!parse("arch").unwrap().matches(&host));
}
#[test]
fn versioned_pattern_matches_only_that_version() {
let host = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
assert!(parse("fedora-42").unwrap().matches(&host));
assert!(!parse("fedora-43").unwrap().matches(&host));
}
#[test]
fn versioned_pattern_matches_through_id_like() {
// Bazzite: ID=bazzite ID_LIKE=fedora VERSION_ID=40 — the user
// can target `fedora-40` and it should match.
let host = platform_with(OsFamily::Linux, Some("bazzite"), &["fedora"], Some("40"));
assert!(parse("fedora-40").unwrap().matches(&host));
assert!(!parse("fedora-41").unwrap().matches(&host));
}
#[test]
fn unknown_distro_parses_and_never_matches_other_hosts() {
// Key property: `os = "haiku"` on a Fedora host must parse and
// be falsy, not blow up config loading.
let host = platform_with(OsFamily::Linux, Some("fedora"), &[], Some("42"));
let pat = parse("haiku").unwrap();
assert!(!pat.matches(&host));
}
#[test]
fn opensuse_tumbleweed_is_a_single_id_not_id_plus_version() {
// The id contains a dash; the trailing segment isn't numeric so
// we must NOT split it.
let host = platform_with(
OsFamily::Linux,
Some("opensuse-tumbleweed"),
&["opensuse", "suse"],
Some("20240601"),
);
let pat = parse("opensuse-tumbleweed").unwrap();
assert!(pat.matches(&host));
// And the same pattern with a real numeric version still works:
let pat = parse("opensuse-tumbleweed-20240601").unwrap();
assert!(pat.matches(&host));
}
#[test]
fn underscored_os_release_id_parses_and_matches() {
// SUSE ships `ID=sles_sap`. `parse` takes any non-empty string,
// and the JSON schema deliberately doesn't narrow that — an
// editor must not flag a config that loads fine.
let host = platform_with(
OsFamily::Linux,
Some("sles_sap"),
&["sles", "suse"],
Some("15.5"),
);
assert!(parse("sles_sap").unwrap().matches(&host));
}
#[test]
fn arch_no_version_id_still_matches() {
let host = platform_with(OsFamily::Linux, Some("arch"), &[], None);
assert!(parse("arch").unwrap().matches(&host));
}
#[test]
fn empty_string_rejected() {
assert!(parse("").is_err());
}
#[test]
fn round_trips_through_toml() {
let cases = [
"linux",
"macos",
"fedora",
"fedora-42",
"ubuntu",
"ubuntu-24.04",
"arch",
"debian",
"opensuse-tumbleweed",
"opensuse-tumbleweed-20240601",
];
for token in cases {
#[derive(serde::Deserialize, serde::Serialize)]
struct Holder {
v: OsPattern,
}
let h: Holder = toml::from_str(&format!(r#"v = "{token}""#))
.unwrap_or_else(|e| panic!("parse {token}: {e}"));
let back = toml::to_string(&h).unwrap();
assert!(
back.contains(&format!("v = \"{token}\"")),
"round-trip of {token} produced {back}",
);
}
}
#[test]
fn is_version_segment_accepts_dotted_digits() {
assert!(is_version_segment("42"));
assert!(is_version_segment("24.04"));
assert!(is_version_segment("20240601"));
assert!(is_version_segment("1.2.3"));
}
#[test]
fn is_version_segment_rejects_non_digits() {
assert!(!is_version_segment("rawhide"));
assert!(!is_version_segment("tumbleweed"));
assert!(!is_version_segment(""));
assert!(!is_version_segment("42a"));
assert!(!is_version_segment("."));
assert!(!is_version_segment("24."));
}
}