zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Best-effort `/etc/os-release` parsing.
//!
//! Returns the raw `ID`, `ID_LIKE` chain, and `VERSION_ID` strings, with
//! no interpretation. Missing fields become `None` / empty; the file
//! itself being absent or unreadable is also fine — callers get a blank
//! [`Identity`] and continue. Nothing here errors.

use smol_str::SmolStr;

const OS_RELEASE_PATH: &str = "/etc/os-release";

/// Raw distro identity, as written in `/etc/os-release`. None of the
/// fields are interpreted: a `VERSION_ID` of `"rawhide"` is the literal
/// string `"rawhide"`, not an error.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Identity {
    /// `os-release.ID`, e.g. `"fedora"`, `"ubuntu"`, `"opensuse-tumbleweed"`.
    pub distro_id: Option<SmolStr>,
    /// `os-release.ID_LIKE`, space-split. E.g. EndeavourOS sets
    /// `ID_LIKE=arch`; Pop!_OS sets `ID_LIKE="ubuntu debian"`.
    pub distro_id_like: Vec<SmolStr>,
    /// `os-release.VERSION_ID`, e.g. `"42"`, `"24.04"`, `"rawhide"`,
    /// or absent for rolling distros.
    pub distro_version_id: Option<SmolStr>,
}

impl Identity {
    /// Read and parse the host's `/etc/os-release`. Returns a blank
    /// [`Identity`] if the file is missing or unreadable — never errors.
    pub fn detect() -> Self {
        match std::fs::read_to_string(OS_RELEASE_PATH) {
            Ok(body) => parse(&body),
            Err(_) => Identity::default(),
        }
    }
}

/// Parse `/etc/os-release` body. Format is `KEY=value` per line; values
/// may be double-quoted; empty / comment lines are ignored. Unknown keys
/// are silently skipped. Empty values are treated as absent.
pub(crate) fn parse(body: &str) -> Identity {
    let mut id = None;
    let mut id_like = Vec::new();
    let mut version_id = 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('"');
        if value.is_empty() {
            continue;
        }
        match key.trim() {
            "ID" => id = Some(SmolStr::new(value)),
            "ID_LIKE" => {
                id_like = value.split_whitespace().map(SmolStr::new).collect();
            }
            "VERSION_ID" => version_id = Some(SmolStr::new(value)),
            _ => {}
        }
    }
    Identity {
        distro_id: id,
        distro_id_like: id_like,
        distro_version_id: version_id,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_fedora_sample() {
        let body = r#"NAME="Fedora Linux"
VERSION="42 (Cloud Edition)"
ID=fedora
VERSION_ID=42
PRETTY_NAME="Fedora Linux 42 (Cloud Edition)"
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("fedora"));
        assert!(id.distro_id_like.is_empty());
        assert_eq!(id.distro_version_id.as_deref(), Some("42"));
    }

    #[test]
    fn parses_ubuntu_sample() {
        let body = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
ID=ubuntu
ID_LIKE=debian
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("ubuntu"));
        assert_eq!(id.distro_id_like, vec![SmolStr::new("debian")]);
        assert_eq!(id.distro_version_id.as_deref(), Some("24.04"));
    }

    #[test]
    fn parses_arch_sample_no_version_id() {
        let body = r#"NAME="Arch Linux"
ID=arch
BUILD_ID=rolling
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("arch"));
        assert!(id.distro_id_like.is_empty());
        assert_eq!(id.distro_version_id, None);
    }

    #[test]
    fn parses_endeavouros_id_like_arch() {
        let body = r#"NAME="EndeavourOS"
PRETTY_NAME="EndeavourOS"
ID=endeavouros
ID_LIKE=arch
BUILD_ID=rolling
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("endeavouros"));
        assert_eq!(id.distro_id_like, vec![SmolStr::new("arch")]);
        assert_eq!(id.distro_version_id, None);
    }

    #[test]
    fn parses_popos_multi_id_like() {
        let body = r#"NAME="Pop!_OS"
ID=pop
ID_LIKE="ubuntu debian"
VERSION_ID="22.04"
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("pop"));
        assert_eq!(
            id.distro_id_like,
            vec![SmolStr::new("ubuntu"), SmolStr::new("debian")]
        );
        assert_eq!(id.distro_version_id.as_deref(), Some("22.04"));
    }

    #[test]
    fn parses_debian_sample() {
        let body = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
ID=debian
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("debian"));
        assert!(id.distro_id_like.is_empty());
        assert_eq!(id.distro_version_id.as_deref(), Some("12"));
    }

    #[test]
    fn parses_nixos_sample() {
        let body = r#"NAME=NixOS
ID=nixos
VERSION="24.05 (Uakari)"
VERSION_CODENAME=uakari
VERSION_ID="24.05"
BUILD_ID="24.05.20240601.abcdef0"
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("nixos"));
        assert!(id.distro_id_like.is_empty());
        assert_eq!(id.distro_version_id.as_deref(), Some("24.05"));
    }

    #[test]
    fn parses_opensuse_tumbleweed_with_dashed_id() {
        let body = r#"NAME="openSUSE Tumbleweed"
ID="opensuse-tumbleweed"
ID_LIKE="opensuse suse"
VERSION_ID="20240601"
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("opensuse-tumbleweed"));
        assert_eq!(
            id.distro_id_like,
            vec![SmolStr::new("opensuse"), SmolStr::new("suse")]
        );
        assert_eq!(id.distro_version_id.as_deref(), Some("20240601"));
    }

    #[test]
    fn parses_bazzite_sample() {
        let body = r#"NAME="Bazzite"
ID=bazzite
ID_LIKE="fedora"
VERSION_ID="40"
VARIANT_ID="bazzite"
"#;
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("bazzite"));
        assert_eq!(id.distro_id_like, vec![SmolStr::new("fedora")]);
        assert_eq!(id.distro_version_id.as_deref(), Some("40"));
    }

    #[test]
    fn empty_body_returns_blank_identity() {
        let id = parse("");
        assert_eq!(id, Identity::default());
    }

    #[test]
    fn unknown_keys_silently_ignored() {
        let id = parse("FOO=bar\nBAZ=qux\nID=fedora\n");
        assert_eq!(id.distro_id.as_deref(), Some("fedora"));
    }

    #[test]
    fn comments_and_blank_lines_ignored() {
        let body = "# a comment\n\n  \nID=ubuntu\n  # another\nVERSION_ID=24.04\n";
        let id = parse(body);
        assert_eq!(id.distro_id.as_deref(), Some("ubuntu"));
        assert_eq!(id.distro_version_id.as_deref(), Some("24.04"));
    }

    #[test]
    fn empty_string_values_treated_as_absent() {
        // Some hosts emit `ID=""`; treat as missing rather than ID == "".
        let id = parse("ID=\"\"\nVERSION_ID=\"\"\n");
        assert_eq!(id.distro_id, None);
        assert_eq!(id.distro_version_id, None);
    }

    #[test]
    fn double_quoted_values_stripped() {
        let id = parse("ID=\"fedora\"\nVERSION_ID=\"42\"\n");
        assert_eq!(id.distro_id.as_deref(), Some("fedora"));
        assert_eq!(id.distro_version_id.as_deref(), Some("42"));
    }

    #[test]
    fn rolling_version_id_string_kept_verbatim() {
        // Old model rejected this; new model just carries it.
        let id = parse("ID=fedora\nVERSION_ID=rawhide\n");
        assert_eq!(id.distro_version_id.as_deref(), Some("rawhide"));
    }
}