zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! `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."));
    }
}