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 {
303    use super::*;
304
305    #[test]
306    fn detect_returns_valid_platform() {
307        let platform = Platform::detect();
308        // We can't assert specific values since tests run on different platforms,
309        // but we can verify the struct is populated
310        assert!(!format!("{}", platform.os).is_empty());
311        assert!(!format!("{}", platform.arch).is_empty());
312    }
313
314    #[test]
315    fn native_manager_mapping() {
316        let cases: &[(Os, Distro, &str, &str)] = &[
317            (Os::MacOS, Distro::MacOS, "14.0", "brew"),
318            (Os::Linux, Distro::Ubuntu, "22.04", "apt"),
319            (Os::Linux, Distro::Debian, "12", "apt"),
320            (Os::Linux, Distro::Fedora, "39", "dnf"),
321            (Os::Linux, Distro::RHEL, "7.9", "yum"),
322            (Os::Linux, Distro::RHEL, "8.9", "dnf"),
323            (Os::Linux, Distro::Arch, "", "pacman"),
324            (Os::Linux, Distro::Alpine, "3.19", "apk"),
325            (Os::Linux, Distro::OpenSUSE, "15.5", "zypper"),
326            (Os::FreeBSD, Distro::FreeBSD, "14.0", "pkg"),
327        ];
328        for (os, distro, version, expected) in cases {
329            let p = Platform {
330                os: os.clone(),
331                distro: distro.clone(),
332                version: version.to_string(),
333                arch: Arch::X86_64,
334            };
335            assert_eq!(
336                p.native_manager(),
337                *expected,
338                "failed for {:?}/{:?}",
339                os,
340                distro
341            );
342        }
343    }
344
345    #[test]
346    fn parse_os_release_ubuntu() {
347        let content = r#"
348NAME="Ubuntu"
349VERSION="22.04.3 LTS (Jammy Jellyfish)"
350ID=ubuntu
351ID_LIKE=debian
352VERSION_ID="22.04"
353"#;
354        let (distro, version) = parse_os_release_content_to_distro(content);
355        assert_eq!(distro, Distro::Ubuntu);
356        assert_eq!(version, "22.04");
357    }
358
359    #[test]
360    fn parse_os_release_fedora() {
361        let content = r#"
362NAME="Fedora Linux"
363ID=fedora
364VERSION_ID=39
365"#;
366        let (distro, version) = parse_os_release_content_to_distro(content);
367        assert_eq!(distro, Distro::Fedora);
368        assert_eq!(version, "39");
369    }
370
371    #[test]
372    fn parse_os_release_arch() {
373        let content = r#"
374NAME="Arch Linux"
375ID=arch
376"#;
377        let (distro, version) = parse_os_release_content_to_distro(content);
378        assert_eq!(distro, Distro::Arch);
379        assert_eq!(version, "");
380    }
381
382    #[test]
383    fn parse_os_release_alpine() {
384        let content = r#"
385NAME="Alpine Linux"
386ID=alpine
387VERSION_ID=3.19.0
388"#;
389        let (distro, version) = parse_os_release_content_to_distro(content);
390        assert_eq!(distro, Distro::Alpine);
391        assert_eq!(version, "3.19.0");
392    }
393
394    #[test]
395    fn parse_os_release_derivative() {
396        let content = r#"
397NAME="Linux Mint"
398ID=linuxmint
399ID_LIKE="ubuntu debian"
400VERSION_ID="21.2"
401"#;
402        let (distro, version) = parse_os_release_content_to_distro(content);
403        assert_eq!(distro, Distro::Ubuntu);
404        assert_eq!(version, "21.2");
405    }
406
407    #[test]
408    fn parse_os_release_opensuse_leap() {
409        let content = r#"
410NAME="openSUSE Leap"
411ID="opensuse-leap"
412VERSION_ID="15.5"
413"#;
414        let (distro, version) = parse_os_release_content_to_distro(content);
415        assert_eq!(distro, Distro::OpenSUSE);
416        assert_eq!(version, "15.5");
417    }
418
419    #[test]
420    fn parse_os_release_centos() {
421        let content = r#"
422NAME="CentOS Linux"
423ID="centos"
424ID_LIKE="rhel fedora"
425VERSION_ID="7"
426"#;
427        let (distro, version) = parse_os_release_content_to_distro(content);
428        assert_eq!(distro, Distro::CentOS);
429        assert_eq!(version, "7");
430    }
431
432    #[test]
433    fn enum_display_formatting() {
434        // Arch
435        assert_eq!(format!("{}", Arch::X86_64), "x86_64");
436        assert_eq!(format!("{}", Arch::Aarch64), "aarch64");
437        assert_eq!(format!("{}", Arch::Other("riscv64".into())), "riscv64");
438        // Os
439        assert_eq!(format!("{}", Os::Linux), "linux");
440        assert_eq!(format!("{}", Os::MacOS), "macos");
441        assert_eq!(format!("{}", Os::FreeBSD), "freebsd");
442        // Distro
443        assert_eq!(format!("{}", Distro::Ubuntu), "ubuntu");
444        assert_eq!(format!("{}", Distro::RHEL), "rhel");
445        assert_eq!(format!("{}", Distro::OpenSUSE), "opensuse");
446    }
447
448    /// Helper: parse os-release content string into (Distro, version).
449    /// Delegates to the shared `distro_from_os_release_content`.
450    fn parse_os_release_content_to_distro(content: &str) -> (Distro, String) {
451        distro_from_os_release_content(content)
452    }
453
454    #[test]
455    fn platform_matches_any_os() {
456        let p = Platform {
457            os: Os::Linux,
458            distro: Distro::Ubuntu,
459            version: "22.04".into(),
460            arch: Arch::X86_64,
461        };
462        assert!(p.matches_any(&["linux".into()]));
463        assert!(!p.matches_any(&["macos".into()]));
464    }
465
466    #[test]
467    fn platform_matches_any_distro() {
468        let p = Platform {
469            os: Os::Linux,
470            distro: Distro::Ubuntu,
471            version: "22.04".into(),
472            arch: Arch::X86_64,
473        };
474        assert!(p.matches_any(&["ubuntu".into()]));
475        assert!(!p.matches_any(&["fedora".into()]));
476    }
477
478    #[test]
479    fn platform_matches_any_arch() {
480        let p = Platform {
481            os: Os::Linux,
482            distro: Distro::Ubuntu,
483            version: "22.04".into(),
484            arch: Arch::Aarch64,
485        };
486        assert!(p.matches_any(&["aarch64".into()]));
487        assert!(!p.matches_any(&["x86_64".into()]));
488    }
489
490    #[test]
491    fn platform_matches_any_empty_matches_all() {
492        let p = Platform {
493            os: Os::MacOS,
494            distro: Distro::MacOS,
495            version: "14.0".into(),
496            arch: Arch::Aarch64,
497        };
498        assert!(p.matches_any(&[]));
499    }
500
501    #[test]
502    fn platform_matches_any_multiple_tags() {
503        let p = Platform {
504            os: Os::Linux,
505            distro: Distro::Fedora,
506            version: "39".into(),
507            arch: Arch::X86_64,
508        };
509        // Any match is sufficient
510        assert!(p.matches_any(&["macos".into(), "linux".into()]));
511        assert!(p.matches_any(&["ubuntu".into(), "fedora".into()]));
512        assert!(!p.matches_any(&["macos".into(), "freebsd".into()]));
513    }
514
515    // --- native_manager: additional distro mappings ---
516
517    #[test]
518    fn native_manager_centos_7_uses_yum() {
519        let p = Platform {
520            os: Os::Linux,
521            distro: Distro::CentOS,
522            version: "7.9".into(),
523            arch: Arch::X86_64,
524        };
525        assert_eq!(p.native_manager(), "yum");
526    }
527
528    #[test]
529    fn native_manager_centos_8_uses_dnf() {
530        let p = Platform {
531            os: Os::Linux,
532            distro: Distro::CentOS,
533            version: "8.5".into(),
534            arch: Arch::X86_64,
535        };
536        assert_eq!(p.native_manager(), "dnf");
537    }
538
539    #[test]
540    fn native_manager_manjaro() {
541        let p = Platform {
542            os: Os::Linux,
543            distro: Distro::Manjaro,
544            version: "23.0".into(),
545            arch: Arch::X86_64,
546        };
547        assert_eq!(p.native_manager(), "pacman");
548    }
549
550    #[test]
551    fn native_manager_windows() {
552        let p = Platform {
553            os: Os::Windows,
554            distro: Distro::Windows,
555            version: String::new(),
556            arch: Arch::X86_64,
557        };
558        assert_eq!(p.native_manager(), "winget");
559    }
560
561    #[test]
562    fn native_manager_unknown_defaults_to_apt() {
563        let p = Platform {
564            os: Os::Linux,
565            distro: Distro::Unknown,
566            version: String::new(),
567            arch: Arch::X86_64,
568        };
569        assert_eq!(p.native_manager(), "apt");
570    }
571
572    // --- distro_from_os_release_content: ID_LIKE derivative detection ---
573
574    #[test]
575    fn parse_os_release_debian_only_derivative() {
576        // A distro with ID_LIKE=debian (not ubuntu)
577        let content = r#"
578NAME="Raspberry Pi OS"
579ID=raspbian
580ID_LIKE=debian
581VERSION_ID="11"
582"#;
583        let (distro, version) = distro_from_os_release_content(content);
584        assert_eq!(distro, Distro::Debian);
585        assert_eq!(version, "11");
586    }
587
588    #[test]
589    fn parse_os_release_fedora_derivative() {
590        // A distro with ID_LIKE containing fedora
591        let content = r#"
592NAME="Nobara"
593ID=nobara
594ID_LIKE="fedora"
595VERSION_ID="38"
596"#;
597        let (distro, version) = distro_from_os_release_content(content);
598        assert_eq!(distro, Distro::Fedora);
599        assert_eq!(version, "38");
600    }
601
602    #[test]
603    fn parse_os_release_rhel_derivative() {
604        // A distro with ID_LIKE containing rhel
605        let content = r#"
606NAME="Rocky Linux"
607ID=rocky
608ID_LIKE="rhel centos fedora"
609VERSION_ID="9.2"
610"#;
611        let (distro, version) = distro_from_os_release_content(content);
612        assert_eq!(distro, Distro::Fedora);
613        assert_eq!(version, "9.2");
614    }
615
616    #[test]
617    fn parse_os_release_arch_derivative() {
618        // A distro with ID_LIKE containing arch
619        let content = r#"
620NAME="EndeavourOS"
621ID=endeavouros
622ID_LIKE=arch
623VERSION_ID="2023.11.17"
624"#;
625        let (distro, version) = distro_from_os_release_content(content);
626        assert_eq!(distro, Distro::Arch);
627        assert_eq!(version, "2023.11.17");
628    }
629
630    #[test]
631    fn parse_os_release_suse_derivative() {
632        // A distro with ID_LIKE containing suse
633        let content = r#"
634NAME="GeckoLinux"
635ID=geckolinux
636ID_LIKE="suse opensuse"
637VERSION_ID="999"
638"#;
639        let (distro, version) = distro_from_os_release_content(content);
640        assert_eq!(distro, Distro::OpenSUSE);
641        assert_eq!(version, "999");
642    }
643
644    #[test]
645    fn parse_os_release_unknown_distro() {
646        let content = r#"
647NAME="Exotic Linux"
648ID=exotic
649VERSION_ID="1.0"
650"#;
651        let (distro, version) = distro_from_os_release_content(content);
652        assert_eq!(distro, Distro::Unknown);
653        assert_eq!(version, "1.0");
654    }
655
656    #[test]
657    fn parse_os_release_rhel_id() {
658        let content = "ID=rhel\nVERSION_ID=9.2\n";
659        let (distro, version) = distro_from_os_release_content(content);
660        assert_eq!(distro, Distro::RHEL);
661        assert_eq!(version, "9.2");
662    }
663
664    #[test]
665    fn parse_os_release_redhat_id() {
666        let content = "ID=redhat\nVERSION_ID=8\n";
667        let (distro, version) = distro_from_os_release_content(content);
668        assert_eq!(distro, Distro::RHEL);
669        assert_eq!(version, "8");
670    }
671
672    #[test]
673    fn parse_os_release_archlinux_id() {
674        let content = "ID=archlinux\n";
675        let (distro, _) = distro_from_os_release_content(content);
676        assert_eq!(distro, Distro::Arch);
677    }
678
679    #[test]
680    fn parse_os_release_opensuse_tumbleweed() {
681        let content = "ID=opensuse-tumbleweed\nVERSION_ID=20231201\n";
682        let (distro, version) = distro_from_os_release_content(content);
683        assert_eq!(distro, Distro::OpenSUSE);
684        assert_eq!(version, "20231201");
685    }
686
687    #[test]
688    fn parse_os_release_manjaro() {
689        let content = "ID=manjaro\nVERSION_ID=23.0\n";
690        let (distro, version) = distro_from_os_release_content(content);
691        assert_eq!(distro, Distro::Manjaro);
692        assert_eq!(version, "23.0");
693    }
694
695    // --- parse_os_release_content: edge cases ---
696
697    #[test]
698    fn parse_os_release_content_handles_comments_and_blanks() {
699        let content =
700            "# This is a comment\n\nID=ubuntu\n\n# Another comment\nVERSION_ID=\"22.04\"\n";
701        let fields = parse_os_release_content(content);
702        assert_eq!(fields.get("ID").unwrap(), "ubuntu");
703        assert_eq!(fields.get("VERSION_ID").unwrap(), "22.04");
704    }
705
706    #[test]
707    fn parse_os_release_content_handles_single_quotes() {
708        let content = "ID='fedora'\nVERSION_ID='39'\n";
709        let fields = parse_os_release_content(content);
710        assert_eq!(fields.get("ID").unwrap(), "fedora");
711        assert_eq!(fields.get("VERSION_ID").unwrap(), "39");
712    }
713
714    #[test]
715    fn parse_os_release_content_no_equals() {
716        let content = "NOEQUALS\nID=test\n";
717        let fields = parse_os_release_content(content);
718        assert!(!fields.contains_key("NOEQUALS"));
719        assert_eq!(fields.get("ID").unwrap(), "test");
720    }
721
722    // --- Display and as_str coverage ---
723
724    #[test]
725    fn os_windows_display() {
726        assert_eq!(format!("{}", Os::Windows), "windows");
727    }
728
729    #[test]
730    fn distro_display_all_variants() {
731        let cases: &[(Distro, &str)] = &[
732            (Distro::CentOS, "centos"),
733            (Distro::Manjaro, "manjaro"),
734            (Distro::FreeBSD, "freebsd"),
735            (Distro::MacOS, "macos"),
736            (Distro::Windows, "windows"),
737            (Distro::Unknown, "unknown"),
738        ];
739        for (distro, expected) in cases {
740            assert_eq!(distro.as_str(), *expected);
741            assert_eq!(format!("{}", distro), *expected);
742        }
743    }
744}