Skip to main content

waterui_cli/toolchain/
windows_arm64_llvm.rs

1//! Windows ARM64 LLVM toolchain support for native assembly dependencies.
2
3use std::ffi::OsString;
4use std::path::PathBuf;
5
6use crate::toolchain::{
7    Host, Installation, Toolchain, ToolchainError,
8    managed_tool::{LLVM_ARM64_MSI_SHA256, LLVM_ARM64_MSI_URL, ManagedToolError, fetch_pinned},
9    winget::{WingetInstallError, ensure_package_installed},
10};
11
12const LLVM_WINGET_PACKAGE_ID: &str = "LLVM.LLVM";
13const DEFAULT_CLANG_CL_PATH: &str = r"C:\Program Files\LLVM\bin\clang-cl.exe";
14const DEFAULT_LLVM_LIB_PATH: &str = r"C:\Program Files\LLVM\bin\llvm-lib.exe";
15const TARGET_UNDERSCORE: &str = "aarch64_pc_windows_msvc";
16const TARGET_DASHED: &str = "aarch64-pc-windows-msvc";
17
18/// Toolchain for Windows ARM64 LLVM C/ASM build support.
19///
20/// This is required by native Rust dependencies that ship `.S` sources
21/// (for example `aws-lc-sys` and `rav1e`) when building on `aarch64-pc-windows-msvc`.
22#[derive(Debug, Clone, Default)]
23pub struct WindowsArm64LlvmToolchain;
24
25impl WindowsArm64LlvmToolchain {
26    /// Whether this host requires explicit LLVM tooling for native assembly builds.
27    #[must_use]
28    pub const fn required_on_host() -> bool {
29        cfg!(all(target_os = "windows", target_arch = "aarch64"))
30    }
31
32    /// Build target-scoped cargo environment overrides that force LLVM tools
33    /// for Windows ARM64 C/C++/ASM compilation.
34    ///
35    /// Returns an empty list on hosts where this toolchain is not required.
36    ///
37    /// # Errors
38    /// Returns an error if this host requires LLVM tools and they cannot be located.
39    pub async fn cargo_envs(
40        &self,
41        host: &Host,
42    ) -> Result<Vec<(String, OsString)>, ToolchainError<WindowsArm64LlvmInstallation>> {
43        if !Self::required_on_host() {
44            return Ok(Vec::new());
45        }
46
47        let tools = ensure_llvm_tools_available(host).await?;
48        Ok(vec![
49            (
50                format!("CC_{TARGET_UNDERSCORE}"),
51                tools.clang_cl.clone().into_os_string(),
52            ),
53            (
54                format!("CXX_{TARGET_UNDERSCORE}"),
55                tools.clang_cl.clone().into_os_string(),
56            ),
57            (
58                format!("AR_{TARGET_UNDERSCORE}"),
59                tools.llvm_lib.clone().into_os_string(),
60            ),
61            (
62                format!("CC_{TARGET_DASHED}"),
63                tools.clang_cl.clone().into_os_string(),
64            ),
65            (
66                format!("CXX_{TARGET_DASHED}"),
67                tools.clang_cl.clone().into_os_string(),
68            ),
69            (
70                format!("AR_{TARGET_DASHED}"),
71                tools.llvm_lib.into_os_string(),
72            ),
73        ])
74    }
75}
76
77impl Toolchain for WindowsArm64LlvmToolchain {
78    type Installation = WindowsArm64LlvmInstallation;
79
80    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
81        if !Self::required_on_host() {
82            return Ok(());
83        }
84
85        ensure_llvm_tools_available(host).await.map(|_| ())
86    }
87}
88
89/// Installation plan for Windows ARM64 LLVM tooling — the strategy `check`
90/// selected for this host.
91#[derive(Debug, Clone)]
92pub enum WindowsArm64LlvmInstallation {
93    /// `winget install LLVM.LLVM`.
94    Winget,
95    /// The pinned `llvm/llvm-project` Windows ARM64 MSI run through
96    /// `msiexec` — no package manager required. Installs to
97    /// `C:\Program Files\LLVM`, which the resolution already probes.
98    Msi,
99}
100
101/// Errors that can occur when installing Windows ARM64 LLVM tooling.
102#[derive(Debug, thiserror::Error)]
103pub enum FailToInstallWindowsArm64Llvm {
104    /// winget is required for automatic installation.
105    #[error(
106        "winget is required for automatic LLVM installation on Windows. Install App Installer and retry."
107    )]
108    WingetNotFound,
109    /// winget installation failed.
110    #[error("Failed to install LLVM via winget: {0}")]
111    WingetInstallFailed(String),
112    /// The pinned MSI could not be downloaded or verified.
113    #[error(transparent)]
114    Managed(#[from] ManagedToolError),
115    /// An installation command failed to spawn.
116    #[error(transparent)]
117    Command(#[from] crate::utils::CommandError),
118    /// An I/O operation failed.
119    #[error(transparent)]
120    Io(#[from] std::io::Error),
121    /// `msiexec` exited with a failure.
122    #[error("`msiexec` for the LLVM installer exited with {0}")]
123    MsiexecFailed(std::process::ExitStatus),
124    /// LLVM package installed but required binaries are still unavailable.
125    #[error(
126        "LLVM was installed, but required binaries are still missing ({missing}). Ensure `{}` is accessible and restart shell/terminal.",
127        DEFAULT_CLANG_CL_PATH
128    )]
129    ToolsNotDetected {
130        /// Missing binary list.
131        missing: String,
132    },
133}
134
135impl Installation for WindowsArm64LlvmInstallation {
136    type Error = FailToInstallWindowsArm64Llvm;
137
138    /// `msiexec` writes outside `~/.water` (`C:\Program Files\LLVM`), so the
139    /// doctor fix loop confirms it first.
140    fn modifies_system(&self) -> bool {
141        matches!(self, Self::Msi)
142    }
143
144    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
145        match self {
146            Self::Winget => {
147                ensure_package_installed(host, LLVM_WINGET_PACKAGE_ID)
148                    .await
149                    .map_err(map_winget_error_for_windows_arm64_llvm)?;
150            }
151            Self::Msi => {
152                use std::ffi::OsStr;
153                let staging = smol::unblock(tempfile::tempdir).await?;
154                let msi = staging.path().join("LLVM-23.1.1-woa64.msi");
155                fetch_pinned(LLVM_ARM64_MSI_URL, LLVM_ARM64_MSI_SHA256, &msi).await?;
156                let output = host
157                    .output(
158                        "msiexec",
159                        [
160                            OsStr::new("/i"),
161                            msi.as_os_str(),
162                            OsStr::new("/quiet"),
163                            OsStr::new("/norestart"),
164                        ],
165                    )
166                    .await?;
167                if !output.status.success() {
168                    return Err(FailToInstallWindowsArm64Llvm::MsiexecFailed(output.status));
169                }
170            }
171        }
172
173        let tools = resolve_llvm_tools(host).await;
174        if tools.is_complete() {
175            Ok(())
176        } else {
177            Err(FailToInstallWindowsArm64Llvm::ToolsNotDetected {
178                missing: tools.missing_components().join(", "),
179            })
180        }
181    }
182}
183
184#[derive(Debug, Clone)]
185struct CompleteLlvmTools {
186    clang_cl: PathBuf,
187    llvm_lib: PathBuf,
188}
189
190#[derive(Debug, Clone, Default)]
191struct ResolvedLlvmTools {
192    clang_cl: Option<PathBuf>,
193    llvm_lib: Option<PathBuf>,
194}
195
196impl ResolvedLlvmTools {
197    const fn is_complete(&self) -> bool {
198        self.clang_cl.is_some() && self.llvm_lib.is_some()
199    }
200
201    fn missing_components(&self) -> Vec<&'static str> {
202        let mut missing = Vec::new();
203        if self.clang_cl.is_none() {
204            missing.push("clang-cl");
205        }
206        if self.llvm_lib.is_none() {
207            missing.push("llvm-lib");
208        }
209        missing
210    }
211
212    fn into_complete(self) -> Option<CompleteLlvmTools> {
213        Some(CompleteLlvmTools {
214            clang_cl: self.clang_cl?,
215            llvm_lib: self.llvm_lib?,
216        })
217    }
218}
219
220async fn ensure_llvm_tools_available(
221    host: &Host,
222) -> Result<CompleteLlvmTools, ToolchainError<WindowsArm64LlvmInstallation>> {
223    let resolved = resolve_llvm_tools(host).await;
224    if let Some(complete) = resolved.clone().into_complete() {
225        return Ok(complete);
226    }
227
228    if host.which("winget").await.is_ok() {
229        Err(ToolchainError::fixable(
230            WindowsArm64LlvmInstallation::Winget,
231        ))
232    } else {
233        // The pinned llvm-project Windows ARM64 MSI covers hosts without
234        // winget (Windows Server images ship without App Installer).
235        Err(ToolchainError::fixable(WindowsArm64LlvmInstallation::Msi))
236    }
237}
238
239async fn resolve_llvm_tools(host: &Host) -> ResolvedLlvmTools {
240    let clang_cl = find_executable(host, "clang-cl", DEFAULT_CLANG_CL_PATH).await;
241    let llvm_lib = find_executable(host, "llvm-lib", DEFAULT_LLVM_LIB_PATH).await;
242    ResolvedLlvmTools { clang_cl, llvm_lib }
243}
244
245async fn find_executable(
246    host: &Host,
247    binary_name: &'static str,
248    fallback_path: &'static str,
249) -> Option<PathBuf> {
250    if let Ok(path) = host.which(binary_name).await {
251        return Some(path);
252    }
253
254    let fallback = PathBuf::from(fallback_path);
255    if fallback.exists() {
256        Some(fallback)
257    } else {
258        None
259    }
260}
261
262fn map_winget_error_for_windows_arm64_llvm(
263    error: WingetInstallError,
264) -> FailToInstallWindowsArm64Llvm {
265    match error {
266        WingetInstallError::WingetNotFound => FailToInstallWindowsArm64Llvm::WingetNotFound,
267        WingetInstallError::CommandFailed(err) => {
268            FailToInstallWindowsArm64Llvm::WingetInstallFailed(err.to_string())
269        }
270        WingetInstallError::NotInstalled { package_id } => {
271            FailToInstallWindowsArm64Llvm::WingetInstallFailed(format!(
272                "Package `{package_id}` is still missing after winget install; verify winget sources and retry."
273            ))
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::{
281        FailToInstallWindowsArm64Llvm, ResolvedLlvmTools, map_winget_error_for_windows_arm64_llvm,
282    };
283    use crate::toolchain::winget::WingetInstallError;
284
285    #[test]
286    fn maps_winget_not_found_to_specific_error() {
287        let mapped = map_winget_error_for_windows_arm64_llvm(WingetInstallError::WingetNotFound);
288        assert!(matches!(
289            mapped,
290            FailToInstallWindowsArm64Llvm::WingetNotFound
291        ));
292    }
293
294    #[test]
295    fn maps_not_installed_error_with_package_context() {
296        let mapped = map_winget_error_for_windows_arm64_llvm(WingetInstallError::NotInstalled {
297            package_id: "LLVM.LLVM",
298        });
299        let message = mapped.to_string();
300        assert!(message.contains("LLVM.LLVM"));
301        assert!(message.contains("still missing"));
302    }
303
304    #[test]
305    fn missing_components_reports_expected_tools() {
306        let missing_both = ResolvedLlvmTools::default();
307        assert_eq!(
308            missing_both.missing_components(),
309            vec!["clang-cl", "llvm-lib"]
310        );
311
312        let missing_llvm_lib = ResolvedLlvmTools {
313            clang_cl: Some("clang-cl".into()),
314            llvm_lib: None,
315        };
316        assert_eq!(missing_llvm_lib.missing_components(), vec!["llvm-lib"]);
317    }
318}
319
320#[cfg(test)]
321mod host_tests {
322    use super::WindowsArm64LlvmToolchain;
323    use crate::toolchain::Toolchain;
324    #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
325    use crate::toolchain::ToolchainError;
326    use crate::toolchain::testing::TestMachine;
327
328    #[test]
329    #[cfg(not(all(target_os = "windows", target_arch = "aarch64")))]
330    fn not_required_outside_windows_arm64() {
331        let machine = TestMachine::new();
332        let host = machine.host(Vec::<(String, String)>::new());
333        smol::block_on(WindowsArm64LlvmToolchain.check(&host))
334            .expect("LLVM tooling is only required on Windows ARM64");
335        let envs = smol::block_on(WindowsArm64LlvmToolchain.cargo_envs(&host))
336            .expect("cargo envs off Windows ARM64 must not probe tools");
337        assert!(envs.is_empty());
338    }
339
340    #[test]
341    #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
342    fn ok_when_llvm_tools_on_path() {
343        let machine = TestMachine::new();
344        machine.install("clang-cl");
345        machine.install("llvm-lib");
346        let host = machine.host(Vec::<(String, String)>::new());
347        smol::block_on(WindowsArm64LlvmToolchain.check(&host))
348            .expect("clang-cl and llvm-lib on PATH must satisfy the check");
349    }
350
351    #[test]
352    #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
353    fn missing_tools_classify_by_winget_presence() {
354        let machine = TestMachine::new();
355        let host = machine.host(Vec::<(String, String)>::new());
356        let result = smol::block_on(WindowsArm64LlvmToolchain.check(&host));
357        assert!(
358            matches!(
359                result,
360                Err(ToolchainError::Fixable(
361                    super::WindowsArm64LlvmInstallation::Msi
362                ))
363            ),
364            "missing LLVM tools without winget falls back to the pinned MSI: {result:?}"
365        );
366        machine.install("winget");
367        let host = machine.host(Vec::<(String, String)>::new());
368        let result = smol::block_on(WindowsArm64LlvmToolchain.check(&host));
369        assert!(
370            matches!(
371                result,
372                Err(ToolchainError::Fixable(
373                    super::WindowsArm64LlvmInstallation::Winget
374                ))
375            ),
376            "missing LLVM tools with winget must stay on winget: {result:?}"
377        );
378    }
379}