#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DistroFamily {
DebianLike,
FedoraLike,
ArchLike,
AlpineLike,
SuseLike,
Unknown,
}
impl DistroFamily {
pub fn from_base_image(base: &str) -> Self {
let base_lower = base.to_lowercase();
if base_lower.contains("debian")
|| base_lower.contains("ubuntu")
|| base_lower.contains("mint")
|| base_lower.contains("kali")
|| base_lower.contains("pop")
|| base_lower.contains("elementary")
{
Self::DebianLike
} else if base_lower.contains("fedora")
|| base_lower.contains("rhel")
|| base_lower.contains("centos")
|| base_lower.contains("rocky")
|| base_lower.contains("alma")
|| base_lower.contains("nobara")
{
Self::FedoraLike
} else if base_lower.contains("arch")
|| base_lower.contains("cachy")
|| base_lower.contains("manjaro")
|| base_lower.contains("endeavouros")
|| base_lower.contains("garuda")
{
Self::ArchLike
} else if base_lower.contains("alpine") {
Self::AlpineLike
} else if base_lower.contains("opensuse") || base_lower.contains("suse") {
Self::SuseLike
} else {
Self::Unknown
}
}
pub fn manager(&self) -> crate::config::PackageManager {
match self {
Self::DebianLike => crate::config::PackageManager::Apt,
Self::FedoraLike => crate::config::PackageManager::Dnf,
Self::ArchLike => crate::config::PackageManager::Pacman,
Self::AlpineLike => crate::config::PackageManager::Apk,
Self::SuseLike => crate::config::PackageManager::Zypper,
Self::Unknown => crate::config::PackageManager::Dnf,
}
}
pub fn install_cmd(&self) -> &'static str {
match self {
Self::DebianLike => "apt-get update && apt-get install -y --no-install-recommends",
Self::FedoraLike => "dnf install -y",
Self::ArchLike => "pacman -Syu --noconfirm",
Self::AlpineLike => "apk add --no-cache",
Self::SuseLike => "zypper install -y",
Self::Unknown => "dnf install -y",
}
}
pub fn clean_cmd(&self) -> &'static str {
match self {
Self::DebianLike => "rm -rf /var/lib/apt/lists/*",
Self::FedoraLike => "dnf clean all",
Self::ArchLike => "pacman -Scc --noconfirm",
Self::AlpineLike => "",
Self::SuseLike => "zypper clean --all",
Self::Unknown => "dnf clean all",
}
}
pub fn base_packages(&self, host_shell: Option<&str>) -> Vec<String> {
let common: Vec<String> = [
"sudo",
"curl",
"tar",
"unzip",
"wget",
"which",
"coreutils",
"diffutils",
"findutils",
"grep",
"sed",
"gawk",
"bash-completion",
]
.into_iter()
.map(String::from)
.collect();
let mut pkgs = common;
if let Some(shell) = host_shell {
let shell_pkgs = Self::shell_packages(self, shell);
for pkg in shell_pkgs {
if !pkgs.contains(&pkg) {
pkgs.push(pkg);
}
}
}
pkgs
}
fn shell_packages(&self, shell_path: &str) -> Vec<String> {
let shell_name = shell_path.split('/').next_back().unwrap_or("");
let mut pkgs = Vec::new();
match shell_name {
"bash" => {
pkgs.push("bash".into());
pkgs.push("bash-completion".into());
}
"zsh" => {
pkgs.push("zsh".into());
match self {
Self::DebianLike => pkgs.push("zsh-common".into()),
Self::FedoraLike => pkgs.push("zsh".into()),
Self::ArchLike => pkgs.push("zsh-completions".into()),
Self::AlpineLike => pkgs.push("zsh".into()),
Self::SuseLike => pkgs.push("zsh".into()),
Self::Unknown => pkgs.push("zsh".into()),
}
}
"fish" => {
pkgs.push("fish".into());
}
"sh" | "dash" => match self {
Self::DebianLike => pkgs.push("dash".into()),
Self::FedoraLike => pkgs.push("dash".into()),
Self::ArchLike => pkgs.push("dash".into()),
Self::AlpineLike => pkgs.push("dash".into()),
Self::SuseLike => pkgs.push("dash".into()),
Self::Unknown => pkgs.push("dash".into()),
},
_ => {}
}
pkgs
}
pub fn remove_cmd(&self, packages: &[String]) -> String {
if packages.is_empty() {
return String::new();
}
let pkgs = packages.join(" ");
match self {
Self::DebianLike => {
format!(
"apt-get purge -y {} && apt-get autoremove -y && {}",
pkgs,
Self::clean_cmd(self)
)
}
Self::FedoraLike => {
format!("dnf remove -y {} && {}", pkgs, Self::clean_cmd(self))
}
Self::ArchLike => {
format!(
"pacman -Rns --noconfirm {} && {}",
pkgs,
Self::clean_cmd(self)
)
}
Self::AlpineLike => {
format!("apk del {} && {}", pkgs, Self::clean_cmd(self))
}
Self::SuseLike => {
format!("zypper rm {} && {}", pkgs, Self::clean_cmd(self))
}
Self::Unknown => {
format!("dnf remove -y {} && {}", pkgs, Self::clean_cmd(self))
}
}
}
pub fn locale_packages(&self) -> Vec<String> {
match self {
Self::DebianLike => vec!["locales".into()],
Self::FedoraLike => vec!["glibc-all-langpacks".into()],
Self::ArchLike => vec!["glibc".into()],
Self::AlpineLike => vec!["musl-locales".into()],
Self::SuseLike => vec!["glibc-all-langpacks".into()],
Self::Unknown => vec!["locales".into()],
}
}
}
pub fn detect_host_shell() -> Option<String> {
std::env::var("SHELL").ok().filter(|s| !s.is_empty())
}
pub fn detect_host_locale() -> Option<String> {
std::env::var("LANG")
.ok()
.or_else(|| std::env::var("LC_ALL").ok())
.or_else(|| std::env::var("LC_CTYPE").ok())
.filter(|s| !s.is_empty())
}
pub fn detect_package_manager(image: &str) -> crate::config::PackageManager {
DistroFamily::from_base_image(image).manager()
}
pub fn is_tty() -> bool {
nix::unistd::isatty(std::io::stdin()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_distro_from_base_image() {
assert_eq!(
DistroFamily::from_base_image("debian:12"),
DistroFamily::DebianLike
);
assert_eq!(
DistroFamily::from_base_image("ubuntu:26.04"),
DistroFamily::DebianLike
);
assert_eq!(
DistroFamily::from_base_image("fedora:41"),
DistroFamily::FedoraLike
);
assert_eq!(
DistroFamily::from_base_image("cachy-latest"),
DistroFamily::ArchLike
);
assert_eq!(
DistroFamily::from_base_image("archlinux:latest"),
DistroFamily::ArchLike
);
assert_eq!(
DistroFamily::from_base_image("alpine:3.20"),
DistroFamily::AlpineLike
);
assert_eq!(
DistroFamily::from_base_image("opensuse/tumbleweed:latest"),
DistroFamily::SuseLike
);
assert_eq!(
DistroFamily::from_base_image("unknown:latest"),
DistroFamily::Unknown
);
}
#[test]
fn test_debian_base_packages() {
let pkgs = DistroFamily::DebianLike.base_packages(Some("/usr/bin/fish"));
assert!(pkgs.contains(&"sudo".into()));
assert!(pkgs.contains(&"curl".into()));
assert!(pkgs.contains(&"fish".into()));
}
#[test]
fn test_fedora_base_packages() {
let pkgs = DistroFamily::FedoraLike.base_packages(Some("/usr/bin/zsh"));
assert!(pkgs.contains(&"sudo".into()));
assert!(pkgs.contains(&"zsh".into()));
assert!(pkgs.contains(&"zsh-completions".into()) || pkgs.contains(&"zsh".into()));
}
#[test]
fn test_arch_base_packages() {
let pkgs = DistroFamily::ArchLike.base_packages(Some("/bin/bash"));
assert!(pkgs.contains(&"sudo".into()));
assert!(pkgs.contains(&"bash".into()));
assert!(pkgs.contains(&"bash-completion".into()));
}
#[test]
fn test_alpine_base_packages() {
let pkgs = DistroFamily::AlpineLike.base_packages(None);
assert!(pkgs.contains(&"sudo".into()));
assert!(pkgs.contains(&"curl".into()));
}
#[test]
fn test_install_cmd() {
assert_eq!(
DistroFamily::DebianLike.install_cmd(),
"apt-get update && apt-get install -y --no-install-recommends"
);
assert_eq!(DistroFamily::FedoraLike.install_cmd(), "dnf install -y");
assert_eq!(
DistroFamily::ArchLike.install_cmd(),
"pacman -Syu --noconfirm"
);
assert_eq!(DistroFamily::AlpineLike.install_cmd(), "apk add --no-cache");
assert_eq!(DistroFamily::SuseLike.install_cmd(), "zypper install -y");
}
}