Skip to main content

waterui_cli/toolchain/
cmake.rs

1//! Toolchain support for `CMake`.
2
3use std::path::PathBuf;
4
5use crate::{
6    brew::Brew,
7    toolchain::linux::{
8        LinuxPackageManagerError, has_supported_package_manager, install_named_packages,
9    },
10    toolchain::managed_tool::{self, ManagedTool, ManagedToolError},
11    toolchain::winget::{WingetInstallError, ensure_package_installed},
12    toolchain::{Host, Installation, Toolchain, ToolchainError},
13    utils::CommandError,
14};
15
16/// Toolchain for `CMake`
17#[derive(Debug, Clone, Default)]
18pub struct Cmake {}
19
20impl Cmake {
21    /// Get the path to the `cmake` executable.
22    ///
23    /// `PATH` first, then the managed install under `~/.water/tools`.
24    ///
25    /// # Errors
26    /// - If `CMake` is not found in the system PATH or the managed tools.
27    pub async fn path(&self, host: &Host) -> Result<PathBuf, which::Error> {
28        match host.which("cmake").await {
29            Ok(path) => Ok(path),
30            Err(error) => managed_tool::cmake()
31                .and_then(|tool| tool.binary_path(host))
32                .ok_or(error),
33        }
34    }
35}
36
37/// What a missing `cmake` on Windows resolves to: `winget` when present,
38/// otherwise a pinned release archive unpacked under `~/.water/tools` — no
39/// package manager required.
40async fn missing_cmake_on_windows(host: &Host) -> ToolchainError<CmakeInstallation> {
41    if host.which("winget").await.is_ok() {
42        ToolchainError::fixable(CmakeInstallation::Winget)
43    } else if let Some(tool) = managed_tool::cmake() {
44        ToolchainError::fixable(CmakeInstallation::Managed(tool))
45    } else {
46        ToolchainError::unfixable(
47            "CMake is missing and this host has no usable installer",
48            "Download CMake from https://cmake.org/download/ and put `cmake` on PATH, then re-run `water doctor`.",
49        )
50    }
51}
52
53impl Toolchain for Cmake {
54    type Installation = CmakeInstallation;
55
56    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
57        // Check if CMake is installed
58        // TODO: Also detect android-cmake toolchain files if needed
59        if self.path(host).await.is_ok() {
60            Ok(())
61        } else if cfg!(target_os = "windows") {
62            Err(missing_cmake_on_windows(host).await)
63        } else if cfg!(target_os = "macos") {
64            if host.which("brew").await.is_ok() {
65                Err(ToolchainError::fixable(CmakeInstallation::Brew))
66            } else {
67                Err(ToolchainError::unfixable(
68                    "CMake not found and Homebrew is unavailable",
69                    "Install CMake from https://cmake.org/download/ (or Homebrew) and ensure `cmake` is available in PATH.",
70                ))
71            }
72        } else if cfg!(target_os = "linux") {
73            if has_supported_package_manager(host).await {
74                Err(ToolchainError::fixable(CmakeInstallation::PackageManager))
75            } else {
76                Err(ToolchainError::unfixable(
77                    "CMake is missing and no supported package manager was found",
78                    "Install CMake manually and ensure `cmake` is available in PATH.",
79                ))
80            }
81        } else {
82            Err(ToolchainError::unfixable(
83                "CMake not found",
84                "Install CMake manually for your platform and ensure `cmake` is available in PATH.",
85            ))
86        }
87    }
88}
89
90/// Installation for `CMake` — the strategy `check` selected for this host.
91#[derive(Debug, Clone)]
92pub enum CmakeInstallation {
93    /// `brew install cmake`.
94    Brew,
95    /// `winget install Kitware.CMake`.
96    Winget,
97    /// The host's Linux package manager.
98    PackageManager,
99    /// A pinned, checksum-verified release archive unpacked under
100    /// `~/.water/tools` — no package manager required.
101    Managed(ManagedTool),
102}
103
104/// Errors that can occur during `CMake` installation
105#[derive(Debug, thiserror::Error)]
106pub enum FailToInstallCmake {
107    /// Homebrew not found error
108    #[error("Homebrew not found. Please install Homebrew to proceed.")]
109    BrewNotFound,
110
111    /// An installation command failed.
112    #[error("Failed to install CMake: {0}")]
113    Command(#[from] CommandError),
114
115    /// winget is required for Windows automatic installation.
116    #[error(
117        "winget is required for automatic CMake installation on Windows. Install App Installer and retry."
118    )]
119    WingetNotFound,
120
121    /// Windows installation via winget failed.
122    #[error("Failed to install CMake via winget: {0}")]
123    WingetInstallFailed(String),
124
125    /// Linux package manager is required for automatic installation.
126    #[error(
127        "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install CMake manually."
128    )]
129    UnsupportedPackageManager,
130
131    /// The managed archive install failed.
132    #[error(transparent)]
133    Managed(#[from] ManagedToolError),
134}
135
136impl Installation for CmakeInstallation {
137    type Error = FailToInstallCmake;
138
139    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
140        match self {
141            Self::Brew => {
142                let brew = Brew::default();
143                brew.check(host)
144                    .await
145                    .map_err(|_| FailToInstallCmake::BrewNotFound)?;
146                brew.install(host, "cmake").await?;
147                Ok(())
148            }
149            Self::Winget => ensure_package_installed(host, "Kitware.CMake")
150                .await
151                .map_err(map_winget_error_for_cmake),
152            Self::PackageManager => install_named_packages(host, &["cmake"])
153                .await
154                .map_err(map_linux_error_for_cmake),
155            Self::Managed(tool) => {
156                tool.install(host).await?;
157                Ok(())
158            }
159        }
160    }
161}
162
163fn map_linux_error_for_cmake(error: LinuxPackageManagerError) -> FailToInstallCmake {
164    match error {
165        LinuxPackageManagerError::UnsupportedPackageManager => {
166            FailToInstallCmake::UnsupportedPackageManager
167        }
168        LinuxPackageManagerError::Command(source) => FailToInstallCmake::Command(source),
169    }
170}
171
172fn map_winget_error_for_cmake(error: WingetInstallError) -> FailToInstallCmake {
173    match error {
174        WingetInstallError::WingetNotFound => FailToInstallCmake::WingetNotFound,
175        WingetInstallError::CommandFailed(err) => {
176            FailToInstallCmake::WingetInstallFailed(err.to_string())
177        }
178        WingetInstallError::NotInstalled { package_id } => {
179            FailToInstallCmake::WingetInstallFailed(format!(
180                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
181            ))
182        }
183    }
184}
185
186#[cfg(test)]
187mod host_tests {
188    use super::{Cmake, CmakeInstallation, missing_cmake_on_windows};
189    use crate::toolchain::testing::TestMachine;
190    use crate::toolchain::{Installation, Toolchain, ToolchainError};
191
192    fn check(machine: &TestMachine) -> Result<(), ToolchainError<CmakeInstallation>> {
193        let host = machine.host(Vec::<(String, String)>::new());
194        smol::block_on(Cmake::default().check(&host))
195    }
196
197    #[test]
198    fn ok_when_cmake_on_path() {
199        let machine = TestMachine::new();
200        machine.install("cmake");
201        check(&machine).expect("cmake on PATH must be ok");
202    }
203
204    #[test]
205    fn missing_without_installer_is_unfixable() {
206        let machine = TestMachine::new();
207        let result = check(&machine);
208        // Windows hosts have the managed-archive fallback, so a bare Windows
209        // machine is fixable even without winget; elsewhere no package
210        // manager means manual.
211        if cfg!(target_os = "windows") && crate::toolchain::managed_tool::cmake().is_some() {
212            assert!(
213                matches!(result, Err(ToolchainError::Fixable(_))),
214                "missing cmake on Windows without winget falls back to the managed archive: {result:?}"
215            );
216        } else {
217            assert!(
218                matches!(result, Err(ToolchainError::Unfixable(_))),
219                "missing cmake without a package manager must be unfixable: {result:?}"
220            );
221        }
222    }
223
224    #[test]
225    fn missing_with_installer_is_fixable() {
226        let machine = TestMachine::new();
227        #[cfg(target_os = "macos")]
228        machine.install("brew");
229        #[cfg(target_os = "linux")]
230        machine.install("apt-get");
231        #[cfg(target_os = "windows")]
232        machine.install("winget");
233        let result = check(&machine);
234        assert!(
235            matches!(result, Err(ToolchainError::Fixable(_))),
236            "missing cmake with a package manager must be fixable: {result:?}"
237        );
238    }
239
240    #[test]
241    #[cfg(any(target_os = "macos", target_os = "linux"))]
242    fn install_runs_the_package_manager() {
243        let machine = TestMachine::new();
244        #[cfg(target_os = "macos")]
245        machine.install("brew");
246        #[cfg(target_os = "linux")]
247        machine.install("apt-get");
248        let host = machine.host(Vec::<(String, String)>::new());
249        let installation = if cfg!(target_os = "macos") {
250            CmakeInstallation::Brew
251        } else {
252            CmakeInstallation::PackageManager
253        };
254        smol::block_on(installation.install(&host))
255            .expect("installing cmake through the host's package manager must succeed");
256    }
257
258    /// The fake `winget` accepts `install` but never reports the package
259    /// afterwards, so the post-install verification must fail fast instead of
260    /// reporting success.
261    #[test]
262    #[cfg(target_os = "windows")]
263    fn install_fails_when_winget_leaves_the_package_missing() {
264        let machine = TestMachine::new();
265        machine.install("winget");
266        let host = machine.host(Vec::<(String, String)>::new());
267        let result = smol::block_on(CmakeInstallation::Winget.install(&host));
268        assert!(
269            matches!(
270                result,
271                Err(super::FailToInstallCmake::WingetInstallFailed(_))
272            ),
273            "a package still missing after winget install must be an error: {result:?}"
274        );
275    }
276
277    #[test]
278    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
279    fn install_unsupported_platform() {
280        let machine = TestMachine::new();
281        let host = machine.host(Vec::<(String, String)>::new());
282        let result = smol::block_on(CmakeInstallation::PackageManager.install(&host));
283        assert!(
284            matches!(
285                result,
286                Err(super::FailToInstallCmake::UnsupportedPackageManager)
287            ),
288            "install on unsupported platforms must fail fast: {result:?}"
289        );
290    }
291
292    /// A Windows host without `winget` gets the managed archive — fixable,
293    /// never a pointer at another prerequisite installer.
294    #[test]
295    fn windows_host_without_winget_is_fixable_managed() {
296        let machine = TestMachine::new();
297        let host = machine.host(Vec::<(String, String)>::new());
298        let result = smol::block_on(missing_cmake_on_windows(&host));
299        match crate::toolchain::managed_tool::cmake() {
300            Some(_) => assert!(
301                matches!(
302                    result,
303                    ToolchainError::Fixable(CmakeInstallation::Managed(_))
304                ),
305                "no winget must fall back to the managed archive: {result:?}"
306            ),
307            None => assert!(
308                matches!(result, ToolchainError::Unfixable(_)),
309                "no managed build for this architecture must be unfixable: {result:?}"
310            ),
311        }
312    }
313
314    #[test]
315    fn windows_host_with_winget_prefers_winget() {
316        let machine = TestMachine::new();
317        machine.install("winget");
318        let host = machine.host(Vec::<(String, String)>::new());
319        let result = smol::block_on(missing_cmake_on_windows(&host));
320        assert!(
321            matches!(result, ToolchainError::Fixable(CmakeInstallation::Winget)),
322            "winget stays preferred when present: {result:?}"
323        );
324    }
325
326    /// A cmake unpacked under `~/.water/tools` satisfies the check even
327    /// though nothing named `cmake` is on `PATH`.
328    #[test]
329    fn ok_when_cmake_is_managed() {
330        let machine = TestMachine::new();
331        let Some(tool) = crate::toolchain::managed_tool::cmake() else {
332            return; // this architecture has no managed build
333        };
334        let host = machine.host(Vec::<(String, String)>::new());
335        let install_dir = tool.install_dir(&host).unwrap();
336        machine.file(
337            install_dir
338                .join(&tool.binary)
339                .strip_prefix(machine.root())
340                .unwrap(),
341            "",
342        );
343        let result = smol::block_on(Cmake::default().check(&host));
344        assert!(
345            result.is_ok(),
346            "a managed cmake must satisfy the check: {result:?}"
347        );
348    }
349}