Skip to main content

waterui_cli/toolchain/
web.rs

1//! Web toolchain checks and installations.
2
3use crate::{
4    toolchain::{Host, Installation, Toolchain, ToolchainError, rust::SelectedToolchainTargets},
5    utils::CommandError,
6    web::PackageManager,
7};
8
9/// The `wasm32-unknown-unknown` target on the toolchain the project's
10/// `rust-toolchain` pin (or the rustup default) selects.
11#[must_use]
12pub fn wasm32_target() -> SelectedToolchainTargets {
13    SelectedToolchainTargets::new(vec![String::from("wasm32-unknown-unknown")])
14}
15
16/// `wasm-pack` binary for packaging browser bundles.
17#[derive(Debug, Clone, Copy, Default)]
18pub struct WasmPack;
19
20/// Installation plan for `wasm-pack`.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct WasmPackInstallation;
23
24impl Toolchain for WasmPack {
25    type Installation = WasmPackInstallation;
26
27    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
28        if host.which("wasm-pack").await.is_ok() {
29            return Ok(());
30        }
31        if host.which("cargo").await.is_err() {
32            return Err(ToolchainError::unfixable(
33                "wasm-pack is not installed and cargo is not on PATH",
34                "Install Rust via rustup first (see the `rust` doctor item), then run `cargo install wasm-pack`.",
35            ));
36        }
37        Err(ToolchainError::Fixable(WasmPackInstallation))
38    }
39}
40
41impl Installation for WasmPackInstallation {
42    type Error = CommandError;
43
44    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
45        host.run("cargo", ["install", "--locked", "wasm-pack"])
46            .await
47            .map(|_| ())
48    }
49}
50
51/// The `[web] package_manager` a project declares: the executable the CLI
52/// invokes for frontend builds and scaffolding. Doctor checks the declared
53/// manager only — it never substitutes another.
54#[derive(Debug, Clone, Copy)]
55pub struct PackageManagerToolchain(pub PackageManager);
56
57/// Installation plan for a declared package manager, using its official
58/// installer.
59#[derive(Debug, Clone, Copy)]
60pub struct PackageManagerInstallation(pub PackageManager);
61
62impl Toolchain for PackageManagerToolchain {
63    type Installation = PackageManagerInstallation;
64
65    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
66        let package_manager = self.0;
67        if host.which(package_manager.binary()).await.is_ok() {
68            return Ok(());
69        }
70        match package_manager {
71            // npm ships with Node.js; there is no official standalone
72            // installer, so this stays a manual step.
73            PackageManager::Npm => Err(ToolchainError::unfixable(
74                "npm is not installed",
75                package_manager.install_hint(),
76            )),
77            _ => Err(ToolchainError::fixable(PackageManagerInstallation(
78                package_manager,
79            ))),
80        }
81    }
82}
83
84impl Installation for PackageManagerInstallation {
85    type Error = eyre::Report;
86
87    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
88        match self.0 {
89            // Yarn's official distribution is the corepack shim.
90            PackageManager::Yarn => host
91                .run("corepack", ["enable"])
92                .await
93                .map(|_| ())
94                .map_err(Into::into),
95            PackageManager::Npm => Err(eyre::eyre!(
96                "npm ships with Node.js; install Node.js from https://nodejs.org/"
97            )),
98            package_manager => {
99                #[cfg(unix)]
100                {
101                    let script = match package_manager {
102                        PackageManager::Bun => "curl -fsSL https://bun.sh/install | bash",
103                        PackageManager::Pnpm => "curl -fsSL https://get.pnpm.io/install.sh | sh -",
104                        _ => unreachable!(),
105                    };
106                    host.run("sh", ["-c", script])
107                        .await
108                        .map(|_| ())
109                        .map_err(Into::into)
110                }
111                #[cfg(windows)]
112                {
113                    let script = match package_manager {
114                        PackageManager::Bun => "irm bun.sh/install.ps1 | iex",
115                        PackageManager::Pnpm => "iwr https://get.pnpm.io/install.ps1 -useb | iex",
116                        _ => unreachable!(),
117                    };
118                    host.run("powershell", ["-c", script])
119                        .await
120                        .map(|_| ())
121                        .map_err(Into::into)
122                }
123                #[cfg(not(any(unix, windows)))]
124                {
125                    Err(eyre::eyre!(
126                        "no automatic installer for {} on this platform; run: {}",
127                        package_manager.binary(),
128                        package_manager.install_hint()
129                    ))
130                }
131            }
132        }
133    }
134}
135
136/// Composite toolchain for Web/WASM support.
137pub type WebToolchain = (SelectedToolchainTargets, WasmPack);
138
139/// The web toolchain as the CLI's web build/package path invokes it.
140#[must_use]
141pub fn web_toolchain() -> WebToolchain {
142    (wasm32_target(), WasmPack)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{WasmPack, wasm32_target};
148    use crate::toolchain::testing::TestMachine;
149    use crate::toolchain::{Toolchain, ToolchainError};
150
151    /// A host whose `rustup` reports a `stable-<host>` default toolchain.
152    fn machine_with_rustup() -> TestMachine {
153        let machine = TestMachine::new();
154        machine.install("rustup");
155        machine.respond(
156            "RUSTUP_ACTIVE_TOOLCHAIN",
157            "stable-aarch64-apple-darwin (default)",
158        );
159        machine
160    }
161
162    #[test]
163    fn wasm32_target_unfixable_without_rustup() {
164        let machine = TestMachine::new();
165        let host = machine.host(Vec::<(String, String)>::new());
166        let result = smol::block_on(wasm32_target().check(&host));
167        assert!(
168            matches!(result, Err(ToolchainError::Unfixable(_))),
169            "missing rustup must be unfixable: {result:?}"
170        );
171    }
172
173    #[test]
174    fn wasm32_target_fixable_when_not_installed() {
175        let machine = machine_with_rustup();
176        let host = machine.host([(
177            String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
178            String::from("aarch64-apple-darwin"),
179        )]);
180        let result = smol::block_on(wasm32_target().check(&host));
181        assert!(
182            matches!(result, Err(ToolchainError::Fixable(_))),
183            "absent wasm32 target must be fixable: {result:?}"
184        );
185    }
186
187    #[test]
188    fn wasm32_target_ok_when_installed() {
189        let machine = machine_with_rustup();
190        // `rustup target list --installed` emits one target per line.
191        machine.respond(
192            "RUSTUP_INSTALLED_TARGETS",
193            &["aarch64-apple-darwin", "wasm32-unknown-unknown"].join("\n"),
194        );
195        let host = machine.host(Vec::<(String, String)>::new());
196        smol::block_on(wasm32_target().check(&host)).expect("installed wasm32 target must be ok");
197    }
198
199    /// The check probes the toolchain the project pin selects, not the
200    /// rustup default.
201    #[test]
202    fn wasm32_target_checks_pinned_toolchain() {
203        let machine = machine_with_rustup();
204        machine.file(
205            "rust-toolchain.toml",
206            "[toolchain]\nchannel = \"nightly\"\n",
207        );
208        machine.respond(
209            "RUSTUP_ACTIVE_TOOLCHAIN",
210            "nightly-aarch64-apple-darwin (overridden by rust-toolchain.toml)",
211        );
212        machine.respond(
213            "RUSTUP_INSTALLED_TARGETS",
214            "aarch64-apple-darwin\nwasm32-unknown-unknown",
215        );
216        let host = machine.host(Vec::<(String, String)>::new());
217        smol::block_on(wasm32_target().check(&host))
218            .expect("installed wasm32 target on the pinned toolchain must be ok");
219    }
220
221    #[test]
222    fn wasm_pack_ok_when_on_path() {
223        let machine = TestMachine::new();
224        machine.install("wasm-pack");
225        let host = machine.host(Vec::<(String, String)>::new());
226        smol::block_on(WasmPack.check(&host)).expect("wasm-pack on PATH must be ok");
227    }
228
229    #[test]
230    fn wasm_pack_fixable_when_missing() {
231        let machine = TestMachine::new();
232        machine.install("cargo");
233        let host = machine.host(Vec::<(String, String)>::new());
234        let result = smol::block_on(WasmPack.check(&host));
235        assert!(
236            matches!(result, Err(ToolchainError::Fixable(_))),
237            "missing wasm-pack must be fixable via cargo install: {result:?}"
238        );
239    }
240
241    #[test]
242    fn wasm_pack_unfixable_without_cargo() {
243        let machine = TestMachine::new();
244        let host = machine.host(Vec::<(String, String)>::new());
245        let result = smol::block_on(WasmPack.check(&host));
246        assert!(
247            matches!(result, Err(ToolchainError::Unfixable(_))),
248            "missing wasm-pack without cargo must be manual: {result:?}"
249        );
250    }
251}