Skip to main content

cfgd_core/platform/
mod.rs

1// Platform detection — OS, distro, architecture, native package manager mapping
2
3use std::collections::HashMap;
4use std::fs;
5
6/// Detected operating system.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Os {
9    Linux,
10    MacOS,
11    FreeBSD,
12    Windows,
13}
14
15/// Detected Linux distribution (or MacOS/FreeBSD pseudo-distro).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Distro {
18    Ubuntu,
19    Debian,
20    Fedora,
21    RHEL,
22    CentOS,
23    Arch,
24    Manjaro,
25    Alpine,
26    OpenSUSE,
27    FreeBSD,
28    MacOS,
29    Windows,
30    Unknown,
31}
32
33/// CPU architecture.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Arch {
36    X86_64,
37    Aarch64,
38    Other(String),
39}
40
41/// Detected platform: OS, distro, version, and architecture.
42#[derive(Debug, Clone)]
43pub struct Platform {
44    pub os: Os,
45    pub distro: Distro,
46    pub version: String,
47    pub arch: Arch,
48}
49
50impl Platform {
51    /// Detect the current platform.
52    ///
53    /// - macOS: uses `cfg!(target_os)` and `sw_vers` for version.
54    /// - Linux: parses `/etc/os-release`.
55    /// - FreeBSD: uses `cfg!(target_os)` and `freebsd-version`.
56    pub fn detect() -> Self {
57        let arch = detect_arch();
58
59        if cfg!(windows) {
60            return Platform {
61                os: Os::Windows,
62                distro: Distro::Windows,
63                version: String::new(),
64                arch,
65            };
66        }
67
68        if cfg!(target_os = "macos") {
69            let version = read_macos_version().unwrap_or_default();
70            return Platform {
71                os: Os::MacOS,
72                distro: Distro::MacOS,
73                version,
74                arch,
75            };
76        }
77
78        if cfg!(target_os = "freebsd") {
79            let version = read_command_output("freebsd-version", &[]).unwrap_or_default();
80            return Platform {
81                os: Os::FreeBSD,
82                distro: Distro::FreeBSD,
83                version: version.trim().to_string(),
84                arch,
85            };
86        }
87
88        // Linux — parse /etc/os-release
89        let (distro, version) = parse_os_release().unwrap_or((Distro::Unknown, String::new()));
90        Platform {
91            os: Os::Linux,
92            distro,
93            version,
94            arch,
95        }
96    }
97
98    /// Check whether this platform matches any tag in the given filter list.
99    /// Tags are matched against OS, distro, and arch names.
100    /// Returns `true` if any tag matches, or if the filter list is empty.
101    pub fn matches_any(&self, tags: &[String]) -> bool {
102        if tags.is_empty() {
103            return true;
104        }
105        tags.iter().any(|tag| {
106            tag == self.os.as_str() || tag == self.distro.as_str() || tag == self.arch.as_str()
107        })
108    }
109
110    /// Return the canonical native package manager name for this platform.
111    pub fn native_manager(&self) -> &str {
112        match self.distro {
113            Distro::MacOS => "brew",
114            Distro::Ubuntu | Distro::Debian => "apt",
115            Distro::Fedora => "dnf",
116            Distro::RHEL | Distro::CentOS => {
117                // RHEL 8+ and CentOS 8+ use dnf; 7 uses yum.
118                // If version starts with "7", use yum.
119                if self.version.starts_with('7') {
120                    "yum"
121                } else {
122                    "dnf"
123                }
124            }
125            Distro::Arch | Distro::Manjaro => "pacman",
126            Distro::Alpine => "apk",
127            Distro::OpenSUSE => "zypper",
128            Distro::FreeBSD => "pkg",
129            Distro::Windows => "winget",
130            Distro::Unknown => "apt", // best-effort default for unknown Linux
131        }
132    }
133}
134
135impl Os {
136    pub fn as_str(&self) -> &str {
137        match self {
138            Os::Linux => "linux",
139            Os::MacOS => "macos",
140            Os::FreeBSD => "freebsd",
141            Os::Windows => "windows",
142        }
143    }
144}
145
146impl Distro {
147    pub fn as_str(&self) -> &str {
148        match self {
149            Distro::Ubuntu => "ubuntu",
150            Distro::Debian => "debian",
151            Distro::Fedora => "fedora",
152            Distro::RHEL => "rhel",
153            Distro::CentOS => "centos",
154            Distro::Arch => "arch",
155            Distro::Manjaro => "manjaro",
156            Distro::Alpine => "alpine",
157            Distro::OpenSUSE => "opensuse",
158            Distro::FreeBSD => "freebsd",
159            Distro::MacOS => "macos",
160            Distro::Windows => "windows",
161            Distro::Unknown => "unknown",
162        }
163    }
164}
165
166impl Arch {
167    pub fn as_str(&self) -> &str {
168        match self {
169            Arch::X86_64 => "x86_64",
170            Arch::Aarch64 => "aarch64",
171            Arch::Other(s) => s,
172        }
173    }
174}
175
176impl std::fmt::Display for Os {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.write_str(self.as_str())
179    }
180}
181
182impl std::fmt::Display for Distro {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.write_str(self.as_str())
185    }
186}
187
188impl std::fmt::Display for Arch {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.write_str(self.as_str())
191    }
192}
193
194fn detect_arch() -> Arch {
195    match std::env::consts::ARCH {
196        "x86_64" => Arch::X86_64,
197        "aarch64" => Arch::Aarch64,
198        other => Arch::Other(other.to_string()),
199    }
200}
201
202fn read_macos_version() -> Option<String> {
203    read_command_output("sw_vers", &["-productVersion"])
204        .map(|s| s.trim().to_string())
205        .ok()
206}
207
208fn read_command_output(cmd: &str, args: &[&str]) -> Result<String, std::io::Error> {
209    let output = std::process::Command::new(cmd)
210        .args(args)
211        .stdout(std::process::Stdio::piped())
212        .stderr(std::process::Stdio::piped())
213        .output()?;
214    if output.status.success() {
215        Ok(crate::stdout_lossy_trimmed(&output))
216    } else {
217        let stderr = crate::stderr_lossy_trimmed(&output);
218        Err(std::io::Error::other(format!(
219            "{} failed: {}",
220            cmd,
221            if stderr.is_empty() {
222                format!("exit code {}", output.status.code().unwrap_or(-1))
223            } else {
224                stderr
225            }
226        )))
227    }
228}
229
230/// Parse `/etc/os-release` to detect Linux distro and version.
231fn parse_os_release() -> Option<(Distro, String)> {
232    let content = fs::read_to_string("/etc/os-release").ok()?;
233    Some(distro_from_os_release_content(&content))
234}
235
236/// Given raw os-release file content, parse fields and resolve distro + version.
237/// Used by both production `parse_os_release` and tests.
238fn distro_from_os_release_content(content: &str) -> (Distro, String) {
239    let fields = parse_os_release_content(content);
240
241    let id = fields
242        .get("ID")
243        .map(|s| s.to_lowercase())
244        .unwrap_or_default();
245    let id_like = fields
246        .get("ID_LIKE")
247        .map(|s| s.to_lowercase())
248        .unwrap_or_default();
249    let version_id = fields.get("VERSION_ID").cloned().unwrap_or_default();
250
251    let distro = match id.as_str() {
252        "ubuntu" => Distro::Ubuntu,
253        "debian" => Distro::Debian,
254        "fedora" => Distro::Fedora,
255        "rhel" | "redhat" => Distro::RHEL,
256        "centos" => Distro::CentOS,
257        "arch" | "archlinux" => Distro::Arch,
258        "manjaro" => Distro::Manjaro,
259        "alpine" => Distro::Alpine,
260        "opensuse" | "opensuse-leap" | "opensuse-tumbleweed" => Distro::OpenSUSE,
261        _ => {
262            // Check ID_LIKE for derivatives
263            if id_like.contains("ubuntu") || id_like.contains("debian") {
264                if id_like.contains("ubuntu") {
265                    Distro::Ubuntu
266                } else {
267                    Distro::Debian
268                }
269            } else if id_like.contains("fedora") || id_like.contains("rhel") {
270                Distro::Fedora
271            } else if id_like.contains("arch") {
272                Distro::Arch
273            } else if id_like.contains("suse") {
274                Distro::OpenSUSE
275            } else {
276                Distro::Unknown
277            }
278        }
279    };
280
281    (distro, version_id)
282}
283
284/// Parse os-release file content into key-value pairs.
285/// Handles quoted and unquoted values.
286pub(crate) fn parse_os_release_content(content: &str) -> HashMap<String, String> {
287    let mut fields = HashMap::new();
288    for line in content.lines() {
289        let line = line.trim();
290        if line.is_empty() || line.starts_with('#') {
291            continue;
292        }
293        if let Some((key, value)) = line.split_once('=') {
294            let value = value.trim_matches('"').trim_matches('\'').to_string();
295            fields.insert(key.to_string(), value);
296        }
297    }
298    fields
299}
300
301#[cfg(test)]
302mod tests;