Skip to main content

waterui_cli/toolchain/
managed_tool.rs

1//! Managed tools: pinned, checksum-verified release archives unpacked under
2//! `~/.water/tools` — no package manager required.
3//!
4//! A [`ManagedTool`] names one upstream release artifact (URL, sha256, and the
5//! binary's path inside the archive). [`ManagedTool::install`] unpacks the
6//! archive into `~/.water/tools/<name>/<version>/` and returns the directory
7//! holding the binary; an already-unpacked install is reused without touching
8//! the network. Builds put every installed tool's bin directory on `PATH`
9//! through [`managed_tools_path_env`], so nothing depends on the user editing
10//! `PATH` by hand.
11
12use std::{
13    ffi::OsString,
14    io,
15    path::{Path, PathBuf},
16};
17
18use sha2::{Digest, Sha256};
19use waterui_assets_core::{AssetError, download_remote_bytes, write_bytes_atomically};
20
21use crate::{
22    project_model::water_dir::{HomeDirError, water_home_dir_in},
23    toolchain::Host,
24};
25
26/// A pinned release archive that unpacks under `~/.water/tools/<name>/<version>/`.
27#[derive(Debug, Clone)]
28pub struct ManagedTool {
29    /// Directory name under `~/.water/tools`.
30    pub name: &'static str,
31    /// Version segment of the install directory.
32    pub version: &'static str,
33    /// Pinned archive URL.
34    pub url: String,
35    /// Expected sha256 of the archive, lowercase hex.
36    pub sha256: &'static str,
37    /// Path of the tool binary inside the unpacked tree.
38    pub binary: String,
39}
40
41impl ManagedTool {
42    /// The directory this tool unpacks into: `~/.water/tools/<name>/<version>`.
43    ///
44    /// # Errors
45    /// Returns [`HomeDirError`] when the host declares no home directory.
46    pub fn install_dir(&self, host: &Host) -> Result<PathBuf, HomeDirError> {
47        Ok(water_home_dir_in(host)?
48            .join("tools")
49            .join(self.name)
50            .join(self.version))
51    }
52
53    /// The unpacked binary's path on `host`, when already installed.
54    #[must_use]
55    pub fn binary_path(&self, host: &Host) -> Option<PathBuf> {
56        let path = self.install_dir(host).ok()?.join(&self.binary);
57        path.is_file().then_some(path)
58    }
59
60    /// The directory holding the binary on `host`, when already installed.
61    ///
62    /// `binary` is always a multi-component relative path, so `None` parents
63    /// collapse to `None` rather than a panic.
64    #[must_use]
65    pub fn binary_dir(&self, host: &Host) -> Option<PathBuf> {
66        self.binary_path(host)
67            .and_then(|path| path.parent().map(Path::to_path_buf))
68    }
69
70    /// Download the pinned archive, verify its sha256, and unpack it into
71    /// `~/.water/tools/<name>/<version>/`. An already-unpacked install is
72    /// returned without any network access.
73    ///
74    /// Returns the directory that holds the tool binary.
75    ///
76    /// # Errors
77    /// Returns [`ManagedToolError`] when the download fails, the checksum does
78    /// not match the pinned value, the archive cannot be unpacked, or the
79    /// unpacked tree does not contain the expected binary.
80    pub async fn install(&self, host: &Host) -> Result<PathBuf, ManagedToolError> {
81        if let Some(dir) = self.binary_dir(host) {
82            return Ok(dir);
83        }
84
85        let tool_dir = water_home_dir_in(host)?.join("tools").join(self.name);
86        let install_dir = tool_dir.join(self.version);
87        smol::unblock({
88            let tool_dir = tool_dir.clone();
89            move || std::fs::create_dir_all(&tool_dir)
90        })
91        .await?;
92
93        let staging = smol::unblock({
94            move || {
95                tempfile::Builder::new()
96                    .prefix(".staging-")
97                    .tempdir_in(&tool_dir)
98            }
99        })
100        .await?;
101        let archive_path = staging.path().join("archive.zip");
102        fetch_pinned(&self.url, self.sha256, &archive_path).await?;
103
104        let extract_dir = staging.path().join("extract");
105        smol::unblock({
106            let archive_path = archive_path.clone();
107            let extract_dir = extract_dir.clone();
108            move || -> Result<(), ManagedToolError> {
109                std::fs::create_dir_all(&extract_dir)?;
110                let file = std::fs::File::open(&archive_path)?;
111                zip::ZipArchive::new(file)?.extract(&extract_dir)?;
112                Ok(())
113            }
114        })
115        .await?;
116
117        remove_directory_if_exists(&install_dir).await?;
118        smol::unblock(move || std::fs::rename(&extract_dir, &install_dir)).await?;
119
120        self.binary_dir(host)
121            .ok_or_else(|| ManagedToolError::ArchiveLayout {
122                name: self.name,
123                binary: self.binary.clone(),
124            })
125    }
126}
127
128/// `dxc` (DirectX Shader Compiler) from the pinned
129/// `microsoft/DirectXShaderCompiler` release.
130#[must_use]
131pub fn dxc() -> ManagedTool {
132    let binary = if cfg!(target_arch = "aarch64") {
133        "bin/arm64/dxc.exe"
134    } else if cfg!(target_arch = "x86_64") {
135        "bin/x64/dxc.exe"
136    } else {
137        "bin/x86/dxc.exe"
138    };
139    ManagedTool {
140        name: "dxc",
141        version: "1.8.2505.1",
142        url: "https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip"
143            .to_string(),
144        sha256: "9ad895a6b039e3a8f8c22a1009f866800b840a74b50db9218d13319e215ea8a4",
145        binary: binary.to_string(),
146    }
147}
148
149/// `cmake` for a Windows host without a package manager, from the pinned
150/// Kitware release zip.
151#[must_use]
152pub fn cmake() -> Option<ManagedTool> {
153    let (package, sha256) = if cfg!(target_arch = "x86_64") {
154        (
155            "cmake-4.4.3-windows-x86_64",
156            "4d52ebab7193a698651639ed80d8d04fd903358843572cf44c7fd234cb7c26ab",
157        )
158    } else if cfg!(target_arch = "aarch64") {
159        (
160            "cmake-4.4.3-windows-arm64",
161            "7b410ddd00e24c7250eec7452da2348a4a70437aa87e9cda0a20d6a85662fcff",
162        )
163    } else if cfg!(target_arch = "x86") {
164        (
165            "cmake-4.4.3-windows-i386",
166            "018024d05e2fc77d386046da87f90345f9beea21e35c5c8ab02fd15421b7da18",
167        )
168    } else {
169        return None;
170    };
171    Some(ManagedTool {
172        name: "cmake",
173        version: "4.4.3",
174        url: format!("https://github.com/Kitware/CMake/releases/download/v4.4.3/{package}.zip"),
175        sha256,
176        binary: format!("{package}/bin/cmake.exe"),
177    })
178}
179
180/// `sccache` for a Windows host without a package manager, from the pinned
181/// Mozilla release zip. Upstream publishes no x86 build.
182#[must_use]
183pub fn sccache() -> Option<ManagedTool> {
184    let (package, sha256) = if cfg!(target_arch = "x86_64") {
185        (
186            "sccache-v0.18.0-x86_64-pc-windows-msvc",
187            "8965c74d5e8a225244f741e18ad2f3f504f48228dc1bac948fc22761a348363d",
188        )
189    } else if cfg!(target_arch = "aarch64") {
190        (
191            "sccache-v0.18.0-aarch64-pc-windows-msvc",
192            "205d613fa74a9a0525e41a5ace77b1c71907d5bd4a5e668bad79111776829290",
193        )
194    } else {
195        return None;
196    };
197    Some(ManagedTool {
198        name: "sccache",
199        version: "0.18.0",
200        url: format!("https://github.com/mozilla/sccache/releases/download/v0.18.0/{package}.zip"),
201        sha256,
202        binary: format!("{package}/sccache.exe"),
203    })
204}
205
206/// A JDK (Temurin 21) for a Windows host without a package manager, from the
207/// pinned Adoptium release zip.
208#[must_use]
209pub fn jdk() -> Option<ManagedTool> {
210    let (package, sha256) = if cfg!(target_arch = "x86_64") {
211        (
212            "OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1",
213            "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e",
214        )
215    } else if cfg!(target_arch = "aarch64") {
216        (
217            "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1",
218            "ccf2e51f527d542a70ba5794a600d3aac04b4e967950e227834c7566cb1bec7b",
219        )
220    } else {
221        return None;
222    };
223    Some(ManagedTool {
224        name: "jdk",
225        version: "21.0.12.1+1",
226        url: format!(
227            "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/{package}.zip"
228        ),
229        sha256,
230        binary: "jdk-21.0.12.1+1/bin/java.exe".to_string(),
231    })
232}
233
234/// Every managed tool the CLI can install.
235#[must_use]
236pub fn all() -> Vec<ManagedTool> {
237    let mut tools = vec![dxc()];
238    for tool in [cmake(), sccache(), jdk()].into_iter().flatten() {
239        tools.push(tool);
240    }
241    tools
242}
243
244/// The LLVM installer MSI for Windows on ARM64, verified against the pinned
245/// sha256 and run through `msiexec`.
246pub const LLVM_ARM64_MSI_URL: &str =
247    "https://github.com/llvm/llvm-project/releases/download/llvmorg-23.1.1/LLVM-23.1.1-woa64.msi";
248/// Pinned sha256 of [`LLVM_ARM64_MSI_URL`], lowercase hex.
249pub const LLVM_ARM64_MSI_SHA256: &str =
250    "aeb4415a5fcfd488dc0ef69ccb2c85a81163c11960b63b6b295c6c3df5a6317e";
251
252/// Download `url`, verify it against the pinned `sha256`, and write it to
253/// `destination` atomically.
254///
255/// # Errors
256/// Returns [`ManagedToolError`] when the download fails, the checksum does not
257/// match the pinned value, or the file cannot be written.
258pub async fn fetch_pinned(
259    url: &str,
260    sha256: &str,
261    destination: &Path,
262) -> Result<(), ManagedToolError> {
263    let bytes = download_remote_bytes(url).await?;
264    let actual = hex::encode(Sha256::digest(&bytes));
265    if actual != sha256 {
266        return Err(ManagedToolError::Checksum {
267            expected: sha256.to_string(),
268            actual,
269        });
270    }
271    write_bytes_atomically(destination, &bytes).await?;
272    Ok(())
273}
274
275/// A `"PATH"` env entry prepending every installed managed tool's bin
276/// directory to the host's `PATH`, for builds that resolve tools by name
277/// (e.g. `shaderloom` invoking `dxc`).
278///
279/// Returns `None` when no managed tool is installed.
280#[must_use]
281pub fn managed_tools_path_env(host: &Host) -> Option<(String, OsString)> {
282    let mut entries: Vec<PathBuf> = all()
283        .into_iter()
284        .filter_map(|tool| tool.binary_dir(host))
285        .collect();
286    if entries.is_empty() {
287        return None;
288    }
289    entries.extend(host.path_entries());
290    let value = std::env::join_paths(entries).ok()?;
291    Some(("PATH".to_string(), value))
292}
293
294async fn remove_directory_if_exists(path: &Path) -> Result<(), ManagedToolError> {
295    if smol::unblock({
296        let path = path.to_path_buf();
297        move || path.is_dir()
298    })
299    .await
300    {
301        smol::unblock({
302            let path = path.to_path_buf();
303            move || std::fs::remove_dir_all(&path)
304        })
305        .await?;
306    }
307    Ok(())
308}
309
310/// Errors from managed-tool installs.
311#[derive(Debug, thiserror::Error)]
312pub enum ManagedToolError {
313    /// The asset could not be downloaded or written.
314    #[error(transparent)]
315    Asset(#[from] AssetError),
316
317    /// The host declares no home directory.
318    #[error(transparent)]
319    HomeDir(#[from] HomeDirError),
320
321    /// An I/O operation failed.
322    #[error(transparent)]
323    Io(#[from] io::Error),
324
325    /// The downloaded archive could not be read.
326    #[error("Could not unpack the release archive: {0}")]
327    Zip(#[from] zip::result::ZipError),
328
329    /// The downloaded archive's sha256 does not match the pinned value.
330    #[error(
331        "Checksum mismatch for the downloaded archive: expected sha256 {expected}, got {actual}"
332    )]
333    Checksum {
334        /// Pinned sha256, lowercase hex.
335        expected: String,
336        /// Computed sha256, lowercase hex.
337        actual: String,
338    },
339
340    /// The unpacked archive does not contain the expected binary.
341    #[error("The unpacked {name} archive does not contain the expected binary `{binary}`")]
342    ArchiveLayout {
343        /// Tool name.
344        name: &'static str,
345        /// Expected binary path inside the unpacked tree.
346        binary: String,
347    },
348}