1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum DistroFamily {
3 DebianLike,
4 FedoraLike,
5 ArchLike,
6 AlpineLike,
7 SuseLike,
8 Unknown,
9}
10
11impl DistroFamily {
12 pub fn from_base_image(base: &str) -> Self {
13 let base_lower = base.to_lowercase();
14 if base_lower.contains("debian")
15 || base_lower.contains("ubuntu")
16 || base_lower.contains("mint")
17 || base_lower.contains("kali")
18 || base_lower.contains("pop")
19 || base_lower.contains("elementary")
20 {
21 Self::DebianLike
22 } else if base_lower.contains("fedora")
23 || base_lower.contains("rhel")
24 || base_lower.contains("centos")
25 || base_lower.contains("rocky")
26 || base_lower.contains("alma")
27 || base_lower.contains("nobara")
28 {
29 Self::FedoraLike
30 } else if base_lower.contains("arch")
31 || base_lower.contains("cachy")
32 || base_lower.contains("manjaro")
33 || base_lower.contains("endeavouros")
34 || base_lower.contains("garuda")
35 {
36 Self::ArchLike
37 } else if base_lower.contains("alpine") {
38 Self::AlpineLike
39 } else if base_lower.contains("opensuse") || base_lower.contains("suse") {
40 Self::SuseLike
41 } else {
42 Self::Unknown
43 }
44 }
45
46 pub fn manager(&self) -> crate::config::PackageManager {
47 match self {
48 Self::DebianLike => crate::config::PackageManager::Apt,
49 Self::FedoraLike => crate::config::PackageManager::Dnf,
50 Self::ArchLike => crate::config::PackageManager::Pacman,
51 Self::AlpineLike => crate::config::PackageManager::Apk,
52 Self::SuseLike => crate::config::PackageManager::Zypper,
53 Self::Unknown => crate::config::PackageManager::Dnf,
54 }
55 }
56
57 pub fn install_cmd(&self) -> &'static str {
58 match self {
59 Self::DebianLike => "apt-get update && apt-get install -y --no-install-recommends",
60 Self::FedoraLike => "dnf install -y",
61 Self::ArchLike => "pacman -Syu --noconfirm",
62 Self::AlpineLike => "apk add --no-cache",
63 Self::SuseLike => "zypper install -y",
64 Self::Unknown => "dnf install -y",
65 }
66 }
67
68 pub fn clean_cmd(&self) -> &'static str {
69 match self {
70 Self::DebianLike => "rm -rf /var/lib/apt/lists/*",
71 Self::FedoraLike => "dnf clean all",
72 Self::ArchLike => "pacman -Scc --noconfirm",
73 Self::AlpineLike => "",
74 Self::SuseLike => "zypper clean --all",
75 Self::Unknown => "dnf clean all",
76 }
77 }
78
79 pub fn base_packages(&self, host_shell: Option<&str>) -> Vec<String> {
80 let common: Vec<String> = [
81 "sudo",
82 "curl",
83 "tar",
84 "unzip",
85 "wget",
86 "which",
87 "coreutils",
88 "diffutils",
89 "findutils",
90 "grep",
91 "sed",
92 "gawk",
93 "bash-completion",
94 ]
95 .into_iter()
96 .map(String::from)
97 .collect();
98
99 let mut pkgs = common;
100
101 if let Some(shell) = host_shell {
102 let shell_pkgs = Self::shell_packages(self, shell);
103 for pkg in shell_pkgs {
104 if !pkgs.contains(&pkg) {
105 pkgs.push(pkg);
106 }
107 }
108 }
109
110 pkgs
111 }
112
113 fn shell_packages(&self, shell_path: &str) -> Vec<String> {
114 let shell_name = shell_path.split('/').next_back().unwrap_or("");
115 let mut pkgs = Vec::new();
116
117 match shell_name {
118 "bash" => {
119 pkgs.push("bash".into());
120 pkgs.push("bash-completion".into());
121 }
122 "zsh" => {
123 pkgs.push("zsh".into());
124 match self {
125 Self::DebianLike => pkgs.push("zsh-common".into()),
126 Self::FedoraLike => pkgs.push("zsh".into()),
127 Self::ArchLike => pkgs.push("zsh-completions".into()),
128 Self::AlpineLike => pkgs.push("zsh".into()),
129 Self::SuseLike => pkgs.push("zsh".into()),
130 Self::Unknown => pkgs.push("zsh".into()),
131 }
132 }
133 "fish" => {
134 pkgs.push("fish".into());
135 }
136 "sh" | "dash" => match self {
137 Self::DebianLike => pkgs.push("dash".into()),
138 Self::FedoraLike => pkgs.push("dash".into()),
139 Self::ArchLike => pkgs.push("dash".into()),
140 Self::AlpineLike => pkgs.push("dash".into()),
141 Self::SuseLike => pkgs.push("dash".into()),
142 Self::Unknown => pkgs.push("dash".into()),
143 },
144 _ => {}
145 }
146
147 pkgs
148 }
149
150 pub fn remove_cmd(&self, packages: &[String]) -> String {
151 if packages.is_empty() {
152 return String::new();
153 }
154 let pkgs = packages.join(" ");
155 match self {
156 Self::DebianLike => {
157 format!(
158 "apt-get purge -y {} && apt-get autoremove -y && {}",
159 pkgs,
160 Self::clean_cmd(self)
161 )
162 }
163 Self::FedoraLike => {
164 format!("dnf remove -y {} && {}", pkgs, Self::clean_cmd(self))
165 }
166 Self::ArchLike => {
167 format!(
168 "pacman -Rns --noconfirm {} && {}",
169 pkgs,
170 Self::clean_cmd(self)
171 )
172 }
173 Self::AlpineLike => {
174 format!("apk del {} && {}", pkgs, Self::clean_cmd(self))
175 }
176 Self::SuseLike => {
177 format!("zypper rm {} && {}", pkgs, Self::clean_cmd(self))
178 }
179 Self::Unknown => {
180 format!("dnf remove -y {} && {}", pkgs, Self::clean_cmd(self))
181 }
182 }
183 }
184
185 pub fn locale_packages(&self) -> Vec<String> {
186 match self {
187 Self::DebianLike => vec!["locales".into()],
188 Self::FedoraLike => vec!["glibc-all-langpacks".into()],
189 Self::ArchLike => vec!["glibc".into()],
190 Self::AlpineLike => vec!["musl-locales".into()],
191 Self::SuseLike => vec!["glibc-all-langpacks".into()],
192 Self::Unknown => vec!["locales".into()],
193 }
194 }
195}
196
197pub fn detect_host_shell() -> Option<String> {
198 std::env::var("SHELL").ok().filter(|s| !s.is_empty())
199}
200
201pub fn detect_host_locale() -> Option<String> {
202 std::env::var("LANG")
203 .ok()
204 .or_else(|| std::env::var("LC_ALL").ok())
205 .or_else(|| std::env::var("LC_CTYPE").ok())
206 .filter(|s| !s.is_empty())
207}
208
209pub fn detect_package_manager(image: &str) -> crate::config::PackageManager {
210 DistroFamily::from_base_image(image).manager()
211}
212
213pub fn is_tty() -> bool {
214 nix::unistd::isatty(std::io::stdin()).unwrap_or(false)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_distro_from_base_image() {
223 assert_eq!(
224 DistroFamily::from_base_image("debian:12"),
225 DistroFamily::DebianLike
226 );
227 assert_eq!(
228 DistroFamily::from_base_image("ubuntu:26.04"),
229 DistroFamily::DebianLike
230 );
231 assert_eq!(
232 DistroFamily::from_base_image("fedora:41"),
233 DistroFamily::FedoraLike
234 );
235 assert_eq!(
236 DistroFamily::from_base_image("cachy-latest"),
237 DistroFamily::ArchLike
238 );
239 assert_eq!(
240 DistroFamily::from_base_image("archlinux:latest"),
241 DistroFamily::ArchLike
242 );
243 assert_eq!(
244 DistroFamily::from_base_image("alpine:3.20"),
245 DistroFamily::AlpineLike
246 );
247 assert_eq!(
248 DistroFamily::from_base_image("opensuse/tumbleweed:latest"),
249 DistroFamily::SuseLike
250 );
251 assert_eq!(
252 DistroFamily::from_base_image("unknown:latest"),
253 DistroFamily::Unknown
254 );
255 }
256
257 #[test]
258 fn test_debian_base_packages() {
259 let pkgs = DistroFamily::DebianLike.base_packages(Some("/usr/bin/fish"));
260 assert!(pkgs.contains(&"sudo".into()));
261 assert!(pkgs.contains(&"curl".into()));
262 assert!(pkgs.contains(&"fish".into()));
263 }
264
265 #[test]
266 fn test_fedora_base_packages() {
267 let pkgs = DistroFamily::FedoraLike.base_packages(Some("/usr/bin/zsh"));
268 assert!(pkgs.contains(&"sudo".into()));
269 assert!(pkgs.contains(&"zsh".into()));
270 assert!(pkgs.contains(&"zsh-completions".into()) || pkgs.contains(&"zsh".into()));
271 }
272
273 #[test]
274 fn test_arch_base_packages() {
275 let pkgs = DistroFamily::ArchLike.base_packages(Some("/bin/bash"));
276 assert!(pkgs.contains(&"sudo".into()));
277 assert!(pkgs.contains(&"bash".into()));
278 assert!(pkgs.contains(&"bash-completion".into()));
279 }
280
281 #[test]
282 fn test_alpine_base_packages() {
283 let pkgs = DistroFamily::AlpineLike.base_packages(None);
284 assert!(pkgs.contains(&"sudo".into()));
285 assert!(pkgs.contains(&"curl".into()));
286 }
287
288 #[test]
289 fn test_install_cmd() {
290 assert_eq!(
291 DistroFamily::DebianLike.install_cmd(),
292 "apt-get update && apt-get install -y --no-install-recommends"
293 );
294 assert_eq!(DistroFamily::FedoraLike.install_cmd(), "dnf install -y");
295 assert_eq!(
296 DistroFamily::ArchLike.install_cmd(),
297 "pacman -Syu --noconfirm"
298 );
299 assert_eq!(DistroFamily::AlpineLike.install_cmd(), "apk add --no-cache");
300 assert_eq!(DistroFamily::SuseLike.install_cmd(), "zypper install -y");
301 }
302}