Skip to main content

waterui_cli/esp32/
toolchain.rs

1//! ESP32 (Dew) toolchain checks and remediation.
2//!
3//! An ESP32 build drives a non-rustup toolchain — the Espressif `esp` Rust
4//! fork under `~/.rustup/toolchains/esp` — plus the pieces that live beside
5//! it: the Espressif clang libraries (`LIBCLANG_PATH` for `esp-idf-sys`'s
6//! bindgen), the chip architecture's GCC linker toolchain, `rust-src` (the
7//! generated harness builds `std` itself via `-Zbuild-std`), and the
8//! cargo-installed `espflash`/`ldproxy` binaries the build and run paths
9//! invoke. `espup install` is the programmatic repair for everything under
10//! the toolchain directories; QEMU, needed for emulated `water run`, comes
11//! from the system package manager.
12
13use std::path::{Path, PathBuf};
14
15use crate::{
16    esp32::{
17        chip::{Esp32Arch, Esp32Chip},
18        platform::newest_toolchain_subpath,
19    },
20    toolchain::{
21        Host, Installation, Toolchain, ToolchainError,
22        cargo_helpers::{CargoHelpersInstallation, FailToInstallCargoHelpers},
23        rust::rustup_toolchains_dir,
24    },
25    utils::CommandError,
26};
27
28/// ESP32 toolchain checker for a set of chips.
29///
30/// The chip set is what `[backends.esp32]` selects — or every supported chip
31/// for a playground, which can target any of them. Pieces shared across
32/// chips (the `esp` toolchain, its clang libraries, `rust-src`, the helper
33/// binaries) are probed once; the architecture-specific GCC and QEMU binary
34/// once per architecture.
35#[derive(Debug, Clone)]
36pub struct Esp32Toolchain {
37    chips: Vec<Esp32Chip>,
38}
39
40impl Esp32Toolchain {
41    /// Check the ESP32 toolchain for `chips`.
42    #[must_use]
43    pub fn new(chips: impl IntoIterator<Item = Esp32Chip>) -> Self {
44        Self {
45            chips: chips.into_iter().collect(),
46        }
47    }
48}
49
50/// Installation plan for the ESP32 toolchain.
51///
52/// `cargo install espup` runs when the installer itself is absent, `espup
53/// install` repairs the toolchain directories (`--esp-riscv-gcc` when the
54/// chip's GCC is RISC-V and missing), then `cargo install` covers the
55/// helper binaries.
56#[derive(Debug, Clone)]
57pub struct Esp32ToolchainInstallation {
58    /// What the check found missing — surfaced as the doctor item's message.
59    missing: Vec<String>,
60    /// Pieces no automatic repair covers (e.g. QEMU).
61    manual: Vec<String>,
62    /// Run `cargo install espup` before `espup install`.
63    install_espup: bool,
64    /// Run `espup install` (with `--esp-riscv-gcc` when `riscv_gcc`).
65    run_espup: bool,
66    /// A RISC-V chip's GCC was missing — pass `--esp-riscv-gcc`.
67    riscv_gcc: bool,
68    /// `cargo install`/`cargo binstall` for missing helper binaries.
69    helpers: CargoHelpersInstallation,
70}
71
72impl Esp32ToolchainInstallation {
73    /// The missing pieces and manual repairs, for the doctor item's message.
74    #[must_use]
75    pub fn describe(&self) -> String {
76        let manual = if self.manual.is_empty() {
77            String::new()
78        } else {
79            format!(". Manual steps: {}", self.manual.join("; "))
80        };
81        format!(
82            "ESP32 toolchain incomplete: {}{manual}",
83            self.missing.join(", ")
84        )
85    }
86}
87
88/// Errors from ESP32 toolchain installation.
89#[derive(Debug, thiserror::Error)]
90pub enum FailToInstallEsp32Toolchain {
91    /// `cargo install espup` failed.
92    #[error("Failed to install `espup`: {0}")]
93    InstallEspup(#[source] FailToInstallCargoHelpers),
94    /// `espup install` failed.
95    #[error("`espup install` failed: {0}")]
96    EspupInstall(#[source] CommandError),
97    /// A helper binary could not be installed.
98    #[error(transparent)]
99    Helpers(#[from] FailToInstallCargoHelpers),
100}
101
102/// The Espressif `esp` toolchain root on this host:
103/// `$RUSTUP_HOME/toolchains/esp`, or `~/.rustup/toolchains/esp`.
104fn esp_toolchain_dir(host: &Host) -> Option<PathBuf> {
105    rustup_toolchains_dir(host).map(|dir| dir.join("esp"))
106}
107
108/// The install hint for QEMU's system emulator on this OS.
109const fn qemu_install_hint() -> &'static str {
110    if cfg!(target_os = "macos") {
111        "brew install qemu"
112    } else if cfg!(target_os = "windows") {
113        "install QEMU from https://www.qemu.org/download/#windows or `winget install QEMU`"
114    } else {
115        "install the qemu-system package (e.g. `apt install qemu-system-misc`)"
116    }
117}
118
119/// The gaps the ESP32 probes accumulate before classification.
120#[derive(Default)]
121struct Esp32Findings {
122    /// What is missing — surfaced verbatim in the doctor item's message.
123    missing: Vec<String>,
124    /// Repairs no automatic step covers (QEMU, an espup gap it cannot fill).
125    manual: Vec<String>,
126    /// `espup install` repairs the toolchain directories.
127    run_espup: bool,
128    /// The RISC-V GCC was missing — pass `--esp-riscv-gcc`.
129    riscv_gcc: bool,
130    /// Cargo-installable helper binaries missing from PATH.
131    helpers: Vec<String>,
132}
133
134impl Esp32Toolchain {
135    /// The `esp` toolchain directory and the pieces living inside it: the
136    /// Espressif clang libraries `esp-idf-sys`'s bindgen needs, `rust-src`
137    /// (the generated harness builds `std` via `-Zbuild-std`), and the
138    /// Xtensa GCC the toolchain ships.
139    fn probe_esp_toolchain(&self, host: &Host, findings: &mut Esp32Findings) {
140        let Some(esp_dir) = esp_toolchain_dir(host).filter(|dir| dir.is_dir()) else {
141            findings.missing.push("the `esp` Rust toolchain".to_owned());
142            findings.run_espup = true;
143            return;
144        };
145        if newest_toolchain_subpath(
146            &esp_dir.join("xtensa-esp32-elf-clang"),
147            Path::new("esp-clang/lib"),
148        )
149        .is_none()
150        {
151            findings
152                .missing
153                .push("the Espressif clang libraries".to_owned());
154            findings.run_espup = true;
155        }
156        if !esp_dir.join("lib/rustlib/src/rust").is_dir() {
157            findings
158                .missing
159                .push("the `rust-src` component on the `esp` toolchain".to_owned());
160            findings.run_espup = true;
161        }
162        if self
163            .chips
164            .iter()
165            .any(|chip| chip.arch() == Esp32Arch::Xtensa)
166        {
167            let gcc = Esp32Chip::Esp32S3.gcc_component();
168            if newest_toolchain_subpath(&esp_dir.join(gcc.component), Path::new(gcc.bin_subpath))
169                .is_none()
170            {
171                findings
172                    .missing
173                    .push(format!("the {} ({})", gcc.what, gcc.component));
174                findings.run_espup = true;
175            }
176        }
177    }
178
179    /// The RISC-V GCC lives outside the `esp` toolchain, under
180    /// `~/.espressif/tools`, so it is probed even when `esp` is missing.
181    fn probe_riscv_gcc(&self, host: &Host, findings: &mut Esp32Findings) {
182        let Some(chip) = self
183            .chips
184            .iter()
185            .find(|chip| chip.arch() == Esp32Arch::RiscV)
186        else {
187            return;
188        };
189        let gcc = chip.gcc_component();
190        let base = host
191            .home_dir()
192            .map(|home| home.join(".espressif/tools").join(gcc.component));
193        let present = base.as_ref().is_some_and(|base| {
194            newest_toolchain_subpath(base, Path::new(gcc.bin_subpath)).is_some()
195        });
196        if present {
197            return;
198        }
199        findings.missing.push(format!(
200            "the {} (`{}` under ~/.espressif/tools)",
201            gcc.what, gcc.component
202        ));
203        // `espup install --esp-riscv-gcc` installs Espressif's RISC-V GCC;
204        // ESP-IDF's `idf_tools.py install` is the alternative.
205        findings.run_espup = true;
206        findings.riscv_gcc = true;
207        findings.manual.push(format!(
208            "if `espup install --esp-riscv-gcc` does not provide the {}, install it with ESP-IDF's `idf_tools.py install`",
209            gcc.what
210        ));
211    }
212
213    /// `water run`/`water package` for ESP32 invoke `espflash` directly and
214    /// the generated harness links with `ldproxy`; emulated `water run`
215    /// boots the chip under QEMU's system emulator.
216    async fn probe_binaries(&self, host: &Host, findings: &mut Esp32Findings) {
217        for binary in ["espflash", "ldproxy"] {
218            if host.which(binary).await.is_err() {
219                findings.missing.push(format!("`{binary}` on PATH"));
220                findings.helpers.push(binary.to_owned());
221            }
222        }
223        let mut qemu_checked = Vec::<&'static str>::new();
224        for qemu in self.chips.iter().map(|chip| chip.qemu_binary()) {
225            if qemu_checked.contains(&qemu) {
226                continue;
227            }
228            qemu_checked.push(qemu);
229            if host.which(qemu).await.is_err() {
230                findings
231                    .missing
232                    .push(format!("`{qemu}` on PATH (for emulated `water run`)"));
233                findings
234                    .manual
235                    .push(format!("install QEMU with `{}`", qemu_install_hint()));
236            }
237        }
238    }
239}
240
241impl Toolchain for Esp32Toolchain {
242    type Installation = Esp32ToolchainInstallation;
243
244    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
245        let mut findings = Esp32Findings::default();
246        self.probe_esp_toolchain(host, &mut findings);
247        self.probe_riscv_gcc(host, &mut findings);
248        self.probe_binaries(host, &mut findings).await;
249
250        if findings.missing.is_empty() {
251            return Ok(());
252        }
253
254        let cargo_available = host.which("cargo").await.is_ok();
255        let espup_available = host.which("espup").await.is_ok();
256        let helpers_missing = !findings.helpers.is_empty();
257        let install_espup = findings.run_espup && !espup_available;
258        // `espup` itself, and the cargo helpers, are cargo installs — nothing
259        // is programmatically repairable without cargo on PATH.
260        if !cargo_available && (install_espup || helpers_missing) {
261            let mut commands = Vec::new();
262            if install_espup {
263                commands.push("cargo install espup".to_owned());
264            }
265            if findings.run_espup {
266                commands.push("espup install".to_owned());
267            }
268            if helpers_missing {
269                commands.push(format!("cargo install {}", findings.helpers.join(" ")));
270            }
271            return Err(ToolchainError::unfixable(
272                format!(
273                    "ESP32 toolchain incomplete: {}",
274                    findings.missing.join(", ")
275                ),
276                format!(
277                    "Install Rust via rustup first (see the `rust` doctor item), then run {}.",
278                    commands.join("`, `")
279                ),
280            ));
281        }
282
283        let installation = Esp32ToolchainInstallation {
284            missing: findings.missing,
285            manual: findings.manual,
286            install_espup,
287            run_espup: findings.run_espup,
288            riscv_gcc: findings.riscv_gcc,
289            helpers: CargoHelpersInstallation::new(findings.helpers),
290        };
291        if install_espup || findings.run_espup || helpers_missing {
292            Err(ToolchainError::fixable(installation))
293        } else {
294            // Only manual pieces are missing (e.g. QEMU alone).
295            let manual = installation.manual.join("; ");
296            Err(ToolchainError::unfixable(installation.describe(), manual))
297        }
298    }
299}
300
301impl Installation for Esp32ToolchainInstallation {
302    type Error = FailToInstallEsp32Toolchain;
303
304    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
305        if self.install_espup {
306            CargoHelpersInstallation::new(vec!["espup".to_owned()])
307                .install(host)
308                .await
309                .map_err(FailToInstallEsp32Toolchain::InstallEspup)?;
310        }
311        if self.run_espup {
312            let mut args = vec!["install"];
313            if self.riscv_gcc {
314                args.push("--esp-riscv-gcc");
315            }
316            host.run("espup", args)
317                .await
318                .map_err(FailToInstallEsp32Toolchain::EspupInstall)?;
319        }
320        self.helpers.install(host).await?;
321        Ok(())
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::Esp32Toolchain;
328    use crate::esp32::chip::Esp32Chip;
329    use crate::toolchain::testing::TestMachine;
330    use crate::toolchain::{Toolchain, ToolchainError};
331
332    /// Stage the shared `esp` toolchain directories (`clang` libs, `rust-src`)
333    /// and, optionally, the Xtensa GCC under the fake home.
334    fn stage_esp_toolchain(machine: &TestMachine, xtensa_gcc: bool) {
335        for subdir in [
336            "xtensa-esp32-elf-clang/1.0/esp-clang/lib",
337            "lib/rustlib/src/rust",
338        ] {
339            machine.dir(format!("home/.rustup/toolchains/esp/{subdir}"));
340        }
341        if xtensa_gcc {
342            machine.dir("home/.rustup/toolchains/esp/xtensa-esp-elf/1.0/xtensa-esp-elf/bin");
343        }
344    }
345
346    /// A host with cargo and the `espup`/`espflash`/`ldproxy`/QEMU binaries
347    /// but no `esp` toolchain — everything missing is a cargo or espup
348    /// install away.
349    fn cargo_machine() -> TestMachine {
350        let machine = TestMachine::new();
351        machine.install("cargo");
352        machine
353    }
354
355    #[test]
356    fn esp32_unfixable_when_esp_missing_and_no_cargo() {
357        let machine = TestMachine::new();
358        let host = machine.host(Vec::<(String, String)>::new());
359        let result = smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32S3]).check(&host));
360        match &result {
361            Err(ToolchainError::Unfixable(error)) => {
362                assert!(
363                    error.suggestion().contains("cargo install espup"),
364                    "the manual path must name `cargo install espup`: {}",
365                    error.suggestion()
366                );
367            }
368            other => panic!("missing esp toolchain without cargo must be manual: {other:?}"),
369        }
370    }
371
372    #[test]
373    fn esp32_fixable_when_esp_missing_and_cargo_present() {
374        let machine = cargo_machine();
375        machine.install("espup");
376        machine.install("espflash");
377        machine.install("ldproxy");
378        machine.install("qemu-system-xtensa");
379        let host = machine.host(Vec::<(String, String)>::new());
380        let result = smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32S3]).check(&host));
381        assert!(
382            matches!(result, Err(ToolchainError::Fixable(_))),
383            "missing esp toolchain with espup present must be fixable: {result:?}"
384        );
385    }
386
387    #[test]
388    fn esp32_ok_when_fully_staged() {
389        let machine = cargo_machine();
390        machine.install("espflash");
391        machine.install("ldproxy");
392        machine.install("qemu-system-xtensa");
393        stage_esp_toolchain(&machine, true);
394        let host = machine.host(Vec::<(String, String)>::new());
395        smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32S3]).check(&host))
396            .expect("a fully staged esp toolchain must be ok");
397    }
398
399    #[test]
400    fn esp32_riscv_checks_espressif_tools_tree() {
401        let machine = cargo_machine();
402        machine.install("espflash");
403        machine.install("ldproxy");
404        machine.install("qemu-system-riscv32");
405        stage_esp_toolchain(&machine, false);
406        // The RISC-V GCC is still missing under ~/.espressif/tools.
407        let host = machine.host(Vec::<(String, String)>::new());
408        let result = smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32C3]).check(&host));
409        match &result {
410            Err(ToolchainError::Fixable(installation)) => {
411                assert!(
412                    installation.describe().contains("riscv32-esp-elf"),
413                    "the missing RISC-V GCC must be named: {}",
414                    installation.describe()
415                );
416            }
417            other => panic!("a missing RISC-V GCC must be fixable via espup: {other:?}"),
418        }
419    }
420
421    #[test]
422    fn esp32_riscv_ok_when_gcc_staged() {
423        let machine = cargo_machine();
424        machine.install("espflash");
425        machine.install("ldproxy");
426        machine.install("qemu-system-riscv32");
427        stage_esp_toolchain(&machine, false);
428        machine.dir("home/.espressif/tools/riscv32-esp-elf/1.0/riscv32-esp-elf/bin");
429        let host = machine.host(Vec::<(String, String)>::new());
430        smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32C3]).check(&host))
431            .expect("staged RISC-V toolchain must be ok");
432    }
433
434    #[test]
435    fn esp32_qemu_alone_is_manual() {
436        let machine = cargo_machine();
437        machine.install("espflash");
438        machine.install("ldproxy");
439        stage_esp_toolchain(&machine, true);
440        let host = machine.host(Vec::<(String, String)>::new());
441        let result = smol::block_on(Esp32Toolchain::new([Esp32Chip::Esp32S3]).check(&host));
442        match &result {
443            Err(ToolchainError::Unfixable(error)) => {
444                assert!(
445                    error.suggestion().contains("QEMU"),
446                    "the QEMU-only gap must name its install: {}",
447                    error.suggestion()
448                );
449            }
450            other => panic!("only QEMU missing must be a manual item: {other:?}"),
451        }
452    }
453}