Skip to main content

waterui_cli/toolchain/
web.rs

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