1use std::collections::HashMap;
4use std::fs;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Os {
9 Linux,
10 MacOS,
11 FreeBSD,
12 Windows,
13}
14
15#[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#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Arch {
36 X86_64,
37 Aarch64,
38 Other(String),
39}
40
41#[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 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 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 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 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 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", }
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
230fn 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
236fn 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 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
284pub(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 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 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 assert_eq!(format!("{}", Os::Linux), "linux");
440 assert_eq!(format!("{}", Os::MacOS), "macos");
441 assert_eq!(format!("{}", Os::FreeBSD), "freebsd");
442 assert_eq!(format!("{}", Distro::Ubuntu), "ubuntu");
444 assert_eq!(format!("{}", Distro::RHEL), "rhel");
445 assert_eq!(format!("{}", Distro::OpenSUSE), "opensuse");
446 }
447
448 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 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 #[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 #[test]
575 fn parse_os_release_debian_only_derivative() {
576 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 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 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 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 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 #[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 #[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}