1use serde::{Deserialize, Serialize};
14use std::fs;
15use std::path::Path;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum DistroFamily {
24 Debian,
25 Arch,
26 Rhel,
27 Suse,
28 Unknown,
29}
30
31impl DistroFamily {
32 pub fn label(self) -> &'static str {
34 match self {
35 DistroFamily::Debian => "debian",
36 DistroFamily::Arch => "arch",
37 DistroFamily::Rhel => "rhel",
38 DistroFamily::Suse => "suse",
39 DistroFamily::Unknown => "unknown",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum PackageManager {
47 Apt,
48 Pacman,
49 Dnf,
50 Yum,
51 Zypper,
52 Unknown,
53}
54
55impl PackageManager {
56 pub fn label(self) -> &'static str {
58 match self {
59 PackageManager::Apt => "apt",
60 PackageManager::Pacman => "pacman",
61 PackageManager::Dnf => "dnf",
62 PackageManager::Yum => "yum",
63 PackageManager::Zypper => "zypper",
64 PackageManager::Unknown => "unknown",
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct DistroInfo {
72 pub id: String,
74 pub name: String,
76 pub version: String,
78 pub codename: String,
80 pub family: DistroFamily,
82 pub pkg_manager: PackageManager,
84 pub id_like: String,
86}
87
88impl Default for DistroInfo {
89 fn default() -> Self {
90 Self {
91 id: "unknown".to_string(),
92 name: "Unknown Linux".to_string(),
93 version: "unknown".to_string(),
94 codename: String::new(),
95 family: DistroFamily::Unknown,
96 pkg_manager: PackageManager::Unknown,
97 id_like: String::new(),
98 }
99 }
100}
101
102pub fn detect_distribution() -> DistroInfo {
118 let mut info = DistroInfo::default();
119
120 if parse_os_release(&mut info) {
122 finalize_distro_info(&mut info);
123 return info;
124 }
125
126 if parse_lsb_release(&mut info) {
128 finalize_distro_info(&mut info);
129 return info;
130 }
131
132 if Path::new("/etc/arch-release").exists() {
134 info.id = "arch".to_string();
135 info.name = "Arch Linux".to_string();
136 info.version = "rolling".to_string();
137 finalize_distro_info(&mut info);
138 return info;
139 }
140
141 if Path::new("/etc/debian_version").exists() {
143 info.id = "debian".to_string();
144 info.name = "Debian GNU/Linux".to_string();
145 if let Ok(version) = fs::read_to_string("/etc/debian_version") {
146 info.version = version.trim().to_string();
147 }
148 finalize_distro_info(&mut info);
149 return info;
150 }
151
152 finalize_distro_info(&mut info);
154 info
155}
156
157pub fn detect_package_manager(info: &DistroInfo) -> PackageManager {
162 if let Ok(pkg_mgr) = std::env::var("MLSTACK_PKG_MANAGER") {
164 let pkg_mgr = pkg_mgr.trim().to_lowercase();
165 if let Some(pm) = match_pkg_manager_str(&pkg_mgr) {
166 return pm;
167 }
168 }
169
170 match info.family {
172 DistroFamily::Debian => PackageManager::Apt,
173 DistroFamily::Arch => PackageManager::Pacman,
174 DistroFamily::Rhel => PackageManager::Dnf,
175 DistroFamily::Suse => PackageManager::Zypper,
176 DistroFamily::Unknown => PackageManager::Unknown,
177 }
178}
179
180pub fn parse_key_value_file(content: &str) -> std::collections::HashMap<String, String> {
185 let mut map = std::collections::HashMap::new();
186 for line in content.lines() {
187 let line = line.trim();
188 if line.is_empty() || line.starts_with('#') {
189 continue;
190 }
191 if let Some((key, value)) = line.split_once('=') {
192 let key = key.trim().to_string();
193 let value = value
194 .trim()
195 .trim_matches('"')
196 .trim_matches('\'')
197 .to_string();
198 map.insert(key, value);
199 }
200 }
201 map
202}
203
204fn parse_os_release(info: &mut DistroInfo) -> bool {
210 let content = match fs::read_to_string("/etc/os-release") {
211 Ok(c) => c,
212 Err(_) => return false,
213 };
214
215 let map = parse_key_value_file(&content);
216
217 info.id = map.get("ID").cloned().unwrap_or_default().to_lowercase();
218 info.name = map.get("NAME").cloned().unwrap_or_default();
219 info.version = map.get("VERSION_ID").cloned().unwrap_or_default();
220 info.codename = map.get("VERSION_CODENAME").cloned().unwrap_or_default();
221 info.id_like = map.get("ID_LIKE").cloned().unwrap_or_default();
222
223 if info.version.is_empty() && is_rolling_distro(&info.id) {
225 info.version = "rolling".to_string();
226 }
227
228 !info.id.is_empty()
229}
230
231fn parse_lsb_release(info: &mut DistroInfo) -> bool {
233 let content = match fs::read_to_string("/etc/lsb-release") {
234 Ok(c) => c,
235 Err(_) => return false,
236 };
237
238 let map = parse_key_value_file(&content);
239
240 info.id = map
241 .get("DISTRIB_ID")
242 .cloned()
243 .unwrap_or_default()
244 .to_lowercase();
245 info.name = map.get("DISTRIB_ID").cloned().unwrap_or_default();
246 info.version = map.get("DISTRIB_RELEASE").cloned().unwrap_or_default();
247 info.codename = map.get("DISTRIB_CODENAME").cloned().unwrap_or_default();
248
249 !info.id.is_empty()
250}
251
252fn classify_distro_family(id: &str, id_like: &str) -> DistroFamily {
254 let id_lower = id.to_lowercase();
255 let id_like_lower = id_like.to_lowercase();
256
257 match id_lower.as_str() {
259 "debian" | "ubuntu" | "linuxmint" | "pop" | "elementary" | "kali" | "mx" | "devuan"
260 | "raspbian" => return DistroFamily::Debian,
261 "arch" | "manjaro" | "cachyos" | "endeavouros" | "garuda" | "arco" | "artix"
262 | "rebornos" => return DistroFamily::Arch,
263 "fedora" | "rhel" | "centos" | "rocky" | "almalinux" | "ol" | "oracle" | "scientific"
264 | "centos-stream" => return DistroFamily::Rhel,
265 "opensuse-leap" | "opensuse-tumbleweed" | "opensuse" | "sles" | "suse" | "sle" => {
266 return DistroFamily::Suse
267 }
268 _ => {}
269 }
270
271 if id_like_lower.contains("debian") || id_like_lower.contains("ubuntu") {
273 return DistroFamily::Debian;
274 }
275 if id_like_lower.contains("arch") {
276 return DistroFamily::Arch;
277 }
278 if id_like_lower.contains("fedora")
279 || id_like_lower.contains("rhel")
280 || id_like_lower.contains("centos")
281 {
282 return DistroFamily::Rhel;
283 }
284 if id_like_lower.contains("suse") {
285 return DistroFamily::Suse;
286 }
287
288 DistroFamily::Unknown
289}
290
291fn is_rolling_distro(id: &str) -> bool {
293 matches!(
294 id,
295 "arch"
296 | "cachyos"
297 | "manjaro"
298 | "endeavouros"
299 | "garuda"
300 | "arco"
301 | "artix"
302 | "rebornos"
303 | "opensuse-tumbleweed"
304 )
305}
306
307fn match_pkg_manager_str(s: &str) -> Option<PackageManager> {
309 match s {
310 "apt" | "apt-get" => Some(PackageManager::Apt),
311 "pacman" => Some(PackageManager::Pacman),
312 "dnf" => Some(PackageManager::Dnf),
313 "yum" => Some(PackageManager::Yum),
314 "zypper" => Some(PackageManager::Zypper),
315 _ => None,
316 }
317}
318
319fn finalize_distro_info(info: &mut DistroInfo) {
321 info.family = classify_distro_family(&info.id, &info.id_like);
322 info.pkg_manager = detect_package_manager(info);
323}
324
325#[cfg(test)]
330mod tests {
331 use super::*;
332
333 struct EnvVarGuard {
338 key: String,
339 original: Option<String>,
340 }
341
342 impl EnvVarGuard {
343 fn remove(key: &str) -> Self {
345 let original = std::env::var(key).ok();
346 std::env::remove_var(key);
347 Self {
348 key: key.to_string(),
349 original,
350 }
351 }
352 }
353
354 impl Drop for EnvVarGuard {
355 fn drop(&mut self) {
356 match &self.original {
357 Some(v) => std::env::set_var(&self.key, v),
358 None => std::env::remove_var(&self.key),
359 }
360 }
361 }
362
363 #[test]
368 fn test_distro_detection_parses_os_release() {
369 let dir = tempfile::tempdir().unwrap();
371 let os_release = dir.path().join("os-release");
372 fs::write(
373 &os_release,
374 r#"NAME="Ubuntu"
375VERSION="24.04 LTS (Noble Numbat)"
376ID=ubuntu
377ID_LIKE=debian
378PRETTY_NAME="Ubuntu 24.04 LTS"
379VERSION_ID="24.04"
380VERSION_CODENAME=noble
381"#,
382 )
383 .unwrap();
384
385 let content = fs::read_to_string(&os_release).unwrap();
386 let map = parse_key_value_file(&content);
387
388 assert_eq!(map.get("ID"), Some(&"ubuntu".to_string()));
389 assert_eq!(map.get("NAME"), Some(&"Ubuntu".to_string()));
390 assert_eq!(map.get("VERSION_ID"), Some(&"24.04".to_string()));
391 assert_eq!(map.get("VERSION_CODENAME"), Some(&"noble".to_string()));
392 assert_eq!(map.get("ID_LIKE"), Some(&"debian".to_string()));
393 }
394
395 #[test]
396 fn test_distro_detection_arch_linux() {
397 let content = r#"NAME="Arch Linux"
398PRETTY_NAME="Arch Linux"
399ID=arch
400BUILD_ID=rolling
401"#;
402 let map = parse_key_value_file(content);
403 assert_eq!(map.get("ID"), Some(&"arch".to_string()));
404 assert_eq!(map.get("NAME"), Some(&"Arch Linux".to_string()));
405 }
406
407 #[test]
408 fn test_distro_detection_fedora() {
409 let content = r#"NAME="Fedora Linux"
410VERSION="41 (Workstation Edition)"
411ID=fedora
412VERSION_ID=41
413ID_LIKE="rhel fedora"
414"#;
415 let map = parse_key_value_file(content);
416 assert_eq!(map.get("ID"), Some(&"fedora".to_string()));
417 assert_eq!(map.get("VERSION_ID"), Some(&"41".to_string()));
418 }
419
420 #[test]
421 fn test_distro_detection_opensuse() {
422 let content = r#"NAME="openSUSE Leap"
423VERSION="15.5"
424ID="opensuse-leap"
425VERSION_ID="15.5"
426ID_LIKE="suse opensuse"
427"#;
428 let map = parse_key_value_file(content);
429 assert_eq!(map.get("ID"), Some(&"opensuse-leap".to_string()));
430 assert_eq!(map.get("VERSION_ID"), Some(&"15.5".to_string()));
431 }
432
433 #[test]
434 fn test_distro_detection_cachyos() {
435 let content = r#"NAME="CachyOS"
436ID=cachyos
437ID_LIKE=arch
438"#;
439 let map = parse_key_value_file(content);
440 assert_eq!(map.get("ID"), Some(&"cachyos".to_string()));
441 assert_eq!(map.get("ID_LIKE"), Some(&"arch".to_string()));
442 }
443
444 #[test]
445 fn test_distro_family_classification_debian() {
446 assert_eq!(
447 classify_distro_family("ubuntu", "debian"),
448 DistroFamily::Debian
449 );
450 assert_eq!(classify_distro_family("debian", ""), DistroFamily::Debian);
451 assert_eq!(
452 classify_distro_family("linuxmint", "debian"),
453 DistroFamily::Debian
454 );
455 assert_eq!(
456 classify_distro_family("pop", "ubuntu debian"),
457 DistroFamily::Debian
458 );
459 assert_eq!(
460 classify_distro_family("kali", "debian"),
461 DistroFamily::Debian
462 );
463 }
464
465 #[test]
466 fn test_distro_family_classification_arch() {
467 assert_eq!(classify_distro_family("arch", ""), DistroFamily::Arch);
468 assert_eq!(
469 classify_distro_family("cachyos", "arch"),
470 DistroFamily::Arch
471 );
472 assert_eq!(
473 classify_distro_family("manjaro", "arch"),
474 DistroFamily::Arch
475 );
476 assert_eq!(
477 classify_distro_family("endeavouros", "arch"),
478 DistroFamily::Arch
479 );
480 }
481
482 #[test]
483 fn test_distro_family_classification_rhel() {
484 assert_eq!(
485 classify_distro_family("fedora", "rhel fedora"),
486 DistroFamily::Rhel
487 );
488 assert_eq!(
489 classify_distro_family("rocky", "rhel centos"),
490 DistroFamily::Rhel
491 );
492 assert_eq!(
493 classify_distro_family("almalinux", "rhel centos"),
494 DistroFamily::Rhel
495 );
496 assert_eq!(
497 classify_distro_family("centos", "rhel fedora"),
498 DistroFamily::Rhel
499 );
500 }
501
502 #[test]
503 fn test_distro_family_classification_suse() {
504 assert_eq!(
505 classify_distro_family("opensuse-leap", "suse opensuse"),
506 DistroFamily::Suse
507 );
508 assert_eq!(
509 classify_distro_family("opensuse-tumbleweed", "suse opensuse"),
510 DistroFamily::Suse
511 );
512 assert_eq!(classify_distro_family("sles", "suse"), DistroFamily::Suse);
513 }
514
515 #[test]
516 fn test_distro_family_classification_unknown() {
517 assert_eq!(classify_distro_family("gentoo", ""), DistroFamily::Unknown);
518 assert_eq!(classify_distro_family("alpine", ""), DistroFamily::Unknown);
519 }
520
521 #[test]
522 fn test_rolling_distro_detection() {
523 assert!(is_rolling_distro("arch"));
524 assert!(is_rolling_distro("cachyos"));
525 assert!(is_rolling_distro("manjaro"));
526 assert!(is_rolling_distro("endeavouros"));
527 assert!(is_rolling_distro("opensuse-tumbleweed"));
528 assert!(!is_rolling_distro("ubuntu"));
529 assert!(!is_rolling_distro("fedora"));
530 assert!(!is_rolling_distro("debian"));
531 }
532
533 #[test]
538 fn test_package_manager_debian_family() {
539 let _env = crate::test_support::lock_env();
540 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
541 let info = DistroInfo {
542 id: "ubuntu".to_string(),
543 family: DistroFamily::Debian,
544 ..Default::default()
545 };
546 assert_eq!(detect_package_manager(&info), PackageManager::Apt);
547 }
548
549 #[test]
550 fn test_package_manager_arch_family() {
551 let _env = crate::test_support::lock_env();
552 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
553 let info = DistroInfo {
554 id: "arch".to_string(),
555 family: DistroFamily::Arch,
556 ..Default::default()
557 };
558 assert_eq!(detect_package_manager(&info), PackageManager::Pacman);
559 }
560
561 #[test]
562 fn test_package_manager_rhel_family() {
563 let _env = crate::test_support::lock_env();
564 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
565 let info = DistroInfo {
566 id: "fedora".to_string(),
567 family: DistroFamily::Rhel,
568 ..Default::default()
569 };
570 assert_eq!(detect_package_manager(&info), PackageManager::Dnf);
571 }
572
573 #[test]
574 fn test_package_manager_suse_family() {
575 let _env = crate::test_support::lock_env();
576 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
577 let info = DistroInfo {
578 id: "opensuse-leap".to_string(),
579 family: DistroFamily::Suse,
580 ..Default::default()
581 };
582 assert_eq!(detect_package_manager(&info), PackageManager::Zypper);
583 }
584
585 #[test]
586 fn test_package_manager_unknown_family() {
587 let _env = crate::test_support::lock_env();
588 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
589 let info = DistroInfo {
590 id: "alpine".to_string(),
591 family: DistroFamily::Unknown,
592 ..Default::default()
593 };
594 assert_eq!(detect_package_manager(&info), PackageManager::Unknown);
595 }
596
597 #[test]
598 fn test_package_manager_env_override() {
599 let _env = crate::test_support::lock_env();
600 let _guard = EnvVarGuard::remove("MLSTACK_PKG_MANAGER");
601 let info = DistroInfo {
602 id: "arch".to_string(),
603 family: DistroFamily::Arch,
604 ..Default::default()
605 };
606
607 std::env::set_var("MLSTACK_PKG_MANAGER", "apt");
609 assert_eq!(detect_package_manager(&info), PackageManager::Apt);
610
611 }
613
614 #[test]
615 fn test_match_pkg_manager_str() {
616 assert_eq!(match_pkg_manager_str("apt"), Some(PackageManager::Apt));
617 assert_eq!(match_pkg_manager_str("apt-get"), Some(PackageManager::Apt));
618 assert_eq!(
619 match_pkg_manager_str("pacman"),
620 Some(PackageManager::Pacman)
621 );
622 assert_eq!(match_pkg_manager_str("dnf"), Some(PackageManager::Dnf));
623 assert_eq!(match_pkg_manager_str("yum"), Some(PackageManager::Yum));
624 assert_eq!(
625 match_pkg_manager_str("zypper"),
626 Some(PackageManager::Zypper)
627 );
628 assert_eq!(match_pkg_manager_str("unknown"), None);
629 }
630
631 #[test]
636 fn test_detect_distribution_real_system() {
637 let info = detect_distribution();
638 assert!(
640 !info.id.is_empty(),
641 "Distro ID should be non-empty on a real system"
642 );
643 assert!(
644 !info.name.is_empty(),
645 "Distro name should be non-empty on a real system"
646 );
647 assert!(
649 info.family != DistroFamily::Unknown || info.id == "unknown",
650 "Should classify family for known distros"
651 );
652 }
653
654 #[test]
655 fn test_detect_distribution_real_system_distro_info_serde_roundtrip() {
656 let info = detect_distribution();
657 let json = serde_json::to_string(&info).unwrap();
658 let back: DistroInfo = serde_json::from_str(&json).unwrap();
659 assert_eq!(info, back);
660 }
661
662 #[test]
667 fn test_parse_key_value_file_empty() {
668 let map = parse_key_value_file("");
669 assert!(map.is_empty());
670 }
671
672 #[test]
673 fn test_parse_key_value_file_comments_and_blanks() {
674 let content = "# This is a comment\n\nID=arch\n# Another comment\nNAME=\"Arch Linux\"\n";
675 let map = parse_key_value_file(content);
676 assert_eq!(map.get("ID"), Some(&"arch".to_string()));
677 assert_eq!(map.get("NAME"), Some(&"Arch Linux".to_string()));
678 assert_eq!(map.len(), 2);
679 }
680
681 #[test]
682 fn test_parse_key_value_file_single_quoted() {
683 let content = "ID='ubuntu'\nNAME='Ubuntu'";
684 let map = parse_key_value_file(content);
685 assert_eq!(map.get("ID"), Some(&"ubuntu".to_string()));
686 assert_eq!(map.get("NAME"), Some(&"Ubuntu".to_string()));
687 }
688
689 #[test]
690 fn test_parse_key_value_file_unquoted() {
691 let content = "ID=arch\nVERSION_ID=rolling";
692 let map = parse_key_value_file(content);
693 assert_eq!(map.get("ID"), Some(&"arch".to_string()));
694 assert_eq!(map.get("VERSION_ID"), Some(&"rolling".to_string()));
695 }
696}