Skip to main content

waterui_cli/toolchain/
linux.rs

1//! Linux system package toolchain checks.
2
3use crate::{
4    toolchain::{Host, Installation, Toolchain, ToolchainError, UnfixableToolchain},
5    utils::CommandError,
6};
7
8/// Linux system dependencies required by `waterui` desktop/media builds.
9#[derive(Debug, Clone, Copy, Default)]
10pub struct LinuxSystemToolchain;
11
12/// Installation plan for missing Linux system packages.
13#[derive(Debug, Clone)]
14pub struct LinuxSystemPackagesInstallation {
15    manager: LinuxPackageManager,
16    missing_packages: Vec<String>,
17}
18
19impl LinuxSystemPackagesInstallation {
20    const fn new(manager: LinuxPackageManager, missing_packages: Vec<String>) -> Self {
21        Self {
22            manager,
23            missing_packages,
24        }
25    }
26
27    /// Returns the detected package manager name.
28    #[must_use]
29    pub const fn package_manager_name(&self) -> &'static str {
30        self.manager.name()
31    }
32
33    /// Returns the missing packages for this installation plan.
34    #[must_use]
35    pub fn missing_packages(&self) -> &[String] {
36        &self.missing_packages
37    }
38
39    /// Returns a command hint for manual installation.
40    #[must_use]
41    pub fn install_command_hint(&self) -> String {
42        self.manager.install_hint(&self.missing_packages)
43    }
44
45    /// Build an installation plan for explicit package names using the detected manager.
46    ///
47    /// This reuses the existing Linux package-manager framework and is useful for
48    /// toolchains that discover missing capabilities via probes (for example,
49    /// pkg-config modules).
50    ///
51    /// # Errors
52    /// Returns an error when no supported package manager is available.
53    pub async fn from_packages(
54        host: &Host,
55        packages: Vec<String>,
56    ) -> Result<Self, UnfixableToolchain> {
57        let Some(manager) = LinuxPackageManager::detect(host).await else {
58            return Err(UnfixableToolchain::new(
59                "Unable to detect Linux package manager",
60                unsupported_manager_hint(),
61            ));
62        };
63        if packages.is_empty() {
64            return Err(UnfixableToolchain::new(
65                "No packages were provided for automatic installation",
66                "Re-run `water doctor` and inspect diagnostics.",
67            ));
68        }
69        Ok(Self::new(manager, packages))
70    }
71}
72
73/// Errors that can occur during Linux package installation.
74#[derive(Debug, thiserror::Error)]
75pub enum FailToInstallLinuxSystemPackages {
76    /// Non-Linux platforms are not supported by this installer.
77    #[error("Automatic Linux package installation is only supported on Linux hosts.")]
78    UnsupportedPlatform,
79    /// No supported package manager was detected.
80    #[error("No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk).")]
81    UnsupportedPackageManager,
82    /// A package-manager command failed.
83    #[error("Failed to install Linux system packages: {0}")]
84    CommandFailed(#[from] CommandError),
85}
86
87/// Errors from Linux package-manager operations.
88#[derive(Debug, thiserror::Error)]
89pub enum LinuxPackageManagerError {
90    /// No supported package manager was detected.
91    #[error("No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk).")]
92    UnsupportedPackageManager,
93    /// A package-manager command failed.
94    #[error(transparent)]
95    Command(#[from] CommandError),
96}
97
98/// A dotted-numeric version string could not be parsed.
99#[derive(Debug, thiserror::Error)]
100#[error("`{version}` is not a dotted-numeric version: {source}")]
101struct DottedVersionError {
102    version: String,
103    #[source]
104    source: std::num::ParseIntError,
105}
106
107/// Probing a versioned native library with `pkg-config` failed.
108#[derive(Debug, thiserror::Error)]
109enum NativeProbeError {
110    /// The `pkg-config` invocation failed.
111    #[error(transparent)]
112    Command(#[from] CommandError),
113    /// `pkg-config --modversion` succeeded but printed nothing.
114    #[error("`pkg-config --modversion {module}` printed nothing")]
115    EmptyModVersion {
116        /// The probed pkg-config module.
117        module: &'static str,
118    },
119    /// The reported version is not dotted-numeric.
120    #[error(transparent)]
121    Version(#[from] DottedVersionError),
122}
123
124impl Installation for LinuxSystemPackagesInstallation {
125    type Error = FailToInstallLinuxSystemPackages;
126
127    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
128        if !cfg!(target_os = "linux") {
129            return Err(FailToInstallLinuxSystemPackages::UnsupportedPlatform);
130        }
131
132        let Some(manager) = LinuxPackageManager::detect(host).await else {
133            return Err(FailToInstallLinuxSystemPackages::UnsupportedPackageManager);
134        };
135
136        install_missing_packages(host, manager, &self.missing_packages).await?;
137        Ok(())
138    }
139}
140
141impl Toolchain for LinuxSystemToolchain {
142    type Installation = LinuxSystemPackagesInstallation;
143
144    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
145        if !cfg!(target_os = "linux") {
146            return Ok(());
147        }
148
149        let Some(manager) = LinuxPackageManager::detect(host).await else {
150            return Err(ToolchainError::unfixable(
151                "Unable to detect Linux package manager",
152                unsupported_manager_hint(),
153            ));
154        };
155
156        let required_packages = manager.required_packages();
157        let mut missing_packages = Vec::new();
158        for &package in required_packages {
159            let installed = manager
160                .check_installed(host, package)
161                .await
162                .map_err(|error| {
163                    ToolchainError::unfixable(
164                        format!("Failed checking Linux package `{package}`: {error}"),
165                        manager.install_hint(&required_packages_to_owned(required_packages)),
166                    )
167                })?;
168            if !installed {
169                missing_packages.push(package.to_string());
170            }
171        }
172
173        if !missing_packages.is_empty() {
174            return Err(ToolchainError::fixable(
175                LinuxSystemPackagesInstallation::new(manager, missing_packages),
176            ));
177        }
178
179        check_versioned_native_libraries(host, manager).await
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum LinuxPackageManager {
185    Apt,
186    Dnf,
187    Pacman,
188    Zypper,
189    Apk,
190}
191
192impl LinuxPackageManager {
193    async fn detect(host: &Host) -> Option<Self> {
194        if host.which("apt-get").await.is_ok() {
195            Some(Self::Apt)
196        } else if host.which("dnf").await.is_ok() {
197            Some(Self::Dnf)
198        } else if host.which("pacman").await.is_ok() {
199            Some(Self::Pacman)
200        } else if host.which("zypper").await.is_ok() {
201            Some(Self::Zypper)
202        } else if host.which("apk").await.is_ok() {
203            Some(Self::Apk)
204        } else {
205            None
206        }
207    }
208
209    const fn name(self) -> &'static str {
210        match self {
211            Self::Apt => "apt-get",
212            Self::Dnf => "dnf",
213            Self::Pacman => "pacman",
214            Self::Zypper => "zypper",
215            Self::Apk => "apk",
216        }
217    }
218
219    const fn required_packages(self) -> &'static [&'static str] {
220        match self {
221            Self::Apt => &[
222                "pkg-config",
223                "libgtk-4-dev",
224                "libpango1.0-dev",
225                "libwayland-dev",
226                "wayland-protocols",
227                "libasound2-dev",
228                "libva-dev",
229                "libgbm-dev",
230                "libxcb1-dev",
231                "libclang-dev",
232                "libfontconfig-dev",
233            ],
234            Self::Dnf => &[
235                "pkgconf-pkg-config",
236                "gtk4-devel",
237                "pango-devel",
238                "wayland-devel",
239                "wayland-protocols-devel",
240                "alsa-lib-devel",
241                "libva-devel",
242                "mesa-libgbm-devel",
243                "libxcb-devel",
244                "clang-devel",
245                "fontconfig-devel",
246            ],
247            Self::Pacman => &[
248                "pkgconf",
249                "gtk4",
250                "pango",
251                "wayland",
252                "wayland-protocols",
253                "alsa-lib",
254                "libva",
255                "mesa",
256                "libxcb",
257                "clang",
258                "fontconfig",
259            ],
260            Self::Zypper => &[
261                "pkg-config",
262                "gtk4-devel",
263                "pango-devel",
264                "wayland-devel",
265                "wayland-protocols-devel",
266                "alsa-devel",
267                "libva-devel",
268                "Mesa-libgbm-devel",
269                "libxcb-devel",
270                "clang-devel",
271                "fontconfig-devel",
272            ],
273            Self::Apk => &[
274                "pkgconf",
275                "gtk4.0-dev",
276                "pango-dev",
277                "wayland-dev",
278                "wayland-protocols",
279                "alsa-lib-dev",
280                "libva-dev",
281                "mesa-dev",
282                "libxcb-dev",
283                "clang-dev",
284                "fontconfig-dev",
285            ],
286        }
287    }
288
289    fn install_hint(self, packages: &[String]) -> String {
290        let package_list = packages.join(" ");
291        match self {
292            Self::Apt => format!("sudo apt-get install -y {package_list}"),
293            Self::Dnf => format!("sudo dnf install -y {package_list}"),
294            Self::Pacman => format!("sudo pacman -S --noconfirm --needed {package_list}"),
295            Self::Zypper => {
296                format!(
297                    "sudo zypper --non-interactive install --auto-agree-with-licenses {package_list}"
298                )
299            }
300            Self::Apk => format!("sudo apk add {package_list}"),
301        }
302    }
303
304    async fn check_installed(self, host: &Host, package: &str) -> Result<bool, CommandError> {
305        let output = match self {
306            Self::Apt => host.output("dpkg-query", ["-W", package]).await?,
307            Self::Dnf | Self::Zypper => host.output("rpm", ["-q", package]).await?,
308            Self::Pacman => host.output("pacman", ["-Q", package]).await?,
309            Self::Apk => host.output("apk", ["info", "-e", package]).await?,
310        };
311        Ok(output.status.success())
312    }
313
314    fn package_for_gtk_pkg_config_probe(self, probe: &str) -> Option<&'static str> {
315        if probe.starts_with("gtk4") {
316            return Some(match self {
317                Self::Apt => "libgtk-4-dev",
318                Self::Dnf | Self::Zypper => "gtk4-devel",
319                Self::Pacman => "gtk4",
320                Self::Apk => "gtk4.0-dev",
321            });
322        }
323        if probe.starts_with("pango") {
324            return Some(match self {
325                Self::Apt => "libpango1.0-dev",
326                Self::Dnf | Self::Zypper => "pango-devel",
327                Self::Pacman => "pango",
328                Self::Apk => "pango-dev",
329            });
330        }
331        None
332    }
333
334    /// Package that provides the development files for a version-checked
335    /// pkg-config module.
336    const fn package_for_native_library(self, module: &str) -> Option<&'static str> {
337        match module.as_bytes() {
338            b"libva" => Some(match self {
339                Self::Apt | Self::Apk => "libva-dev",
340                Self::Dnf | Self::Zypper => "libva-devel",
341                Self::Pacman => "libva",
342            }),
343            b"libpipewire-0.3" => Some(match self {
344                Self::Apt => "libpipewire-0.3-dev",
345                Self::Dnf | Self::Zypper => "pipewire-devel",
346                Self::Pacman => "libpipewire",
347                Self::Apk => "pipewire-dev",
348            }),
349            _ => None,
350        }
351    }
352}
353
354/// A native library whose Rust binding only compiles against headers newer than
355/// the ones some distributions ship.
356///
357/// Presence is not enough for these: `dpkg-query -W libva-dev` succeeds on
358/// Ubuntu 22.04 while `cros-libva` still fails to compile, so the doctor asks
359/// `pkg-config` for the version, which is the same mechanism the bindings' own
360/// build scripts use to find the headers.
361#[derive(Debug, Clone, Copy)]
362struct VersionedNativeLibrary {
363    /// pkg-config module whose `Version:` field carries the axis compared below.
364    module: &'static str,
365    /// Human name of that axis, used in diagnostics.
366    version_axis: &'static str,
367    /// Lowest version of `version_axis` the Rust binding compiles against.
368    minimum_version: &'static str,
369    /// Crate that imposes the floor.
370    required_by: &'static str,
371    /// Version of that crate the floor was read from; `crate_versions_match_lockfile`
372    /// keeps it honest against the workspace lockfile.
373    required_by_version: &'static str,
374    /// Where the floor is written down inside that crate, quoted in diagnostics.
375    requirement_source: &'static str,
376    /// pkg-config variable carrying the upstream release number, for modules
377    /// whose `Version:` field is some other number.
378    release_version_variable: Option<&'static str>,
379    /// What a user whose distribution ships an older build can actually do.
380    distribution_hint: &'static str,
381}
382
383/// Native libraries checked by version rather than by presence.
384///
385/// Every floor here is read out of the crate that imposes it, never guessed:
386///
387/// * **`libva` / VA-API 1.19** — `cros-libva`'s `build.rs` parses
388///   `VA_MAJOR_VERSION` / `VA_MINOR_VERSION` out of `va/va_version.h` and emits
389///   `cargo::rustc-cfg=libva_1_19_or_higher` when the VA-API version is at least
390///   1.19 (`build.rs:96-110`). `src/buffer/av1.rs` gates individual struct
391///   fields on that cfg (`av1.rs:626`, `av1.rs:1085`) but not the surrounding
392///   initialisers, so below VA-API 1.19 the crate does not compile at all — the
393///   `E0061` / `E0560` errors in <https://github.com/water-rs/waterui/issues/376>.
394///   `libva.pc` sets `Version:` to `va_api_version`, not to the libva release
395///   number (`pkgconfig/meson.build`, `pkg.generate(libva, …, version:
396///   va_api_version)`), so `pkg-config --modversion libva` reports exactly the
397///   number the cfg gate tests. The release number is exported next to it as
398///   the `libva_version` pkg-config variable and is only used for diagnostics.
399///
400/// * **`libpipewire-0.3` / `PipeWire` 0.3.65** — `libspa-sys` declares only
401///   `version = "0.3"` for `libpipewire-0.3` in `[package.metadata.system-deps]`,
402///   which does not describe the headers it needs, so the floor comes from the
403///   APIs `libspa` uses with no feature gate: `spa_meta_first` and
404///   `spa_meta_region_is_valid` (`libspa/src/buffer/meta.rs:107,157`) only reach
405///   Rust through `libspa-sys`'s `wrap_static_fns` bindgen pass once upstream
406///   turned them from macros into `static inline` functions, in `PipeWire` 0.3.59;
407///   `spa_video_info_raw::flags` and its `uint64_t modifier`
408///   (`libspa/src/param/video/raw.rs:258,266`) were added to
409///   `spa/include/spa/param/video/raw.h` in `PipeWire` 0.3.65 — 0.3.64 still has
410///   `int64_t modifier` and no `flags`. The later of the two wins.
411///   `libspa-0.2.pc` cannot carry this: `PipeWire` builds it with
412///   `version : spaversion` where `spaversion = '0.2'` is a constant
413///   (`meson.build:23`, `spa/meson.build:26`), so it reports `0.2` on every
414///   release. `libpipewire-0.3.pc` is built with `version : pipewire_version`
415///   (`src/pipewire/meson.build:127`) and is the module that carries the
416///   release number.
417const VERSIONED_NATIVE_LIBRARIES: &[VersionedNativeLibrary] = &[
418    VersionedNativeLibrary {
419        module: "libva",
420        version_axis: "VA-API",
421        minimum_version: "1.19",
422        required_by: "cros-libva",
423        required_by_version: "0.0.12",
424        requirement_source: "its build.rs only emits `libva_1_19_or_higher`, which src/buffer/av1.rs requires, from VA-API 1.19 up",
425        release_version_variable: Some("libva_version"),
426        distribution_hint: "VA-API 1.19 first ships in libva 2.19. Ubuntu 24.04 (libva 2.20) and Debian 13 trixie (libva 2.22) are new enough; Ubuntu 22.04 (libva 2.14) and Debian 12 bookworm (libva 2.17) are not, and neither has a backport in its updates or backports pocket. Upgrade the distribution, or build libva 2.19 or newer from https://github.com/intel/libva and put its prefix on PKG_CONFIG_PATH.",
427    },
428    VersionedNativeLibrary {
429        module: "libpipewire-0.3",
430        version_axis: "PipeWire",
431        minimum_version: "0.3.65",
432        required_by: "libspa",
433        required_by_version: "0.10.1",
434        requirement_source: "it uses spa_video_info_raw::flags, added in PipeWire 0.3.65, and spa_meta_first, a static inline function only since 0.3.59, without a feature gate",
435        release_version_variable: None,
436        distribution_hint: "Ubuntu 24.04 (PipeWire 1.0.5) and Debian 12 bookworm (PipeWire 0.3.65) are new enough; Ubuntu 22.04 (PipeWire 0.3.48) is not, and has no backport in its updates or backports pocket. Upgrade the distribution, or build PipeWire 0.3.65 or newer from https://gitlab.freedesktop.org/pipewire/pipewire and put its prefix on PKG_CONFIG_PATH.",
437    },
438];
439
440/// Outcome of probing one [`VersionedNativeLibrary`] with `pkg-config`.
441#[derive(Debug, Clone, PartialEq, Eq)]
442enum NativeLibraryStatus {
443    /// The module is present and new enough.
444    Satisfied,
445    /// `pkg-config` does not know the module at all.
446    ModuleMissing,
447    /// The module is present but older than the Rust binding accepts.
448    TooOld {
449        /// `pkg-config --modversion` output.
450        installed: String,
451        /// Upstream release number, when the module exports one separately.
452        release: Option<String>,
453    },
454}
455
456impl VersionedNativeLibrary {
457    /// Message shown when the library is installed but too old.
458    fn outdated_message(self, installed: &str, release: Option<&str>) -> String {
459        let Self {
460            module,
461            version_axis,
462            minimum_version,
463            required_by,
464            required_by_version,
465            ..
466        } = self;
467        let release = release.map_or_else(String::new, |release| format!(" (release {release})"));
468        format!(
469            "`{module}` is too old: pkg-config reports {version_axis} {installed}{release}, but `{required_by} {required_by_version}` needs {version_axis} {minimum_version} or newer"
470        )
471    }
472
473    /// Suggestion shown when the library is installed but too old.
474    ///
475    /// `package` is the distribution package that already provides the module,
476    /// so the text can say plainly that installing it again changes nothing.
477    fn outdated_suggestion(self, package: Option<&str>) -> String {
478        let Self {
479            module,
480            minimum_version,
481            required_by,
482            requirement_source,
483            distribution_hint,
484            ..
485        } = self;
486        let already_installed = package.map_or_else(
487            || format!("The package providing `{module}` is already installed"),
488            |package| format!("`{package}` is already installed"),
489        );
490        format!(
491            "{already_installed}, so installing it again will not help. `{required_by}` needs the newer headers because {requirement_source}. {distribution_hint} `pkg-config --modversion {module}` has to report {minimum_version} or newer."
492        )
493    }
494}
495
496/// Check the native libraries whose Rust bindings have a minimum version.
497async fn check_versioned_native_libraries(
498    host: &Host,
499    manager: LinuxPackageManager,
500) -> Result<(), ToolchainError<LinuxSystemPackagesInstallation>> {
501    if !pkg_config_available(host).await {
502        return Err(ToolchainError::unfixable(
503            "pkg-config not found",
504            "Install pkg-config and ensure it is in PATH, then re-run `water doctor`.",
505        ));
506    }
507
508    let mut missing_packages = Vec::new();
509    for &library in VERSIONED_NATIVE_LIBRARIES {
510        let package = manager.package_for_native_library(library.module);
511        let status = probe_native_library(host, library).await.map_err(|error| {
512            ToolchainError::unfixable(
513                format!(
514                    "Failed checking `{}` with pkg-config: {error}",
515                    library.module
516                ),
517                format!(
518                    "Ensure `pkg-config --modversion {}` works, then re-run `water doctor`.",
519                    library.module
520                ),
521            )
522        })?;
523
524        match status {
525            NativeLibraryStatus::Satisfied => {}
526            NativeLibraryStatus::ModuleMissing => {
527                let package = package.ok_or_else(|| {
528                    ToolchainError::unfixable(
529                        format!("`{}` is not known to pkg-config", library.module),
530                        format!(
531                            "Install the development package providing `{}` for this distribution, then re-run `water doctor`.",
532                            library.module
533                        ),
534                    )
535                })?;
536                if !missing_packages.iter().any(|existing| existing == package) {
537                    missing_packages.push(package.to_owned());
538                }
539            }
540            NativeLibraryStatus::TooOld { installed, release } => {
541                return Err(ToolchainError::unfixable(
542                    library.outdated_message(&installed, release.as_deref()),
543                    library.outdated_suggestion(package),
544                ));
545            }
546        }
547    }
548
549    if missing_packages.is_empty() {
550        Ok(())
551    } else {
552        Err(ToolchainError::fixable(
553            LinuxSystemPackagesInstallation::new(manager, missing_packages),
554        ))
555    }
556}
557
558/// Returns `true` when `pkg-config` can be executed.
559async fn pkg_config_available(host: &Host) -> bool {
560    host.output("pkg-config", ["--version"])
561        .await
562        .is_ok_and(|output| output.status.success())
563}
564
565/// Ask `pkg-config` for a module's version and compare it against the floor.
566async fn probe_native_library(
567    host: &Host,
568    library: VersionedNativeLibrary,
569) -> Result<NativeLibraryStatus, NativeProbeError> {
570    let output = host
571        .output("pkg-config", ["--modversion", library.module])
572        .await?;
573    if !output.status.success() {
574        return Ok(NativeLibraryStatus::ModuleMissing);
575    }
576    let installed = String::from_utf8_lossy(&output.stdout).trim().to_owned();
577    if installed.is_empty() {
578        return Err(NativeProbeError::EmptyModVersion {
579            module: library.module,
580        });
581    }
582
583    if version_at_least(&installed, library.minimum_version)? {
584        return Ok(NativeLibraryStatus::Satisfied);
585    }
586
587    let release = match library.release_version_variable {
588        Some(variable) => {
589            let output = host
590                .output(
591                    "pkg-config",
592                    [format!("--variable={variable}").as_str(), library.module],
593                )
594                .await?;
595            let value = String::from_utf8_lossy(&output.stdout).trim().to_owned();
596            (output.status.success() && !value.is_empty()).then_some(value)
597        }
598        None => None,
599    };
600
601    Ok(NativeLibraryStatus::TooOld { installed, release })
602}
603
604/// Compare two dotted-numeric pkg-config versions.
605///
606/// Every module checked here publishes a plain dotted-numeric `Version:` field
607/// (`1.19.0`, `0.3.65`, `1.4.7`), and a missing trailing component reads as
608/// zero, so `1.19` and `1.19.0` compare equal. A component that is not a number
609/// is reported instead of being silently accepted: passing an unreadable
610/// version would turn this check back into the presence check it replaces.
611///
612/// # Errors
613/// Returns an error when either version has a non-numeric component.
614fn version_at_least(installed: &str, minimum: &str) -> Result<bool, DottedVersionError> {
615    let installed = parse_version(installed)?;
616    let minimum = parse_version(minimum)?;
617    let len = installed.len().max(minimum.len());
618    for index in 0..len {
619        let left = installed.get(index).copied().unwrap_or(0);
620        let right = minimum.get(index).copied().unwrap_or(0);
621        if left != right {
622            return Ok(left > right);
623        }
624    }
625    Ok(true)
626}
627
628/// Split a dotted-numeric version into its components.
629fn parse_version(version: &str) -> Result<Vec<u64>, DottedVersionError> {
630    version
631        .split('.')
632        .map(|component| {
633            component
634                .parse::<u64>()
635                .map_err(|source| DottedVersionError {
636                    version: version.to_owned(),
637                    source,
638                })
639        })
640        .collect()
641}
642
643async fn run_with_optional_sudo(
644    host: &Host,
645    command: &str,
646    args: &[String],
647) -> Result<(), CommandError> {
648    if host.which("sudo").await.is_ok() {
649        let mut sudo_args = Vec::with_capacity(args.len() + 1);
650        sudo_args.push(command.to_string());
651        sudo_args.extend(args.iter().cloned());
652        host.run("sudo", sudo_args.iter().map(String::as_str))
653            .await?;
654    } else {
655        host.run(command, args.iter().map(String::as_str)).await?;
656    }
657    Ok(())
658}
659
660async fn install_missing_packages(
661    host: &Host,
662    manager: LinuxPackageManager,
663    packages: &[String],
664) -> Result<(), CommandError> {
665    if packages.is_empty() {
666        return Ok(());
667    }
668
669    match manager {
670        LinuxPackageManager::Apt => {
671            if packages.iter().any(|package| package.ends_with(":amd64")) {
672                ensure_apt_foreign_architecture(host, "amd64").await?;
673            }
674            run_with_optional_sudo(host, "apt-get", &[String::from("update")]).await?;
675            let mut args = vec![String::from("install"), String::from("-y")];
676            args.extend(packages.iter().cloned());
677            run_with_optional_sudo(host, "apt-get", &args).await?;
678        }
679        LinuxPackageManager::Dnf => {
680            let mut args = vec![String::from("install"), String::from("-y")];
681            args.extend(packages.iter().cloned());
682            run_with_optional_sudo(host, "dnf", &args).await?;
683        }
684        LinuxPackageManager::Pacman => {
685            let mut args = vec![
686                String::from("-S"),
687                String::from("--noconfirm"),
688                String::from("--needed"),
689            ];
690            args.extend(packages.iter().cloned());
691            run_with_optional_sudo(host, "pacman", &args).await?;
692        }
693        LinuxPackageManager::Zypper => {
694            let mut args = vec![
695                String::from("--non-interactive"),
696                String::from("install"),
697                String::from("--auto-agree-with-licenses"),
698            ];
699            args.extend(packages.iter().cloned());
700            run_with_optional_sudo(host, "zypper", &args).await?;
701        }
702        LinuxPackageManager::Apk => {
703            let mut args = vec![String::from("add")];
704            args.extend(packages.iter().cloned());
705            run_with_optional_sudo(host, "apk", &args).await?;
706        }
707    }
708
709    Ok(())
710}
711
712async fn ensure_apt_foreign_architecture(
713    host: &Host,
714    architecture: &str,
715) -> Result<(), CommandError> {
716    let output = host.run("dpkg", ["--print-foreign-architectures"]).await?;
717    if output.lines().any(|line| line.trim() == architecture) {
718        return Ok(());
719    }
720    run_with_optional_sudo(
721        host,
722        "dpkg",
723        &[String::from("--add-architecture"), architecture.to_string()],
724    )
725    .await
726}
727
728fn required_packages_to_owned(packages: &[&str]) -> Vec<String> {
729    packages
730        .iter()
731        .map(|package| (*package).to_string())
732        .collect()
733}
734
735fn unsupported_manager_hint() -> String {
736    let apt_hint = LinuxPackageManager::Apt.install_hint(&required_packages_to_owned(
737        LinuxPackageManager::Apt.required_packages(),
738    ));
739    let dnf_hint = LinuxPackageManager::Dnf.install_hint(&required_packages_to_owned(
740        LinuxPackageManager::Dnf.required_packages(),
741    ));
742    let pacman_hint = LinuxPackageManager::Pacman.install_hint(&required_packages_to_owned(
743        LinuxPackageManager::Pacman.required_packages(),
744    ));
745    let zypper_hint = LinuxPackageManager::Zypper.install_hint(&required_packages_to_owned(
746        LinuxPackageManager::Zypper.required_packages(),
747    ));
748    let alpine_hint = LinuxPackageManager::Apk.install_hint(&required_packages_to_owned(
749        LinuxPackageManager::Apk.required_packages(),
750    ));
751    format!(
752        "Install required packages manually. Debian/Ubuntu: `{apt_hint}`; Fedora/RHEL: `{dnf_hint}`; Arch: `{pacman_hint}`; openSUSE: `{zypper_hint}`; Alpine: `{alpine_hint}`."
753    )
754}
755
756/// Returns `true` when a supported Linux package manager is available.
757pub async fn has_supported_package_manager(host: &Host) -> bool {
758    LinuxPackageManager::detect(host).await.is_some()
759}
760
761/// Build an installation plan that repairs missing GTK pkg-config probes.
762///
763/// Supported probe names include `gtk4` and `pango>=1.50`.
764///
765/// # Errors
766/// Returns an error if no package manager is available or if a probe cannot be
767/// mapped to an installable system package.
768pub async fn gtk4_pkg_config_repair_installation(
769    host: &Host,
770    missing_modules: &[String],
771) -> Result<LinuxSystemPackagesInstallation, UnfixableToolchain> {
772    let Some(manager) = LinuxPackageManager::detect(host).await else {
773        return Err(UnfixableToolchain::new(
774            "Unable to detect Linux package manager",
775            unsupported_manager_hint(),
776        ));
777    };
778
779    let mut packages = Vec::new();
780    for module in missing_modules {
781        let package = manager
782            .package_for_gtk_pkg_config_probe(module)
783            .ok_or_else(|| {
784                UnfixableToolchain::new(
785                    format!("No package mapping is defined for GTK probe `{module}`"),
786                    "Install a package that provides the missing module via pkg-config, then re-run `water doctor`.",
787                )
788            })?;
789        if !packages.iter().any(|existing| existing == package) {
790            packages.push(package.to_owned());
791        }
792    }
793
794    LinuxSystemPackagesInstallation::from_packages(host, packages).await
795}
796
797/// Install named packages with the detected Linux package manager.
798///
799/// # Errors
800/// Returns an error when no supported package manager is available, or when
801/// installation fails.
802pub async fn install_named_packages(
803    host: &Host,
804    packages: &[&'static str],
805) -> Result<(), LinuxPackageManagerError> {
806    let Some(manager) = LinuxPackageManager::detect(host).await else {
807        return Err(LinuxPackageManagerError::UnsupportedPackageManager);
808    };
809
810    let packages: Vec<String> = packages
811        .iter()
812        .map(|package| (*package).to_string())
813        .collect();
814    install_missing_packages(host, manager, &packages).await?;
815    Ok(())
816}
817
818/// Install a JDK package using the detected Linux package manager.
819///
820/// # Errors
821/// Returns an error when no supported package manager is available, or when
822/// installation fails.
823pub async fn install_java_jdk(host: &Host) -> Result<(), LinuxPackageManagerError> {
824    let Some(manager) = LinuxPackageManager::detect(host).await else {
825        return Err(LinuxPackageManagerError::UnsupportedPackageManager);
826    };
827
828    let packages: Vec<String> = match manager {
829        LinuxPackageManager::Apt => vec![String::from("openjdk-21-jdk")],
830        LinuxPackageManager::Dnf | LinuxPackageManager::Zypper => {
831            vec![String::from("java-21-openjdk-devel")]
832        }
833        LinuxPackageManager::Pacman => vec![String::from("jdk-openjdk")],
834        LinuxPackageManager::Apk => vec![String::from("openjdk21-jdk")],
835    };
836
837    install_missing_packages(host, manager, &packages).await?;
838    Ok(())
839}
840
841#[cfg(test)]
842mod tests {
843    use super::{
844        LinuxPackageManager, NativeLibraryStatus, VERSIONED_NATIVE_LIBRARIES,
845        VersionedNativeLibrary, version_at_least,
846    };
847
848    /// Stand-in for `pkg-config --modversion` / `--variable=…` on a machine that
849    /// has no pkg-config, so the probe logic is testable without a subprocess.
850    fn interpret_pkg_config(
851        library: VersionedNativeLibrary,
852        modversion: Option<&str>,
853        release: Option<&str>,
854    ) -> NativeLibraryStatus {
855        let Some(modversion) = modversion else {
856            return NativeLibraryStatus::ModuleMissing;
857        };
858        if version_at_least(modversion.trim(), library.minimum_version).unwrap() {
859            NativeLibraryStatus::Satisfied
860        } else {
861            NativeLibraryStatus::TooOld {
862                installed: modversion.trim().to_owned(),
863                release: release.map(str::to_owned),
864            }
865        }
866    }
867
868    fn library(module: &str) -> VersionedNativeLibrary {
869        *VERSIONED_NATIVE_LIBRARIES
870            .iter()
871            .find(|library| library.module == module)
872            .expect("library is checked by the doctor")
873    }
874
875    #[test]
876    fn version_comparison_pads_missing_components() {
877        assert!(version_at_least("1.19.0", "1.19").unwrap());
878        assert!(version_at_least("1.19", "1.19.0").unwrap());
879        assert!(version_at_least("1.20.0", "1.19").unwrap());
880        assert!(!version_at_least("1.14.0", "1.19").unwrap());
881        assert!(!version_at_least("1.2.0", "1.19").unwrap());
882        assert!(version_at_least("1.4.7", "0.3.65").unwrap());
883        assert!(!version_at_least("0.3.48", "0.3.65").unwrap());
884        assert!(version_at_least("0.3.65", "0.3.65").unwrap());
885    }
886
887    #[test]
888    fn version_comparison_rejects_unreadable_versions() {
889        let error = version_at_least("1.19.0-rc1", "1.19").unwrap_err();
890        assert!(error.to_string().contains("dotted-numeric"));
891    }
892
893    #[test]
894    fn ubuntu_2204_libva_is_reported_as_too_old() {
895        let libva = library("libva");
896        // Ubuntu 22.04 ships libva 2.14.0, which is VA-API 1.14.0.
897        let status = interpret_pkg_config(libva, Some("1.14.0\n"), Some("2.14.0"));
898        let NativeLibraryStatus::TooOld { installed, release } = status else {
899            panic!("VA-API 1.14 must not satisfy the cros-libva floor");
900        };
901        let message = libva.outdated_message(&installed, release.as_deref());
902        assert_eq!(
903            message,
904            "`libva` is too old: pkg-config reports VA-API 1.14.0 (release 2.14.0), but `cros-libva 0.0.12` needs VA-API 1.19 or newer"
905        );
906
907        let suggestion = libva.outdated_suggestion(Some("libva-dev"));
908        assert!(suggestion.starts_with(
909            "`libva-dev` is already installed, so installing it again will not help."
910        ));
911        assert!(suggestion.contains("Ubuntu 22.04 (libva 2.14)"));
912        assert!(suggestion.contains("build libva 2.19 or newer"));
913        assert!(suggestion.contains("pkg-config --modversion libva"));
914    }
915
916    #[test]
917    fn ubuntu_2404_libva_satisfies_the_floor() {
918        // Ubuntu 24.04 ships libva 2.20.0, which is VA-API 1.20.0.
919        let status = interpret_pkg_config(library("libva"), Some("1.20.0\n"), Some("2.20.0"));
920        assert_eq!(status, NativeLibraryStatus::Satisfied);
921    }
922
923    #[test]
924    fn ubuntu_2204_pipewire_is_reported_as_too_old() {
925        let pipewire = library("libpipewire-0.3");
926        let status = interpret_pkg_config(pipewire, Some("0.3.48\n"), None);
927        let NativeLibraryStatus::TooOld { installed, release } = status else {
928            panic!("PipeWire 0.3.48 must not satisfy the libspa floor");
929        };
930        assert_eq!(release, None);
931        let message = pipewire.outdated_message(&installed, release.as_deref());
932        assert_eq!(
933            message,
934            "`libpipewire-0.3` is too old: pkg-config reports PipeWire 0.3.48, but `libspa 0.10.1` needs PipeWire 0.3.65 or newer"
935        );
936
937        let suggestion = pipewire.outdated_suggestion(Some("libpipewire-0.3-dev"));
938        assert!(suggestion.contains("spa_video_info_raw::flags"));
939        assert!(suggestion.contains("Ubuntu 22.04 (PipeWire 0.3.48)"));
940    }
941
942    #[test]
943    fn missing_module_is_reported_as_missing_not_outdated() {
944        assert_eq!(
945            interpret_pkg_config(library("libva"), None, None),
946            NativeLibraryStatus::ModuleMissing
947        );
948    }
949
950    #[test]
951    fn outdated_suggestion_without_a_package_mapping_still_reads() {
952        let suggestion = library("libva").outdated_suggestion(None);
953        assert!(suggestion.starts_with("The package providing `libva` is already installed,"));
954    }
955
956    #[test]
957    fn every_manager_maps_the_version_checked_modules() {
958        for manager in [
959            LinuxPackageManager::Apt,
960            LinuxPackageManager::Dnf,
961            LinuxPackageManager::Pacman,
962            LinuxPackageManager::Zypper,
963            LinuxPackageManager::Apk,
964        ] {
965            for library in VERSIONED_NATIVE_LIBRARIES {
966                assert!(
967                    manager.package_for_native_library(library.module).is_some(),
968                    "{} has no package mapping for {}",
969                    manager.name(),
970                    library.module
971                );
972            }
973        }
974    }
975
976    /// The floors are read out of specific crate versions, so the versions the
977    /// diagnostics name have to be the ones the framework actually resolves.
978    /// The lockfile that pins them lives in `water-rs/waterui`, fetched at the
979    /// revision this crate's manifest pins.
980    #[test]
981    #[ignore = "fetches the pinned framework revision's lockfile over the network"]
982    fn crate_versions_match_lockfile() {
983        let (framework, revision) = crate::pinned_framework::source();
984        let lockfile = String::from_utf8(crate::pinned_framework::fetch(
985            &crate::pinned_framework::raw_url(&framework, &revision, "Cargo.lock"),
986        ))
987        .expect("the framework lockfile is UTF-8");
988        for library in VERSIONED_NATIVE_LIBRARIES {
989            let entry = format!(
990                "name = \"{}\"\nversion = \"{}\"\n",
991                library.required_by, library.required_by_version
992            );
993            assert!(
994                lockfile.contains(&entry),
995                "{} {} is no longer the resolved version; re-read its version floor before changing this constant",
996                library.required_by,
997                library.required_by_version
998            );
999        }
1000    }
1001
1002    #[test]
1003    fn dnf_packages_include_validated_core_deps() {
1004        let required = LinuxPackageManager::Dnf.required_packages();
1005        assert!(required.contains(&"gtk4-devel"));
1006        assert!(required.contains(&"pango-devel"));
1007        assert!(required.contains(&"wayland-devel"));
1008        assert!(required.contains(&"libva-devel"));
1009        assert!(required.contains(&"mesa-libgbm-devel"));
1010        assert!(required.contains(&"libxcb-devel"));
1011        assert!(required.contains(&"alsa-lib-devel"));
1012        assert!(required.contains(&"clang-devel"));
1013        assert!(required.contains(&"fontconfig-devel"));
1014    }
1015
1016    #[test]
1017    fn apt_hint_uses_apt_get_install() {
1018        let hint = LinuxPackageManager::Apt
1019            .install_hint(&[String::from("libwayland-dev"), String::from("libva-dev")]);
1020        assert_eq!(hint, "sudo apt-get install -y libwayland-dev libva-dev");
1021    }
1022
1023    #[test]
1024    fn apt_required_packages_include_gtk4_dev() {
1025        let required = LinuxPackageManager::Apt.required_packages();
1026        assert!(required.contains(&"libgtk-4-dev"));
1027        assert!(required.contains(&"libpango1.0-dev"));
1028    }
1029
1030    #[test]
1031    fn pacman_hint_uses_needed_flag() {
1032        let hint = LinuxPackageManager::Pacman
1033            .install_hint(&[String::from("wayland"), String::from("libva")]);
1034        assert_eq!(hint, "sudo pacman -S --noconfirm --needed wayland libva");
1035    }
1036
1037    #[test]
1038    fn dnf_probe_mapping_covers_pango_and_gtk4() {
1039        assert_eq!(
1040            LinuxPackageManager::Dnf.package_for_gtk_pkg_config_probe("gtk4"),
1041            Some("gtk4-devel")
1042        );
1043        assert_eq!(
1044            LinuxPackageManager::Dnf.package_for_gtk_pkg_config_probe("pango>=1.50"),
1045            Some("pango-devel")
1046        );
1047    }
1048}
1049
1050/// Host-driven checks exercise the real `check` code path through fake
1051/// package-manager binaries. They only make sense where `check` probes:
1052/// on non-Linux hosts it returns `Ok(())` unconditionally.
1053#[cfg(all(test, target_os = "linux"))]
1054mod host_tests {
1055    use super::LinuxSystemToolchain;
1056    use crate::toolchain::testing::TestMachine;
1057    use crate::toolchain::{Toolchain, ToolchainError};
1058
1059    const APT_PACKAGES: &str = "pkg-config libgtk-4-dev libpango1.0-dev libwayland-dev \
1060         wayland-protocols libasound2-dev libva-dev libgbm-dev libxcb1-dev \
1061         libclang-dev libfontconfig-dev";
1062
1063    /// A machine whose apt package set is complete and whose pkg-config
1064    /// reports in-range versions for the version-checked native libraries.
1065    fn complete_apt_machine() -> TestMachine {
1066        let machine = TestMachine::new();
1067        for tool in ["apt-get", "dpkg-query", "pkg-config"] {
1068            machine.install(tool);
1069        }
1070        machine.respond_pkg_config_module("libva", "1.20.0");
1071        machine.respond_pkg_config_var("libva_version", "2.20.0");
1072        machine.respond_pkg_config_module("libpipewire-0.3", "0.3.65");
1073        machine
1074    }
1075
1076    #[test]
1077    fn unfixable_without_package_manager() {
1078        let machine = TestMachine::new();
1079        let host = machine.host(Vec::<(String, String)>::new());
1080        let result = smol::block_on(LinuxSystemToolchain.check(&host));
1081        assert!(
1082            matches!(result, Err(ToolchainError::Unfixable(_))),
1083            "no package manager must be unfixable: {result:?}"
1084        );
1085    }
1086
1087    #[test]
1088    fn ok_when_apt_packages_and_library_versions_satisfy_floors() {
1089        let machine = complete_apt_machine();
1090        let host = machine.host([(
1091            String::from("WATERUI_FAKE_DPKG_INSTALLED"),
1092            APT_PACKAGES.to_string(),
1093        )]);
1094        smol::block_on(LinuxSystemToolchain.check(&host))
1095            .expect("complete apt package set must satisfy the check");
1096    }
1097
1098    #[test]
1099    fn fixable_installation_lists_only_missing_packages() {
1100        let machine = complete_apt_machine();
1101        let host = machine.host([(
1102            String::from("WATERUI_FAKE_DPKG_INSTALLED"),
1103            String::from("pkg-config libgtk-4-dev"),
1104        )]);
1105        let Err(ToolchainError::Fixable(installation)) =
1106            smol::block_on(LinuxSystemToolchain.check(&host))
1107        else {
1108            panic!("missing apt packages must produce a fixable installation");
1109        };
1110        assert_eq!(installation.package_manager_name(), "apt-get");
1111        let missing = installation.missing_packages();
1112        assert!(missing.iter().any(|package| package == "libva-dev"));
1113        assert!(!missing.iter().any(|package| package == "libgtk-4-dev"));
1114    }
1115
1116    #[test]
1117    fn unfixable_when_pkg_config_missing() {
1118        let machine = TestMachine::new();
1119        machine.install("apt-get");
1120        machine.install("dpkg-query");
1121        let host = machine.host([(
1122            String::from("WATERUI_FAKE_DPKG_INSTALLED"),
1123            APT_PACKAGES.to_string(),
1124        )]);
1125        let result = smol::block_on(LinuxSystemToolchain.check(&host));
1126        assert!(
1127            matches!(result, Err(ToolchainError::Unfixable(_))),
1128            "absent pkg-config must be an unfixable diagnostic: {result:?}"
1129        );
1130    }
1131
1132    #[test]
1133    fn unfixable_when_libva_below_floor() {
1134        let machine = complete_apt_machine();
1135        // Re-stage libva at the Ubuntu 22.04 version: VA-API 1.14 is below
1136        // the cros-libva floor of 1.19.
1137        machine.respond_pkg_config_module("libva", "1.14.0");
1138        machine.respond_pkg_config_var("libva_version", "2.14.0");
1139        let host = machine.host([(
1140            String::from("WATERUI_FAKE_DPKG_INSTALLED"),
1141            APT_PACKAGES.to_string(),
1142        )]);
1143        let result = smol::block_on(LinuxSystemToolchain.check(&host));
1144        assert!(
1145            matches!(result, Err(ToolchainError::Unfixable(_))),
1146            "an outdated libva must be unfixable (reinstalling changes nothing): {result:?}"
1147        );
1148    }
1149
1150    #[test]
1151    fn fixable_when_versioned_module_absent_from_pkg_config() {
1152        let machine = TestMachine::new();
1153        for tool in ["apt-get", "dpkg-query", "pkg-config"] {
1154            machine.install(tool);
1155        }
1156        // Only libva is staged; libpipewire-0.3 is unknown to pkg-config.
1157        machine.respond_pkg_config_module("libva", "1.20.0");
1158        machine.respond_pkg_config_var("libva_version", "2.20.0");
1159        let host = machine.host([(
1160            String::from("WATERUI_FAKE_DPKG_INSTALLED"),
1161            APT_PACKAGES.to_string(),
1162        )]);
1163        let Err(ToolchainError::Fixable(installation)) =
1164            smol::block_on(LinuxSystemToolchain.check(&host))
1165        else {
1166            panic!("an absent libpipewire module must map to an installable package");
1167        };
1168        assert_eq!(
1169            installation.missing_packages(),
1170            &[String::from("libpipewire-0.3-dev")]
1171        );
1172    }
1173}