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::winget::{WingetInstallError, ensure_package_installed},
11    toolchain::{Host, Installation, Toolchain, ToolchainError},
12    utils::CommandError,
13};
14
15/// Toolchain for `CMake`
16#[derive(Debug, Clone, Default)]
17pub struct Cmake {}
18
19impl Cmake {
20    /// Get the path to the `cmake` executable.
21    ///
22    /// # Errors
23    /// - If `CMake` is not found in the system PATH.
24    pub async fn path(&self, host: &Host) -> Result<PathBuf, which::Error> {
25        host.which("cmake").await
26    }
27}
28
29impl Toolchain for Cmake {
30    type Installation = CmakeInstallation;
31
32    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
33        // Check if CMake is installed
34        // TODO: Also detect android-cmake toolchain files if needed
35        if host.which("cmake").await.is_ok() {
36            Ok(())
37        } else if cfg!(target_os = "windows") {
38            if host.which("winget").await.is_ok() {
39                Err(ToolchainError::fixable(CmakeInstallation))
40            } else {
41                Err(ToolchainError::unfixable(
42                    "CMake not found and winget is unavailable",
43                    "Install Microsoft App Installer to provide winget, or install CMake manually and ensure `cmake` is available in PATH.",
44                ))
45            }
46        } else if cfg!(target_os = "macos") {
47            if host.which("brew").await.is_ok() {
48                Err(ToolchainError::fixable(CmakeInstallation))
49            } else {
50                Err(ToolchainError::unfixable(
51                    "CMake not found and Homebrew is unavailable",
52                    "Install Homebrew to enable automatic fixes, or install CMake manually and ensure `cmake` is available in PATH.",
53                ))
54            }
55        } else if cfg!(target_os = "linux") {
56            if has_supported_package_manager(host).await {
57                Err(ToolchainError::fixable(CmakeInstallation))
58            } else {
59                Err(ToolchainError::unfixable(
60                    "CMake is missing and no supported package manager was found",
61                    "Install CMake manually and ensure `cmake` is available in PATH.",
62                ))
63            }
64        } else {
65            Err(ToolchainError::unfixable(
66                "CMake not found",
67                "Install CMake manually for your platform and ensure `cmake` is available in PATH.",
68            ))
69        }
70    }
71}
72
73/// Installation for `CMake`
74#[derive(Debug, Clone)]
75pub struct CmakeInstallation;
76
77/// Errors that can occur during `CMake` installation
78#[derive(Debug, thiserror::Error)]
79pub enum FailToInstallCmake {
80    /// Homebrew not found error
81    #[error("Homebrew not found. Please install Homebrew to proceed.")]
82    BrewNotFound,
83
84    /// An installation command failed.
85    #[error("Failed to install CMake: {0}")]
86    Command(#[from] CommandError),
87
88    /// winget is required for Windows automatic installation.
89    #[error(
90        "winget is required for automatic CMake installation on Windows. Install App Installer and retry."
91    )]
92    WingetNotFound,
93
94    /// Windows installation via winget failed.
95    #[error("Failed to install CMake via winget: {0}")]
96    WingetInstallFailed(String),
97
98    /// Linux package manager is required for automatic installation.
99    #[error(
100        "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install CMake manually."
101    )]
102    UnsupportedPackageManager,
103
104    /// Unsupported platform error
105    #[error(
106        "Automatic installation of CMake is not supported on this platform. Please install CMake manually."
107    )]
108    UnsupportedPlatform,
109}
110
111impl Installation for CmakeInstallation {
112    type Error = FailToInstallCmake;
113
114    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
115        if cfg!(target_os = "macos") {
116            let brew = Brew::default();
117
118            brew.check(host)
119                .await
120                .map_err(|_| FailToInstallCmake::BrewNotFound)?;
121            brew.install(host, "cmake").await?;
122
123            Ok(())
124        } else if cfg!(target_os = "windows") {
125            ensure_package_installed(host, "Kitware.CMake")
126                .await
127                .map_err(map_winget_error_for_cmake)
128        } else if cfg!(target_os = "linux") {
129            install_named_packages(host, &["cmake"])
130                .await
131                .map_err(map_linux_error_for_cmake)
132        } else {
133            Err(FailToInstallCmake::UnsupportedPlatform)
134        }
135    }
136}
137
138fn map_linux_error_for_cmake(error: LinuxPackageManagerError) -> FailToInstallCmake {
139    match error {
140        LinuxPackageManagerError::UnsupportedPackageManager => {
141            FailToInstallCmake::UnsupportedPackageManager
142        }
143        LinuxPackageManagerError::Command(source) => FailToInstallCmake::Command(source),
144    }
145}
146
147fn map_winget_error_for_cmake(error: WingetInstallError) -> FailToInstallCmake {
148    match error {
149        WingetInstallError::WingetNotFound => FailToInstallCmake::WingetNotFound,
150        WingetInstallError::CommandFailed(err) => {
151            FailToInstallCmake::WingetInstallFailed(err.to_string())
152        }
153        WingetInstallError::NotInstalled { package_id } => {
154            FailToInstallCmake::WingetInstallFailed(format!(
155                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
156            ))
157        }
158    }
159}
160
161#[cfg(test)]
162mod host_tests {
163    use super::{Cmake, CmakeInstallation};
164    use crate::toolchain::testing::TestMachine;
165    use crate::toolchain::{Installation, Toolchain, ToolchainError};
166
167    fn check(machine: &TestMachine) -> Result<(), ToolchainError<CmakeInstallation>> {
168        let host = machine.host(Vec::<(String, String)>::new());
169        smol::block_on(Cmake::default().check(&host))
170    }
171
172    #[test]
173    fn ok_when_cmake_on_path() {
174        let machine = TestMachine::new();
175        machine.install("cmake");
176        check(&machine).expect("cmake on PATH must be ok");
177    }
178
179    #[test]
180    fn missing_without_installer_is_unfixable() {
181        let machine = TestMachine::new();
182        let result = check(&machine);
183        assert!(
184            matches!(result, Err(ToolchainError::Unfixable(_))),
185            "missing cmake without a package manager must be unfixable: {result:?}"
186        );
187    }
188
189    #[test]
190    fn missing_with_installer_is_fixable() {
191        let machine = TestMachine::new();
192        #[cfg(target_os = "macos")]
193        machine.install("brew");
194        #[cfg(target_os = "linux")]
195        machine.install("apt-get");
196        #[cfg(target_os = "windows")]
197        machine.install("winget");
198        let result = check(&machine);
199        assert!(
200            matches!(result, Err(ToolchainError::Fixable(_))),
201            "missing cmake with a package manager must be fixable: {result:?}"
202        );
203    }
204
205    #[test]
206    #[cfg(any(target_os = "macos", target_os = "linux"))]
207    fn install_runs_the_package_manager() {
208        let machine = TestMachine::new();
209        #[cfg(target_os = "macos")]
210        machine.install("brew");
211        #[cfg(target_os = "linux")]
212        machine.install("apt-get");
213        let host = machine.host(Vec::<(String, String)>::new());
214        smol::block_on(CmakeInstallation.install(&host))
215            .expect("installing cmake through the host's package manager must succeed");
216    }
217
218    /// The fake `winget` accepts `install` but never reports the package
219    /// afterwards, so the post-install verification must fail fast instead of
220    /// reporting success.
221    #[test]
222    #[cfg(target_os = "windows")]
223    fn install_fails_when_winget_leaves_the_package_missing() {
224        let machine = TestMachine::new();
225        machine.install("winget");
226        let host = machine.host(Vec::<(String, String)>::new());
227        let result = smol::block_on(CmakeInstallation.install(&host));
228        assert!(
229            matches!(
230                result,
231                Err(super::FailToInstallCmake::WingetInstallFailed(_))
232            ),
233            "a package still missing after winget install must be an error: {result:?}"
234        );
235    }
236
237    #[test]
238    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
239    fn install_unsupported_platform() {
240        let machine = TestMachine::new();
241        let host = machine.host(Vec::<(String, String)>::new());
242        let result = smol::block_on(CmakeInstallation.install(&host));
243        assert!(
244            matches!(result, Err(super::FailToInstallCmake::UnsupportedPlatform)),
245            "install on unsupported platforms must fail fast: {result:?}"
246        );
247    }
248}