Skip to main content

waterui_cli/toolchain/
msvc.rs

1//! Toolchain support for the MSVC C++ build tools — `link.exe` and the C++
2//! libraries every Windows-targeting cargo build needs to link its binaries.
3
4use std::path::{Path, PathBuf};
5
6use waterui_assets_core::{AssetError, download_remote_bytes, write_bytes_atomically};
7
8use crate::toolchain::{Host, Installation, Toolchain, ToolchainError};
9
10/// The Visual Studio Build Tools bootstrapper (evergreen aka.ms link — the
11/// payload is Microsoft's own Authenticode-signed installer, so no source
12/// sha256 exists to pin).
13const VS_BUILD_TOOLS_URL: &str = "https://aka.ms/vs/17/release/vs_BuildTools.exe";
14
15/// The Visual Studio component `vswhere` looks for — the MSVC C++ toolset.
16const VC_TOOLS_COMPONENT: &str = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64";
17
18/// `0xBC2` (`ERROR_SUCCESS_REBOOT_REQUIRED`): the install succeeded but a
19/// reboot is pending — `--norestart` keeps it pending rather than rebooting.
20const EXIT_SUCCESS_REBOOT_REQUIRED: i32 = 0xBC2;
21
22/// MSVC C++ build tools on a Windows host.
23#[derive(Debug, Clone, Copy, Default)]
24pub struct MsvcBuildTools;
25
26impl MsvcBuildTools {
27    /// `vswhere.exe` — Microsoft's installer locator, which ships at a fixed
28    /// path under `Program Files (x86)` rather than on `PATH`.
29    async fn vswhere(host: &Host) -> Option<PathBuf> {
30        if let Ok(path) = host.which("vswhere").await {
31            return Some(path);
32        }
33        let program_files_x86 = host.env("ProgramFiles(x86)")?;
34        let path = Path::new(&program_files_x86)
35            .join("Microsoft Visual Studio")
36            .join("Installer")
37            .join("vswhere.exe");
38        path.is_file().then_some(path)
39    }
40
41    /// Whether `vswhere` reports an installation carrying the MSVC C++
42    /// toolset.
43    async fn vswhere_reports_vc_tools(host: &Host) -> bool {
44        let Some(vswhere) = Self::vswhere(host).await else {
45            return false;
46        };
47        let Ok(output) = host
48            .output(
49                &vswhere,
50                [
51                    "-products",
52                    "*",
53                    "-requires",
54                    VC_TOOLS_COMPONENT,
55                    "-property",
56                    "installationPath",
57                    "-latest",
58                ],
59            )
60            .await
61        else {
62            return false;
63        };
64        output.status.success() && !output.stdout.trim_ascii().is_empty()
65    }
66}
67
68impl Toolchain for MsvcBuildTools {
69    type Installation = MsvcBuildToolsInstallation;
70
71    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
72        if host.which("link.exe").await.is_ok() {
73            return Ok(());
74        }
75        if Self::vswhere_reports_vc_tools(host).await {
76            return Ok(());
77        }
78        Err(ToolchainError::fixable(MsvcBuildToolsInstallation))
79    }
80}
81
82/// Run the official Visual Studio Build Tools bootstrapper with the
83/// `VCTools` workload.
84///
85/// A system-wide install outside `~/.water`, so the doctor fix loop confirms
86/// it with the user first (or proceeds on `--yes`).
87#[derive(Debug, Clone, Copy)]
88pub struct MsvcBuildToolsInstallation;
89
90/// Errors that can occur while installing the MSVC build tools.
91#[derive(Debug, thiserror::Error)]
92pub enum FailToInstallMsvcBuildTools {
93    /// The bootstrapper could not be downloaded or written.
94    #[error(transparent)]
95    Asset(#[from] AssetError),
96
97    /// An I/O operation failed.
98    #[error(transparent)]
99    Io(#[from] std::io::Error),
100
101    /// The installer could not be spawned or awaited.
102    #[error(transparent)]
103    Command(#[from] crate::utils::CommandError),
104
105    /// The bootstrapper ran but reported a failure.
106    #[error("Visual Studio Build Tools installer exited with {0}")]
107    InstallerFailed(std::process::ExitStatus),
108
109    /// The installer can only run on Windows.
110    #[error("Visual Studio Build Tools can only be installed on Windows")]
111    UnsupportedPlatform,
112}
113
114impl Installation for MsvcBuildToolsInstallation {
115    type Error = FailToInstallMsvcBuildTools;
116
117    fn modifies_system(&self) -> bool {
118        true
119    }
120
121    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
122        if !cfg!(target_os = "windows") {
123            return Err(FailToInstallMsvcBuildTools::UnsupportedPlatform);
124        }
125        let bytes = download_remote_bytes(VS_BUILD_TOOLS_URL).await?;
126        let staging = smol::unblock(tempfile::tempdir).await?;
127        let bootstrapper = staging.path().join("vs_BuildTools.exe");
128        write_bytes_atomically(&bootstrapper, &bytes).await?;
129        let output = host
130            .output(
131                &bootstrapper,
132                [
133                    "--quiet",
134                    "--wait",
135                    "--norestart",
136                    "--add",
137                    "Microsoft.VisualStudio.Workload.VCTools",
138                    "--includeRecommended",
139                ],
140            )
141            .await?;
142        if output.status.success() || output.status.code() == Some(EXIT_SUCCESS_REBOOT_REQUIRED) {
143            return Ok(());
144        }
145        Err(FailToInstallMsvcBuildTools::InstallerFailed(output.status))
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use std::path::Path;
152
153    use super::MsvcBuildTools;
154    use crate::toolchain::testing::TestMachine;
155    use crate::toolchain::{Toolchain, ToolchainError};
156
157    #[test]
158    fn missing_reports_fixable() {
159        let machine = TestMachine::new();
160        let host = machine.host(Vec::<(String, String)>::new());
161        let result = smol::block_on(MsvcBuildTools.check(&host));
162        assert!(
163            matches!(result, Err(ToolchainError::Fixable(_))),
164            "a host without MSVC build tools must report fixable: {result:?}"
165        );
166    }
167
168    #[test]
169    fn ok_when_link_exe_on_path() {
170        let machine = TestMachine::new();
171        // `install("link.exe")` would produce `link.exe.cmd` on Windows,
172        // which `which("link.exe")` never resolves — the probe needs the
173        // literal `.exe` name, so stage it via `executable` instead.
174        machine.executable(Path::new("bin").join("link.exe"));
175        let host = machine.host(Vec::<(String, String)>::new());
176        smol::block_on(MsvcBuildTools.check(&host)).expect("link.exe on PATH must be ok");
177    }
178
179    /// A `vswhere` reachable on `PATH` that reports an install location —
180    /// works on every platform because the fake dispatcher answers by name.
181    #[test]
182    fn ok_when_vswhere_reports_vc_tools() {
183        let machine = TestMachine::new();
184        machine.install("vswhere");
185        machine.respond(
186            "VSWHERE",
187            "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
188        );
189        let host = machine.host(Vec::<(String, String)>::new());
190        smol::block_on(MsvcBuildTools.check(&host))
191            .expect("vswhere reporting a VC.Tools install must be ok");
192    }
193
194    /// The canonical `ProgramFiles(x86)` probe. On Windows a `.exe` fixture
195    /// cannot carry the shell dispatcher (same limitation as `adb.exe`), so
196    /// the spawn-failure path is exercised there instead.
197    #[test]
198    #[cfg(unix)]
199    fn ok_when_vswhere_at_installer_path_reports_vc_tools() {
200        let machine = TestMachine::new();
201        let program_files = machine.dir("Program Files (x86)");
202        machine.executable(
203            Path::new("Program Files (x86)")
204                .join("Microsoft Visual Studio")
205                .join("Installer")
206                .join("vswhere.exe"),
207        );
208        machine.respond(
209            "VSWHERE",
210            "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools",
211        );
212        let host = machine.host([("ProgramFiles(x86)", program_files.as_os_str())]);
213        smol::block_on(MsvcBuildTools.check(&host))
214            .expect("a vswhere at the installer path reporting VC.Tools must be ok");
215    }
216
217    #[test]
218    fn missing_when_vswhere_finds_nothing() {
219        let machine = TestMachine::new();
220        machine.install("vswhere");
221        // No VSWHERE response staged → vswhere prints nothing → no VC tools.
222        let host = machine.host(Vec::<(String, String)>::new());
223        let result = smol::block_on(MsvcBuildTools.check(&host));
224        assert!(
225            matches!(result, Err(ToolchainError::Fixable(_))),
226            "vswhere with no VC.Tools install must report fixable: {result:?}"
227        );
228    }
229}