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