Skip to main content

gglib_core/utils/system/
packages.rs

1//! Linux distribution identity and the package names that follow from it.
2//!
3//! The same knowledge — "what is this distro, and what is this dependency
4//! called on it" — was previously spelled out in five places: an `/etc/os-release`
5//! substring match in the CLI, another in the llama.cpp build checker, four
6//! near-identical `match` blocks turning dependency names into apt/dnf/pacman/
7//! zypper packages, and twenty hardcoded `apt install` install hints that were
8//! simply wrong anywhere else. Keeping five copies in step by hand is what let
9//! them drift.
10//!
11//! Everything here is pure. [`parse_os_release`] takes the file's *contents*
12//! rather than reading them, so this stays in the domain layer with no I/O, and
13//! the parser can be tested against real files from distributions no CI machine
14//! is running.
15
16/// Distribution family, which is what package names actually key off.
17///
18/// Families rather than distributions: Mint installs like Debian and `CachyOS`
19/// installs like Arch, and enumerating every derivative would be a losing race.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LinuxDistro {
22    Debian,
23    Fedora,
24    Arch,
25    Suse,
26    /// Identified as none of the above — including when `/etc/os-release` was
27    /// missing or unreadable, which callers report the same way.
28    Unknown,
29}
30
31impl LinuxDistro {
32    /// Human-readable family name, for headings and instructions.
33    #[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    /// The install command this family uses, without `sudo`.
45    ///
46    /// `None` for [`Self::Unknown`], where guessing would be worse than saying
47    /// so: a wrong command is followed and fails, a missing one is looked up.
48    #[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/// What one dependency is called on each family.
61#[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    /// Prose for an unidentified distribution, where a package name would be a
68    /// guess but "OpenSSL development headers" is still actionable.
69    pub generic: &'static str,
70}
71
72impl PackageNames {
73    /// The name for `distro`, or `None` when there is no name to give.
74    #[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
86/// Dependency name → package names, keyed by the names used in
87/// `SystemProbePort::check_all_dependencies`.
88///
89/// Several toolchain entries deliberately share a row: `make`, `gcc` and `g++`
90/// all arrive with `build-essential` on Debian and `base-devel` on Arch, so
91/// callers listing several missing dependencies must de-duplicate the result.
92const 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        // Ayatana rather than the older libappindicator on every family:
205        // `check_libappindicator` probes `ayatana-appindicator3-0.1` first, and
206        // on Arch the non-Ayatana package left the repositories, so the name
207        // this table used to give could not be installed at all.
208        "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        // Optional, unlike its neighbours here: the tray panel is loaded at
219        // runtime and falls back to compositor placement when this is absent,
220        // so a missing entry degrades the panel's position rather than the app.
221        "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/// Package names for a dependency.
313///
314/// `None` when the dependency does not come from the system package manager at
315/// all: `cargo` and `node` have their own installers, and pointing someone at a
316/// distribution package for those would be actively unhelpful.
317#[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/// A ready-to-run install command for one dependency, e.g.
326/// `pacman -S libayatana-appindicator`.
327///
328/// `None` when the distribution is unidentified or the dependency is not a
329/// system package; callers fall back to [`PackageNames::generic`] prose.
330#[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/// Identify the distribution family from the contents of `/etc/os-release`.
340///
341/// Reads the `ID` and `ID_LIKE` fields defined by the os-release specification
342/// rather than searching the file for distribution names. That distinction is
343/// the whole point: `HOME_URL="https://example.org/research/"` contains "arch",
344/// and a substring search would call that machine Arch Linux and hand it
345/// `pacman` commands.
346///
347/// `ID` wins over `ID_LIKE`, so a derivative that names itself is taken at its
348/// word before its declared kinship is consulted.
349#[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 is a space-separated list, most closely related first, so the
368    // first one recognised is the closest match rather than merely any match.
369    id_like
370        .and_then(|likes| likes.split_whitespace().find_map(family_of))
371        .unwrap_or(LinuxDistro::Unknown)
372}
373
374/// Strip the optional quoting os-release allows around values.
375fn 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
384/// Map a single os-release identifier to its family.
385///
386/// Derivatives are listed where they are common enough to be worth naming, but
387/// the list is not the safety net — `ID_LIKE` is, which is what makes an
388/// unlisted derivative work anyway.
389fn 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    /// `CachyOS` names itself, so `ID` alone identifies it.
406    #[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    /// The safety net for derivatives nobody has enumerated: an unrecognised
421    /// `ID` still resolves through `ID_LIKE`.
422    #[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    /// `ID_LIKE` lists the closest relation first, and that is the one to use.
430    #[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    /// The bug that made this a parser rather than a substring search:
438    /// "research" contains "arch". A machine like this used to be handed
439    /// `pacman` commands.
440    #[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    /// Same trap through the other field: `ID_LIKE` is matched token by token,
453    /// so a value merely containing "arch" is not a match.
454    #[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    /// A file with no `ID` at all is unknown rather than a guess.
462    #[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    /// So is an empty or missing file, which callers pass through as `""`.
470    #[test]
471    fn no_os_release_at_all_is_unknown() {
472        assert_eq!(parse_os_release(""), LinuxDistro::Unknown);
473    }
474
475    /// os-release values may be quoted or bare; both are the same value.
476    #[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    /// The point of the exercise: `CachyOS` gets a command it can actually run,
495    /// for the library whose absence means no system tray at all.
496    #[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    /// The panel-placement library is packaged under four different names, and
505    /// the Arch one is the reason this row exists at all.
506    #[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    /// No command is offered for a distribution we could not identify, because
539    /// a wrong one gets run and fails where a missing one gets looked up.
540    #[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    /// Dependencies with their own installers must not be dressed up as
550    /// distribution packages.
551    #[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    /// The compilers share a package, which is why callers de-duplicate.
560    #[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    /// Every row must resolve on every family, or some machine gets a hint
572    /// with a hole in it.
573    #[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}