Skip to main content

espforge_lib/cli/commands/
doctor.rs

1use anyhow::Result;
2use std::process::Command;
3
4// Assumes latest esp-generate version works in espforge
5
6/// All supported chips with their architecture and RISC-V target (if applicable).
7const CHIPS: &[ChipInfo] = &[
8    ChipInfo {
9        name: "ESP32",
10        arch: Arch::Xtensa,
11        riscv_target: None,
12    },
13    ChipInfo {
14        name: "ESP32-S2",
15        arch: Arch::Xtensa,
16        riscv_target: None,
17    },
18    ChipInfo {
19        name: "ESP32-S3",
20        arch: Arch::Xtensa,
21        riscv_target: None,
22    },
23    ChipInfo {
24        name: "ESP32-C2",
25        arch: Arch::RiscV,
26        riscv_target: Some("riscv32imc-unknown-none-elf"),
27    },
28    ChipInfo {
29        name: "ESP32-C3",
30        arch: Arch::RiscV,
31        riscv_target: Some("riscv32imc-unknown-none-elf"),
32    },
33    ChipInfo {
34        name: "ESP32-C6",
35        arch: Arch::RiscV,
36        riscv_target: Some("riscv32imac-unknown-none-elf"),
37    },
38    ChipInfo {
39        name: "ESP32-H2",
40        arch: Arch::RiscV,
41        riscv_target: Some("riscv32imac-unknown-none-elf"),
42    },
43];
44
45struct ChipInfo {
46    name: &'static str,
47    arch: Arch,
48    riscv_target: Option<&'static str>,
49}
50
51#[derive(PartialEq)]
52enum Arch {
53    Xtensa,
54    RiscV,
55}
56
57struct CheckResult {
58    name: &'static str,
59    status: Status,
60    version: Option<String>,
61    note: Option<&'static str>,
62}
63
64enum Status {
65    Ok,
66    Warning(String),
67    Missing,
68}
69
70/// Snapshot of environment facts, used both for individual checks and the chip summary.
71struct EnvState {
72    has_cargo: bool,
73    has_esp_toolchain: bool, // 'esp' rustup channel — required for Xtensa
74    has_stable_toolchain: bool,
75    has_gcc_toolchain: bool, // xtensa-esp-elf-gcc — required for Xtensa linking
76    has_esp_generate: bool,
77    installed_riscv_targets: Vec<String>,
78}
79
80pub fn execute() -> Result<()> {
81    println!();
82    println!("đŸ”Ŧ espforge doctor — environment check");
83    println!("{}", "─".repeat(50));
84    println!();
85
86    let env = probe_environment();
87
88    let checks = vec![
89        check_cargo(&env),
90        check_esp_toolchain(&env),
91        check_esp_generate(&env),
92        check_gcc_toolchain(&env),
93        check_riscv_targets(&env),
94    ];
95
96    let mut all_ok = true;
97
98    for check in &checks {
99        let icon = match &check.status {
100            Status::Ok => "✅",
101            Status::Warning(_) => "âš ī¸ ",
102            Status::Missing => "❌",
103        };
104
105        let version_str = check
106            .version
107            .as_deref()
108            .map(|v| format!("  ({})", v))
109            .unwrap_or_default();
110
111        println!("  {} {}{}", icon, check.name, version_str);
112
113        match &check.status {
114            Status::Ok => {}
115            Status::Warning(msg) => {
116                println!("       {}", msg);
117                all_ok = false;
118            }
119            Status::Missing => {
120                all_ok = false;
121            }
122        }
123
124        if let Some(note) = check.note {
125            println!("       â„šī¸  {}", note);
126        }
127    }
128
129    println!();
130    if all_ok {
131        println!("✨ All checks passed — you're ready to use espforge!");
132    } else {
133        println!("⚡ Some issues were found. See above for details.");
134        println!();
135        println!("  Quick fix:");
136        println!("    cargo install cargo-binstall");
137        println!("    cargo binstall espup");
138        println!("    espup install");
139        println!("    cargo binstall esp-generate");
140        println!("    rustup target add riscv32imc-unknown-none-elf");
141        println!("    rustup target add riscv32imac-unknown-none-elf");
142    }
143
144    print_chip_summary(&env);
145
146    Ok(())
147}
148
149/// Probe the environment once and return a shared state struct used by all checks.
150fn probe_environment() -> EnvState {
151    let has_cargo = run_version_cmd("cargo", &["--version"]).is_some();
152    let has_gcc_toolchain = run_version_cmd("xtensa-esp-elf-gcc", &["--version"]).is_some();
153    let has_esp_generate = run_version_cmd("esp-generate", &["--version"]).is_some();
154
155    let toolchain_list = Command::new("rustup")
156        .args(["toolchain", "list"])
157        .output()
158        .ok()
159        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
160        .unwrap_or_default();
161
162    let has_esp_toolchain = toolchain_list.lines().any(|l| l.starts_with("esp"));
163    let has_stable_toolchain = toolchain_list.lines().any(|l| l.starts_with("stable"));
164
165    let installed_riscv_targets = Command::new("rustup")
166        .args(["target", "list", "--installed"])
167        .output()
168        .ok()
169        .map(|o| {
170            String::from_utf8_lossy(&o.stdout)
171                .lines()
172                .map(|l| l.trim().to_string())
173                .collect()
174        })
175        .unwrap_or_default();
176
177    EnvState {
178        has_cargo,
179        has_esp_toolchain,
180        has_stable_toolchain,
181        has_gcc_toolchain,
182        has_esp_generate,
183        installed_riscv_targets,
184    }
185}
186
187fn print_chip_summary(env: &EnvState) {
188    println!();
189    println!("📋 Chip readiness summary");
190    println!("{}", "─".repeat(50));
191    println!();
192
193    let name_w = 10;
194    let arch_w = 9;
195
196    println!(
197        "  {:<name_w$}  {:<arch_w$}  Status",
198        "Chip",
199        "Arch",
200        name_w = name_w,
201        arch_w = arch_w,
202    );
203    println!("  {}", "─".repeat(46));
204
205    for chip in CHIPS {
206        let (icon, reason) = chip_status(chip, env);
207        let arch_label = match chip.arch {
208            Arch::Xtensa => "Xtensa",
209            Arch::RiscV => "RISC-V",
210        };
211        println!(
212            "  {:<name_w$}  {:<arch_w$}  {} {}",
213            chip.name,
214            arch_label,
215            icon,
216            reason,
217            name_w = name_w,
218            arch_w = arch_w,
219        );
220    }
221
222    println!();
223}
224
225/// Returns (icon, human-readable status) for a single chip.
226fn chip_status(chip: &ChipInfo, env: &EnvState) -> (&'static str, String) {
227    let base_ok = env.has_cargo && env.has_esp_generate;
228
229    match chip.arch {
230        Arch::Xtensa => {
231            let mut missing: Vec<&str> = Vec::new();
232            if !base_ok {
233                missing.push("cargo / esp-generate")
234            }
235            if !env.has_esp_toolchain {
236                missing.push("esp toolchain (espup install)")
237            }
238            if !env.has_gcc_toolchain {
239                missing.push("xtensa-esp-elf-gcc (espup install)")
240            }
241
242            if missing.is_empty() {
243                ("✅", "Ready".to_string())
244            } else {
245                ("❌", format!("Missing: {}", missing.join(", ")))
246            }
247        }
248
249        Arch::RiscV => {
250            let target = chip.riscv_target.unwrap_or("");
251            let has_target = env.installed_riscv_targets.iter().any(|t| t == target);
252            let has_toolchain = env.has_stable_toolchain || env.has_esp_toolchain;
253
254            let mut missing: Vec<String> = Vec::new();
255            if !base_ok {
256                missing.push("cargo / esp-generate".to_string())
257            }
258            if !has_toolchain {
259                missing.push("stable toolchain (rustup toolchain install stable)".to_string())
260            }
261            if !has_target {
262                missing.push(format!("{} (rustup target add {})", target, target))
263            }
264
265            if missing.is_empty() {
266                ("✅", "Ready".to_string())
267            } else {
268                ("❌", format!("Missing: {}", missing.join(", ")))
269            }
270        }
271    }
272}
273
274fn check_cargo(env: &EnvState) -> CheckResult {
275    if env.has_cargo {
276        CheckResult {
277            name: "cargo / Rust",
278            status: Status::Ok,
279            version: run_version_cmd("cargo", &["--version"]),
280            note: None,
281        }
282    } else {
283        CheckResult {
284            name: "cargo / Rust",
285            status: Status::Missing,
286            version: None,
287            note: Some("Install Rust from https://rustup.rs/"),
288        }
289    }
290}
291
292fn check_esp_toolchain(env: &EnvState) -> CheckResult {
293    // The ESP toolchain (for Xtensa chips like esp32, esp32s3) is installed via `espup`
294    // and shows up as the "esp" channel in `rustup toolchain list`.
295    // RISC-V chips (esp32c3, esp32c6, etc.) use the standard stable toolchain.
296    if env.has_esp_toolchain {
297        let esp_version = Command::new("rustup")
298            .args(["toolchain", "list"])
299            .output()
300            .ok()
301            .and_then(|o| {
302                String::from_utf8_lossy(&o.stdout)
303                    .lines()
304                    .find(|l| l.starts_with("esp"))
305                    .map(|l| l.trim().to_string())
306            });
307
308        CheckResult {
309            name: "ESP toolchain (Xtensa/RISC-V via espup)",
310            status: Status::Ok,
311            version: esp_version,
312            note: Some("'esp' channel required for Xtensa chips (esp32, esp32s3, esp32s2)"),
313        }
314    } else if env.has_stable_toolchain {
315        CheckResult {
316            name: "ESP toolchain (Xtensa/RISC-V via espup)",
317            status: Status::Warning(
318                "Only 'stable' toolchain found. Xtensa chips (esp32, esp32s3) require \
319                 the 'esp' channel.\n       Run: espup install"
320                    .to_string(),
321            ),
322            version: None,
323            note: Some("RISC-V chips (esp32c3, esp32c6, etc.) work with stable"),
324        }
325    } else {
326        CheckResult {
327            name: "ESP toolchain (Xtensa/RISC-V via espup)",
328            status: Status::Missing,
329            version: None,
330            note: Some("Run: cargo binstall espup && espup install"),
331        }
332    }
333}
334
335fn check_esp_generate(env: &EnvState) -> CheckResult {
336    if !env.has_esp_generate {
337        return CheckResult {
338            name: "esp-generate",
339            status: Status::Missing,
340            version: None,
341            note: Some("Run: cargo binstall esp-generate"),
342        };
343    }
344
345    match run_version_cmd("esp-generate", &["--version"]) {
346        None => CheckResult {
347            name: "esp-generate",
348            status: Status::Missing,
349            version: None,
350            note: Some("Run: cargo binstall esp-generate"),
351        },
352        Some(raw_version) => {
353            let semver = raw_version
354                .split_whitespace()
355                .last()
356                .unwrap_or(&raw_version)
357                .to_string();
358
359            let status = Status::Ok;
360
361            CheckResult {
362                name: "esp-generate",
363                status,
364                version: Some(semver),
365                note: Some(""),
366            }
367        }
368    }
369}
370
371fn check_gcc_toolchain(env: &EnvState) -> CheckResult {
372    // espup installs the Xtensa GCC toolchain as `xtensa-esp-elf-gcc`, used to link
373    // the final binary for Xtensa targets (esp32, esp32s2, esp32s3).
374    if env.has_gcc_toolchain {
375        CheckResult {
376            name: "GCC toolchain (xtensa-esp-elf-gcc)",
377            status: Status::Ok,
378            version: run_version_cmd("xtensa-esp-elf-gcc", &["--version"]),
379            note: Some("Required for linking Xtensa binaries (esp32, esp32s2, esp32s3)"),
380        }
381    } else {
382        CheckResult {
383            name: "GCC toolchain (xtensa-esp-elf-gcc)",
384            status: Status::Missing,
385            version: None,
386            note: Some(
387                "Installed by espup — run: cargo binstall espup && espup install\n       \
388                 Also ensure $HOME/.espup/... is on your PATH (source the export file)",
389            ),
390        }
391    }
392}
393
394fn check_riscv_targets(env: &EnvState) -> CheckResult {
395    let targets = [
396        ("riscv32imc-unknown-none-elf", "ESP32-C2, ESP32-C3"),
397        ("riscv32imac-unknown-none-elf", "ESP32-C6, ESP32-H2"),
398    ];
399
400    let mut missing: Vec<String> = Vec::new();
401    for (target, chips) in &targets {
402        if !env.installed_riscv_targets.iter().any(|t| t == target) {
403            missing.push(format!("{} ({})", target, chips));
404        }
405    }
406
407    if missing.is_empty() {
408        let names = targets
409            .iter()
410            .map(|(t, _)| *t)
411            .collect::<Vec<_>>()
412            .join(", ");
413        CheckResult {
414            name: "RISC-V targets",
415            status: Status::Ok,
416            version: Some(names),
417            note: Some("Covers ESP32-C2/C3 (riscv32imc) and ESP32-C6/H2 (riscv32imac)"),
418        }
419    } else {
420        let fix = missing
421            .iter()
422            .map(|m| {
423                let target = m.split_whitespace().next().unwrap_or("");
424                format!("rustup target add {}", target)
425            })
426            .collect::<Vec<_>>()
427            .join("\n       ");
428
429        CheckResult {
430            name: "RISC-V targets",
431            status: Status::Warning(format!(
432                "Missing targets:\n         {}\n       Fix:\n       {}",
433                missing.join("\n         "),
434                fix
435            )),
436            version: None,
437            note: Some("Required for RISC-V based ESP chips"),
438        }
439    }
440}
441
442/// Run a command and return the first line of stdout, trimmed.
443fn run_version_cmd(program: &str, args: &[&str]) -> Option<String> {
444    Command::new(program)
445        .args(args)
446        .output()
447        .ok()
448        .filter(|o| o.status.success())
449        .and_then(|o| {
450            String::from_utf8(o.stdout)
451                .ok()
452                .map(|s| s.lines().next().unwrap_or("").trim().to_string())
453        })
454        .filter(|s| !s.is_empty())
455}