1use 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#[derive(Debug, Clone, Default)]
18pub struct Cmake {}
19
20impl Cmake {
21 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
37async 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 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#[derive(Debug, Clone)]
92pub enum CmakeInstallation {
93 Brew,
95 Winget,
97 PackageManager,
99 Managed(ManagedTool),
102}
103
104#[derive(Debug, thiserror::Error)]
106pub enum FailToInstallCmake {
107 #[error("Homebrew not found. Please install Homebrew to proceed.")]
109 BrewNotFound,
110
111 #[error("Failed to install CMake: {0}")]
113 Command(#[from] CommandError),
114
115 #[error(
117 "winget is required for automatic CMake installation on Windows. Install App Installer and retry."
118 )]
119 WingetNotFound,
120
121 #[error("Failed to install CMake via winget: {0}")]
123 WingetInstallFailed(String),
124
125 #[error(
127 "No supported Linux package manager found (apt-get, dnf, pacman, zypper, apk). Install CMake manually."
128 )]
129 UnsupportedPackageManager,
130
131 #[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 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 #[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 #[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 #[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; };
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}