Skip to main content

waterui_cli/gtk4/
toolchain.rs

1//! GTK4 toolchain checking.
2
3use crate::toolchain::{
4    Host, Toolchain, ToolchainError, UnfixableToolchain,
5    linux::{
6        LinuxSystemPackagesInstallation, LinuxSystemToolchain, gtk4_pkg_config_repair_installation,
7    },
8};
9
10/// GTK4 toolchain checker.
11///
12/// Verifies that GTK4 development libraries are installed on Linux.
13#[derive(Debug, Clone, Copy, Default)]
14pub struct Gtk4Toolchain;
15
16#[derive(Debug, Clone, Copy)]
17struct PkgConfigProbe {
18    module: &'static str,
19    min_version: Option<&'static str>,
20}
21
22impl PkgConfigProbe {
23    fn display(self) -> String {
24        self.min_version.map_or_else(
25            || self.module.to_owned(),
26            |min| format!("{}>={min}", self.module),
27        )
28    }
29}
30
31const REQUIRED_PROBES: &[PkgConfigProbe] = &[
32    // The same floor as the `v4_14` feature in `backends/gtk/Cargo.toml`:
33    // clipping to an arbitrary path is `gtk_snapshot_push_fill`, which is 4.14.
34    PkgConfigProbe {
35        module: "gtk4",
36        min_version: Some("4.14"),
37    },
38    PkgConfigProbe {
39        module: "pango",
40        min_version: Some("1.50"),
41    },
42];
43
44impl Toolchain for Gtk4Toolchain {
45    type Installation = LinuxSystemPackagesInstallation;
46
47    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
48        if !cfg!(target_os = "linux") {
49            return Err(ToolchainError::Unfixable(UnfixableToolchain::new(
50                "GTK4 backend is only supported on Linux",
51                "Run GTK4 targets on Linux with `--platform linux --backend gtk4`.",
52            )));
53        }
54
55        if !check_pkg_config_exists(host).await {
56            let linux_toolchain = LinuxSystemToolchain;
57            return match linux_toolchain.check(host).await {
58                Ok(()) => Err(ToolchainError::Unfixable(UnfixableToolchain::new(
59                    "pkg-config not found",
60                    "Install pkg-config and ensure it is in PATH, then re-run `water doctor`.",
61                ))),
62                Err(ToolchainError::Fixable(installation)) => {
63                    Err(ToolchainError::Fixable(installation))
64                }
65                Err(ToolchainError::Unfixable(e)) => Err(ToolchainError::Unfixable(e)),
66            };
67        }
68
69        let missing = missing_pkg_config_probes(host).await;
70        if missing.is_empty() {
71            return Ok(());
72        }
73
74        let linux_toolchain = LinuxSystemToolchain;
75        return match linux_toolchain.check(host).await {
76            Err(ToolchainError::Fixable(installation)) => {
77                Err(ToolchainError::Fixable(installation))
78            }
79            Err(ToolchainError::Unfixable(e)) => Err(ToolchainError::Unfixable(e)),
80            Ok(()) => match gtk4_pkg_config_repair_installation(host, &missing).await {
81                Ok(installation) => Err(ToolchainError::Fixable(installation)),
82                Err(error) => {
83                    let missing = missing.join(", ");
84                    let base_hint = install_gtk4_suggestion();
85                    Err(ToolchainError::Unfixable(UnfixableToolchain::new(
86                        format!("GTK4 pkg-config probe failed: missing {missing}"),
87                        format!(
88                            "{base_hint} Also ensure these probes pass: `pkg-config --exists gtk4 && pkg-config --atleast-version=4.14 gtk4` and `pkg-config --exists pango && pkg-config --atleast-version=1.50 pango`. Repair planner error: {}",
89                            error.message()
90                        ),
91                    )))
92                }
93            },
94        };
95    }
96}
97
98/// Check if pkg-config is available.
99async fn check_pkg_config_exists(host: &Host) -> bool {
100    host.output("pkg-config", ["--version"])
101        .await
102        .is_ok_and(|o| o.status.success())
103}
104
105async fn check_module_exists(host: &Host, module: &str) -> bool {
106    host.output("pkg-config", ["--exists", module])
107        .await
108        .is_ok_and(|o| o.status.success())
109}
110
111async fn check_module_min_version(host: &Host, module: &str, min_version: &str) -> bool {
112    host.output(
113        "pkg-config",
114        [
115            format!("--atleast-version={min_version}"),
116            module.to_owned(),
117        ],
118    )
119    .await
120    .is_ok_and(|o| o.status.success())
121}
122
123async fn missing_pkg_config_probes(host: &Host) -> Vec<String> {
124    let mut missing = Vec::new();
125
126    for probe in REQUIRED_PROBES {
127        if !check_module_exists(host, probe.module).await {
128            missing.push(probe.display());
129            continue;
130        }
131        if let Some(min_version) = probe.min_version
132            && !check_module_min_version(host, probe.module, min_version).await
133        {
134            missing.push(probe.display());
135        }
136    }
137
138    missing
139}
140
141/// Get platform-specific suggestion for installing GTK4.
142const fn install_gtk4_suggestion() -> &'static str {
143    "GTK4 was not discoverable via pkg-config. Ensure GTK4 development packages are installed for your distribution and `pkg-config --exists gtk4` succeeds."
144}
145
146#[cfg(test)]
147mod tests {
148    use super::{PkgConfigProbe, install_gtk4_suggestion};
149
150    #[test]
151    fn gtk4_suggestion_mentions_pkg_config_probe() {
152        let gtk4 = install_gtk4_suggestion();
153        assert!(gtk4.contains("pkg-config"));
154        assert!(gtk4.contains("gtk4"));
155    }
156
157    #[test]
158    fn pkg_config_probe_display_formats_min_version() {
159        let probe = PkgConfigProbe {
160            module: "pango",
161            min_version: Some("1.50"),
162        };
163        assert_eq!(probe.display(), "pango>=1.50");
164    }
165
166    #[test]
167    fn pkg_config_probe_display_without_version() {
168        let probe = PkgConfigProbe {
169            module: "gtk4",
170            min_version: None,
171        };
172        assert_eq!(probe.display(), "gtk4");
173    }
174}
175
176#[cfg(test)]
177mod host_tests {
178    use super::Gtk4Toolchain;
179    use crate::toolchain::testing::TestMachine;
180    use crate::toolchain::{Toolchain, ToolchainError};
181
182    #[test]
183    #[cfg(not(target_os = "linux"))]
184    fn off_linux_is_unfixable() {
185        let machine = TestMachine::new();
186        let host = machine.host(Vec::<(String, String)>::new());
187        let result = smol::block_on(Gtk4Toolchain.check(&host));
188        assert!(
189            matches!(result, Err(ToolchainError::Unfixable(_))),
190            "GTK4 outside Linux must be unfixable: {result:?}"
191        );
192    }
193
194    #[cfg(target_os = "linux")]
195    mod linux {
196        use super::*;
197
198        const APT_PACKAGES: &str = "pkg-config libgtk-4-dev libpango1.0-dev libwayland-dev \
199             wayland-protocols libasound2-dev libva-dev libgbm-dev libxcb1-dev \
200             libclang-dev libfontconfig-dev";
201
202        /// Machine with pkg-config and both GTK probes satisfied, and an apt
203        /// package set that leaves `LinuxSystemToolchain` satisfied as well.
204        fn complete_machine() -> TestMachine {
205            let machine = TestMachine::new();
206            for tool in ["apt-get", "dpkg-query", "pkg-config"] {
207                machine.install(tool);
208            }
209            machine.respond_pkg_config_module("gtk4", "4.18.0");
210            machine.respond_pkg_config_module("pango", "1.56.0");
211            machine.respond_pkg_config_module("libva", "1.20.0");
212            machine.respond_pkg_config_var("libva_version", "2.20.0");
213            machine.respond_pkg_config_module("libpipewire-0.3", "0.3.65");
214            machine
215        }
216
217        #[test]
218        fn ok_when_probes_and_packages_satisfied() {
219            let machine = complete_machine();
220            let host = machine.host([(
221                String::from("WATERUI_FAKE_DPKG_INSTALLED"),
222                APT_PACKAGES.to_string(),
223            )]);
224            smol::block_on(Gtk4Toolchain.check(&host))
225                .expect("satisfied gtk4/pango probes plus complete apt set must be ok");
226        }
227
228        #[test]
229        fn missing_probe_with_apt_is_fixable() {
230            let machine = TestMachine::new();
231            for tool in ["apt-get", "dpkg-query", "pkg-config"] {
232                machine.install(tool);
233            }
234            let host = machine.host([(
235                String::from("WATERUI_FAKE_DPKG_INSTALLED"),
236                String::from("pkg-config"),
237            )]);
238            let result = smol::block_on(Gtk4Toolchain.check(&host));
239            assert!(
240                matches!(result, Err(ToolchainError::Fixable(_))),
241                "missing gtk4 probes under apt must produce a fixable install: {result:?}"
242            );
243        }
244
245        #[test]
246        fn missing_probes_fixable_via_repair_when_packages_complete() {
247            let machine = complete_machine();
248            // Drop the gtk4 module response: `--exists gtk4` now fails while
249            // every apt package still reports installed, so the fix must come
250            // from the repair-installation path. `PKG_CONFIG_GTK4` is the
251            // response key the dispatcher derives for `gtk4` — the raw name.
252            std::fs::remove_file(machine.responses().join("PKG_CONFIG_gtk4"))
253                .expect("remove staged gtk4 module response");
254            let host = machine.host([(
255                String::from("WATERUI_FAKE_DPKG_INSTALLED"),
256                APT_PACKAGES.to_string(),
257            )]);
258            let result = smol::block_on(Gtk4Toolchain.check(&host));
259            assert!(
260                matches!(result, Err(ToolchainError::Fixable(_))),
261                "complete packages + missing gtk4 probe must repair via package mapping: {result:?}"
262            );
263        }
264
265        #[test]
266        fn unfixable_without_package_manager() {
267            let machine = TestMachine::new();
268            machine.install("pkg-config");
269            let host = machine.host(Vec::<(String, String)>::new());
270            let result = smol::block_on(Gtk4Toolchain.check(&host));
271            assert!(
272                matches!(result, Err(ToolchainError::Unfixable(_))),
273                "no package manager must be unfixable: {result:?}"
274            );
275        }
276
277        #[test]
278        fn unfixable_when_pkg_config_missing_but_packages_installed() {
279            let machine = TestMachine::new();
280            machine.install("apt-get");
281            machine.install("dpkg-query");
282            let host = machine.host([(
283                String::from("WATERUI_FAKE_DPKG_INSTALLED"),
284                APT_PACKAGES.to_string(),
285            )]);
286            let result = smol::block_on(Gtk4Toolchain.check(&host));
287            assert!(
288                matches!(result, Err(ToolchainError::Unfixable(_))),
289                "installed packages without pkg-config must be unfixable: {result:?}"
290            );
291        }
292    }
293}