1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LinuxDistro {
22 Debian,
23 Fedora,
24 Arch,
25 Suse,
26 Unknown,
29}
30
31impl LinuxDistro {
32 #[must_use]
34 pub const fn label(self) -> &'static str {
35 match self {
36 Self::Debian => "Debian/Ubuntu",
37 Self::Fedora => "Fedora/RHEL",
38 Self::Arch => "Arch Linux",
39 Self::Suse => "openSUSE",
40 Self::Unknown => "Linux",
41 }
42 }
43
44 #[must_use]
49 pub const fn installer(self) -> Option<&'static str> {
50 match self {
51 Self::Debian => Some("apt install"),
52 Self::Fedora => Some("dnf install"),
53 Self::Arch => Some("pacman -S"),
54 Self::Suse => Some("zypper install"),
55 Self::Unknown => None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct PackageNames {
63 pub debian: &'static str,
64 pub fedora: &'static str,
65 pub arch: &'static str,
66 pub suse: &'static str,
67 pub generic: &'static str,
70}
71
72impl PackageNames {
73 #[must_use]
75 pub const fn for_distro(&self, distro: LinuxDistro) -> Option<&'static str> {
76 match distro {
77 LinuxDistro::Debian => Some(self.debian),
78 LinuxDistro::Fedora => Some(self.fedora),
79 LinuxDistro::Arch => Some(self.arch),
80 LinuxDistro::Suse => Some(self.suse),
81 LinuxDistro::Unknown => None,
82 }
83 }
84}
85
86const PACKAGES: &[(&str, PackageNames)] = &[
93 (
94 "git",
95 PackageNames {
96 debian: "git",
97 fedora: "git",
98 arch: "git",
99 suse: "git",
100 generic: "git",
101 },
102 ),
103 (
104 "make",
105 PackageNames {
106 debian: "build-essential",
107 fedora: "gcc gcc-c++ make",
108 arch: "base-devel",
109 suse: "gcc gcc-c++ make",
110 generic: "a C/C++ toolchain (make, gcc, g++)",
111 },
112 ),
113 (
114 "gcc",
115 PackageNames {
116 debian: "build-essential",
117 fedora: "gcc gcc-c++ make",
118 arch: "base-devel",
119 suse: "gcc gcc-c++ make",
120 generic: "a C/C++ toolchain (make, gcc, g++)",
121 },
122 ),
123 (
124 "g++",
125 PackageNames {
126 debian: "build-essential",
127 fedora: "gcc gcc-c++ make",
128 arch: "base-devel",
129 suse: "gcc gcc-c++ make",
130 generic: "a C/C++ toolchain (make, gcc, g++)",
131 },
132 ),
133 (
134 "pkg-config",
135 PackageNames {
136 debian: "pkg-config",
137 fedora: "pkgconfig",
138 arch: "pkgconf",
139 suse: "pkg-config",
140 generic: "pkg-config",
141 },
142 ),
143 (
144 "cmake",
145 PackageNames {
146 debian: "cmake",
147 fedora: "cmake",
148 arch: "cmake",
149 suse: "cmake",
150 generic: "cmake",
151 },
152 ),
153 (
154 "python3",
155 PackageNames {
156 debian: "python3",
157 fedora: "python3",
158 arch: "python",
159 suse: "python3",
160 generic: "python3",
161 },
162 ),
163 (
164 "libssl-dev",
165 PackageNames {
166 debian: "libssl-dev",
167 fedora: "openssl-devel",
168 arch: "openssl",
169 suse: "libopenssl-devel",
170 generic: "OpenSSL development headers",
171 },
172 ),
173 (
174 "patchelf",
175 PackageNames {
176 debian: "patchelf",
177 fedora: "patchelf",
178 arch: "patchelf",
179 suse: "patchelf",
180 generic: "patchelf",
181 },
182 ),
183 (
184 "webkit2gtk-4.1",
185 PackageNames {
186 debian: "libwebkit2gtk-4.1-dev",
187 fedora: "webkit2gtk4.1-devel",
188 arch: "webkit2gtk-4.1",
189 suse: "webkit2gtk3-devel",
190 generic: "WebKit2GTK 4.1 development headers",
191 },
192 ),
193 (
194 "librsvg",
195 PackageNames {
196 debian: "librsvg2-dev",
197 fedora: "librsvg2-devel",
198 arch: "librsvg",
199 suse: "librsvg-devel",
200 generic: "librsvg development headers",
201 },
202 ),
203 (
204 "libappindicator-gtk3",
209 PackageNames {
210 debian: "libayatana-appindicator3-dev",
211 fedora: "libayatana-appindicator-gtk3-devel",
212 arch: "libayatana-appindicator",
213 suse: "libayatana-appindicator3-devel",
214 generic: "libayatana-appindicator3 development headers",
215 },
216 ),
217 (
218 "gtk-layer-shell",
222 PackageNames {
223 debian: "libgtk-layer-shell-dev",
224 fedora: "gtk-layer-shell-devel",
225 arch: "gtk-layer-shell",
226 suse: "gtk-layer-shell-devel",
227 generic: "gtk-layer-shell",
228 },
229 ),
230 (
231 "libasound2-dev",
232 PackageNames {
233 debian: "libasound2-dev",
234 fedora: "alsa-lib-devel",
235 arch: "alsa-lib",
236 suse: "alsa-devel",
237 generic: "ALSA development headers",
238 },
239 ),
240 (
241 "libcurl-dev",
242 PackageNames {
243 debian: "libcurl4-openssl-dev",
244 fedora: "libcurl-devel",
245 arch: "curl",
246 suse: "libcurl-devel",
247 generic: "libcurl development headers",
248 },
249 ),
250 (
251 "libsqlite3-dev",
252 PackageNames {
253 debian: "libsqlite3-dev",
254 fedora: "sqlite-devel",
255 arch: "sqlite",
256 suse: "sqlite3-devel",
257 generic: "SQLite3 development headers",
258 },
259 ),
260 (
261 "libclang-dev",
262 PackageNames {
263 debian: "libclang-dev",
264 fedora: "clang-devel",
265 arch: "clang",
266 suse: "clang-devel",
267 generic: "libclang development headers",
268 },
269 ),
270 (
271 "Vulkan headers",
272 PackageNames {
273 debian: "libvulkan-dev",
274 fedora: "vulkan-loader-devel",
275 arch: "vulkan-headers",
276 suse: "vulkan-devel",
277 generic: "Vulkan development headers",
278 },
279 ),
280 (
281 "glslc",
282 PackageNames {
283 debian: "glslc",
284 fedora: "glslc",
285 arch: "shaderc",
286 suse: "shaderc",
287 generic: "glslc (the shaderc SPIR-V compiler)",
288 },
289 ),
290 (
291 "SPIR-V headers",
292 PackageNames {
293 debian: "spirv-headers",
294 fedora: "spirv-headers-devel",
295 arch: "spirv-headers",
296 suse: "spirv-headers",
297 generic: "SPIR-V headers",
298 },
299 ),
300 (
301 "Vulkan",
302 PackageNames {
303 debian: "mesa-vulkan-drivers vulkan-tools",
304 fedora: "mesa-vulkan-drivers vulkan-tools",
305 arch: "vulkan-radeon vulkan-tools",
306 suse: "libvulkan_radeon vulkan-tools",
307 generic: "your GPU vendor's Vulkan driver, plus vulkan-tools",
308 },
309 ),
310];
311
312#[must_use]
318pub fn packages_for(dependency: &str) -> Option<PackageNames> {
319 PACKAGES
320 .iter()
321 .find(|(name, _)| *name == dependency)
322 .map(|(_, packages)| *packages)
323}
324
325#[must_use]
331pub fn install_hint(dependency: &str, distro: LinuxDistro) -> Option<String> {
332 let names = packages_for(dependency)?;
333 let installer = distro.installer()?;
334 let package = names.for_distro(distro)?;
335
336 Some(format!("{installer} {package}"))
337}
338
339#[must_use]
350pub fn parse_os_release(contents: &str) -> LinuxDistro {
351 let mut id = None;
352 let mut id_like = None;
353
354 for line in contents.lines() {
355 let line = line.trim();
356 if let Some(value) = line.strip_prefix("ID=") {
357 id = Some(unquote(value));
358 } else if let Some(value) = line.strip_prefix("ID_LIKE=") {
359 id_like = Some(unquote(value));
360 }
361 }
362
363 if let Some(family) = id.and_then(family_of) {
364 return family;
365 }
366
367 id_like
370 .and_then(|likes| likes.split_whitespace().find_map(family_of))
371 .unwrap_or(LinuxDistro::Unknown)
372}
373
374fn unquote(value: &str) -> &str {
376 let value = value.trim();
377 value
378 .strip_prefix('"')
379 .and_then(|v| v.strip_suffix('"'))
380 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
381 .unwrap_or(value)
382}
383
384fn family_of(id: &str) -> Option<LinuxDistro> {
390 match id.to_ascii_lowercase().as_str() {
391 "debian" | "ubuntu" | "linuxmint" | "pop" | "raspbian" => Some(LinuxDistro::Debian),
392 "fedora" | "rhel" | "centos" | "rocky" | "almalinux" => Some(LinuxDistro::Fedora),
393 "arch" | "archarm" | "cachyos" | "manjaro" | "endeavouros" => Some(LinuxDistro::Arch),
394 "opensuse" | "opensuse-leap" | "opensuse-tumbleweed" | "sles" | "suse" => {
395 Some(LinuxDistro::Suse)
396 }
397 _ => None,
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
407 fn cachyos_is_arch() {
408 let os_release = "\
409NAME=\"CachyOS Linux\"
410PRETTY_NAME=\"CachyOS\"
411ID=cachyos
412ID_LIKE=arch
413ANSI_COLOR=\"38;2;23;147;209\"
414HOME_URL=\"https://cachyos.org/\"
415";
416
417 assert_eq!(parse_os_release(os_release), LinuxDistro::Arch);
418 }
419
420 #[test]
423 fn an_unknown_derivative_falls_back_to_its_family() {
424 let os_release = "ID=somenewarchspin\nID_LIKE=arch\n";
425
426 assert_eq!(parse_os_release(os_release), LinuxDistro::Arch);
427 }
428
429 #[test]
431 fn the_closest_relation_in_id_like_wins() {
432 let os_release = "ID=ultramarine\nID_LIKE=\"fedora rhel centos\"\n";
433
434 assert_eq!(parse_os_release(os_release), LinuxDistro::Fedora);
435 }
436
437 #[test]
441 fn a_url_containing_arch_does_not_make_it_arch() {
442 let os_release = "\
443NAME=\"Example Linux\"
444ID=example
445HOME_URL=\"https://example.org/research/\"
446SUPPORT_URL=\"https://example.org/research/support\"
447";
448
449 assert_eq!(parse_os_release(os_release), LinuxDistro::Unknown);
450 }
451
452 #[test]
455 fn id_like_is_matched_whole_not_by_substring() {
456 let os_release = "ID=example\nID_LIKE=monarch\n";
457
458 assert_eq!(parse_os_release(os_release), LinuxDistro::Unknown);
459 }
460
461 #[test]
463 fn a_file_without_an_id_is_unknown() {
464 let os_release = "NAME=\"Some Linux\"\nVERSION=\"1.0\"\n";
465
466 assert_eq!(parse_os_release(os_release), LinuxDistro::Unknown);
467 }
468
469 #[test]
471 fn no_os_release_at_all_is_unknown() {
472 assert_eq!(parse_os_release(""), LinuxDistro::Unknown);
473 }
474
475 #[test]
477 fn quoted_and_bare_ids_are_equivalent() {
478 assert_eq!(parse_os_release("ID=\"ubuntu\"\n"), LinuxDistro::Debian);
479 assert_eq!(parse_os_release("ID=ubuntu\n"), LinuxDistro::Debian);
480 assert_eq!(parse_os_release("ID='ubuntu'\n"), LinuxDistro::Debian);
481 }
482
483 #[test]
484 fn the_common_families_are_recognised() {
485 assert_eq!(parse_os_release("ID=ubuntu\n"), LinuxDistro::Debian);
486 assert_eq!(parse_os_release("ID=fedora\n"), LinuxDistro::Fedora);
487 assert_eq!(parse_os_release("ID=manjaro\n"), LinuxDistro::Arch);
488 assert_eq!(
489 parse_os_release("ID=\"opensuse-tumbleweed\"\n"),
490 LinuxDistro::Suse
491 );
492 }
493
494 #[test]
497 fn cachyos_is_told_to_use_pacman() {
498 assert_eq!(
499 install_hint("libappindicator-gtk3", LinuxDistro::Arch).as_deref(),
500 Some("pacman -S libayatana-appindicator")
501 );
502 }
503
504 #[test]
507 fn gtk_layer_shell_resolves_on_every_family() {
508 assert_eq!(
509 install_hint("gtk-layer-shell", LinuxDistro::Arch).as_deref(),
510 Some("pacman -S gtk-layer-shell")
511 );
512 assert_eq!(
513 install_hint("gtk-layer-shell", LinuxDistro::Debian).as_deref(),
514 Some("apt install libgtk-layer-shell-dev")
515 );
516 assert_eq!(
517 install_hint("gtk-layer-shell", LinuxDistro::Fedora).as_deref(),
518 Some("dnf install gtk-layer-shell-devel")
519 );
520 }
521
522 #[test]
523 fn each_family_gets_its_own_installer() {
524 assert_eq!(
525 install_hint("libssl-dev", LinuxDistro::Debian).as_deref(),
526 Some("apt install libssl-dev")
527 );
528 assert_eq!(
529 install_hint("libssl-dev", LinuxDistro::Fedora).as_deref(),
530 Some("dnf install openssl-devel")
531 );
532 assert_eq!(
533 install_hint("libssl-dev", LinuxDistro::Suse).as_deref(),
534 Some("zypper install libopenssl-devel")
535 );
536 }
537
538 #[test]
541 fn an_unknown_distro_gets_no_command() {
542 assert!(install_hint("libssl-dev", LinuxDistro::Unknown).is_none());
543 assert_eq!(
544 packages_for("libssl-dev").map(|p| p.generic),
545 Some("OpenSSL development headers")
546 );
547 }
548
549 #[test]
552 fn toolchains_that_are_not_system_packages_have_no_row() {
553 assert!(packages_for("cargo").is_none());
554 assert!(packages_for("rustc").is_none());
555 assert!(packages_for("node").is_none());
556 assert!(packages_for("npm").is_none());
557 }
558
559 #[test]
561 fn the_compilers_share_one_package() {
562 let names = ["make", "gcc", "g++"].map(|d| {
563 packages_for(d)
564 .and_then(|p| p.for_distro(LinuxDistro::Arch))
565 .expect("toolchain packages are known")
566 });
567
568 assert_eq!(names, ["base-devel", "base-devel", "base-devel"]);
569 }
570
571 #[test]
574 fn every_package_resolves_on_every_family() {
575 for (dependency, _) in PACKAGES {
576 for distro in [
577 LinuxDistro::Debian,
578 LinuxDistro::Fedora,
579 LinuxDistro::Arch,
580 LinuxDistro::Suse,
581 ] {
582 let hint = install_hint(dependency, distro);
583 assert!(
584 hint.is_some_and(|h| !h.trim().is_empty()),
585 "{dependency} has no package on {}",
586 distro.label()
587 );
588 }
589 }
590 }
591}