Skip to main content

whisker_cli/
doctor.rs

1//! `whisker doctor` — environment health check.
2//!
3//! Mirrors `expo doctor` / `flutter doctor` in spirit: walk the local
4//! machine for the toolchains and artifacts Whisker needs, report each as
5//! ok / warning / error, and exit non-zero if any error is found.
6//!
7//! Each check is a pure inspection — no side effects (no installs, no
8//! downloads). The user runs the fix themselves; we only diagnose.
9//!
10//! ## Output style
11//! Section spinners ("Probing Android …") while each group runs, then
12//! a fixed-width-aligned list with a small ✓/⚠/✗ glyph at the left.
13//! Plain scrollback text — no boxes, no TUI takeover — so the result
14//! is easy to copy/paste into an issue or hand to an AI assistant.
15
16use anyhow::Result;
17use indicatif::{ProgressBar, ProgressStyle};
18use std::path::PathBuf;
19use std::process::Command;
20use std::time::Duration;
21
22#[derive(clap::Args, Debug)]
23pub struct Args {
24    /// Skip the iOS section even on macOS hosts.
25    #[arg(long)]
26    pub no_ios: bool,
27    /// Skip the Android section.
28    #[arg(long)]
29    pub no_android: bool,
30}
31
32pub fn run(args: Args) -> Result<()> {
33    println!("{BOLD}whisker doctor{RESET}\n");
34
35    let mut report = Report::default();
36
37    report.add_section("Rust toolchain", check_rust);
38
39    if !args.no_android {
40        report.add_section("Android", check_android);
41    }
42    if !args.no_ios {
43        report.add_section("iOS", check_ios);
44    }
45
46    // No Lynx section: Android pulls the Lynx aar from gradle's
47    // `whiskerrs.github.io/lynx/maven` repository, and iOS resolves
48    // the four Lynx xcframeworks via SPM's `binaryTarget(url:checksum:)`
49    // declarations in `platforms/ios/Package.swift` during xcodebuild's
50    // package-resolution step. Neither writes to `~/.cache/whisker/`;
51    // the doctor has nothing useful to assert about Lynx before a
52    // build runs.
53
54    report.print_summary();
55    if report.has_errors() {
56        std::process::exit(1);
57    }
58    Ok(())
59}
60
61// ----- Style tokens ---------------------------------------------------------
62
63const C_OK: &str = "\x1b[32m";
64const C_WARN: &str = "\x1b[33m";
65const C_ERR: &str = "\x1b[31m";
66const DIM: &str = "\x1b[2m";
67const BOLD: &str = "\x1b[1m";
68const RESET: &str = "\x1b[0m";
69
70#[derive(Clone, Copy)]
71enum Status {
72    Ok,
73    Warn,
74    Err,
75}
76
77struct Check {
78    name: String,
79    status: Status,
80    detail: String,
81}
82
83impl Check {
84    fn ok(name: impl Into<String>, detail: impl Into<String>) -> Self {
85        Self {
86            name: name.into(),
87            status: Status::Ok,
88            detail: detail.into(),
89        }
90    }
91    fn warn(name: impl Into<String>, detail: impl Into<String>) -> Self {
92        Self {
93            name: name.into(),
94            status: Status::Warn,
95            detail: detail.into(),
96        }
97    }
98    fn err(name: impl Into<String>, detail: impl Into<String>) -> Self {
99        Self {
100            name: name.into(),
101            status: Status::Err,
102            detail: detail.into(),
103        }
104    }
105}
106
107#[derive(Default)]
108struct Report {
109    ok: usize,
110    warn: usize,
111    err: usize,
112}
113
114impl Report {
115    fn add_section<F: FnOnce() -> Vec<Check>>(&mut self, name: &str, body: F) {
116        let pb = section_spinner(name);
117        let checks = body();
118        let (n_ok, n_warn, n_err) = tally(&checks);
119        let summary = format!(
120            "{n_ok}✓  {n_warn}⚠  {n_err}✗",
121            n_ok = n_ok,
122            n_warn = n_warn,
123            n_err = n_err,
124        );
125        pb.finish_and_clear();
126
127        // Section header (bold)
128        println!("{BOLD}{name}{RESET}  {DIM}{summary}{RESET}");
129
130        // Compute aligned width of names (clamped so very long entries
131        // don't push detail off-screen).
132        let name_w = checks
133            .iter()
134            .map(|c| visible_width(&c.name))
135            .max()
136            .unwrap_or(0)
137            .min(40);
138
139        for c in &checks {
140            let (glyph, col) = match c.status {
141                Status::Ok => ("✓", C_OK),
142                Status::Warn => ("⚠", C_WARN),
143                Status::Err => ("✗", C_ERR),
144            };
145            let pad = name_w.saturating_sub(visible_width(&c.name));
146            let detail = if c.detail.is_empty() {
147                String::new()
148            } else {
149                format!("  {DIM}{}{RESET}", c.detail)
150            };
151            println!(
152                "  {col}{glyph}{RESET}  {name}{pad}{detail}",
153                name = c.name,
154                pad = " ".repeat(pad),
155            );
156        }
157        println!();
158
159        self.ok += n_ok;
160        self.warn += n_warn;
161        self.err += n_err;
162    }
163
164    fn has_errors(&self) -> bool {
165        self.err > 0
166    }
167
168    fn print_summary(&self) {
169        let total = self.ok + self.warn + self.err;
170        match (self.err, self.warn) {
171            (0, 0) => println!("{C_OK}{BOLD}all {total} checks passed{RESET}"),
172            (0, w) => println!(
173                "{total} checks: {C_OK}{}✓{RESET}  {C_WARN}{w}⚠{RESET}",
174                self.ok
175            ),
176            (e, w) => println!(
177                "{total} checks: {C_OK}{}✓{RESET}  {C_WARN}{w}⚠{RESET}  {C_ERR}{e}✗{RESET}",
178                self.ok
179            ),
180        }
181    }
182}
183
184fn tally(checks: &[Check]) -> (usize, usize, usize) {
185    let (mut o, mut w, mut e) = (0, 0, 0);
186    for c in checks {
187        match c.status {
188            Status::Ok => o += 1,
189            Status::Warn => w += 1,
190            Status::Err => e += 1,
191        }
192    }
193    (o, w, e)
194}
195
196fn section_spinner(name: &str) -> ProgressBar {
197    let pb = ProgressBar::new_spinner();
198    pb.set_style(
199        ProgressStyle::with_template("{spinner:.cyan}  {msg}")
200            .unwrap()
201            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
202    );
203    pb.set_message(format!("Probing {name} …"));
204    pb.enable_steady_tick(Duration::from_millis(80));
205    pb
206}
207
208/// Visible width of a string ignoring ANSI escapes. Good enough for
209/// our short ASCII labels — no full Unicode width tables required.
210fn visible_width(s: &str) -> usize {
211    let mut w = 0;
212    let mut in_esc = false;
213    for c in s.chars() {
214        if c == '\x1b' {
215            in_esc = true;
216            continue;
217        }
218        if in_esc {
219            if c.is_ascii_alphabetic() {
220                in_esc = false;
221            }
222            continue;
223        }
224        w += 1;
225    }
226    w
227}
228
229// ----- Rust toolchain --------------------------------------------------------
230
231fn check_rust() -> Vec<Check> {
232    let mut out = Vec::new();
233
234    match run_capture("rustc", &["--version"]) {
235        Ok(s) => {
236            let line = s.lines().next().unwrap_or("").trim().to_string();
237            if let Some(v) = parse_rustc_version(&line) {
238                if v >= (1, 85) {
239                    out.push(Check::ok("rustc", line));
240                } else {
241                    out.push(Check::err(
242                        "rustc",
243                        format!("{line} — Whisker requires 1.85+"),
244                    ));
245                }
246            } else {
247                out.push(Check::warn("rustc", line));
248            }
249        }
250        Err(_) => out.push(Check::err("rustc", "not on PATH")),
251    }
252
253    match run_capture("cargo", &["--version"]) {
254        Ok(s) => out.push(Check::ok(
255            "cargo",
256            s.lines().next().unwrap_or("").trim().to_string(),
257        )),
258        Err(_) => out.push(Check::err("cargo", "not on PATH")),
259    }
260
261    let installed = run_capture("rustup", &["target", "list", "--installed"]).unwrap_or_default();
262    let installed: Vec<&str> = installed.lines().map(str::trim).collect();
263    for triple in &[
264        "aarch64-linux-android",
265        "aarch64-apple-ios",
266        "aarch64-apple-ios-sim",
267        "x86_64-apple-ios",
268    ] {
269        if installed.iter().any(|t| t == triple) {
270            out.push(Check::ok(format!("rustup target {triple}"), "installed"));
271        } else {
272            out.push(Check::warn(
273                format!("rustup target {triple}"),
274                format!("missing — `rustup target add {triple}`"),
275            ));
276        }
277    }
278
279    out
280}
281
282fn parse_rustc_version(s: &str) -> Option<(u32, u32)> {
283    let rest = s.strip_prefix("rustc ")?;
284    let v = rest.split_whitespace().next()?;
285    let mut it = v.split('.');
286    let major: u32 = it.next()?.parse().ok()?;
287    let minor: u32 = it.next()?.parse().ok()?;
288    Some((major, minor))
289}
290
291// ----- Android ---------------------------------------------------------------
292
293fn check_android() -> Vec<Check> {
294    let mut out = Vec::new();
295
296    let android_home = std::env::var_os("ANDROID_HOME")
297        .or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
298        .map(PathBuf::from);
299    let android_home = match android_home {
300        Some(p) if p.is_dir() => {
301            out.push(Check::ok("ANDROID_HOME", p.display().to_string()));
302            p
303        }
304        Some(p) => {
305            out.push(Check::err(
306                "ANDROID_HOME",
307                format!("{} does not exist", p.display()),
308            ));
309            return out;
310        }
311        None => {
312            out.push(Check::err(
313                "ANDROID_HOME",
314                "not set (`export ANDROID_HOME=$HOME/Library/Android/sdk`)",
315            ));
316            return out;
317        }
318    };
319
320    // NDK 21.1.6352462 — pinned because Lynx's gn/ninja toolchain
321    // requires that exact version (build_lynx_aar bails otherwise).
322    let ndk = android_home.join("ndk/21.1.6352462");
323    if ndk.is_dir() {
324        out.push(Check::ok("NDK 21.1.6352462", ndk.display().to_string()));
325    } else {
326        out.push(Check::err(
327            "NDK 21.1.6352462",
328            "missing — `sdkmanager 'ndk;21.1.6352462'`",
329        ));
330    }
331
332    // JDK 11 — Lynx's gradle wrapper (6.7.1) refuses anything newer.
333    match resolve_jdk11() {
334        Some(p) => out.push(Check::ok("JDK 11", p.display().to_string())),
335        None => out.push(Check::warn(
336            "JDK 11",
337            "not found (set WHISKER_JAVA11_HOME) — required for Lynx AAR build only",
338        )),
339    }
340
341    // adb — required for `whisker run android` / install workflows.
342    match which("adb").or_else(|| {
343        let cand = android_home.join("platform-tools/adb");
344        cand.is_file().then_some(cand)
345    }) {
346        Some(p) => out.push(Check::ok("adb", p.display().to_string())),
347        None => out.push(Check::warn(
348            "adb",
349            "not on PATH (add $ANDROID_HOME/platform-tools)",
350        )),
351    }
352
353    out
354}
355
356fn resolve_jdk11() -> Option<PathBuf> {
357    if let Some(p) = std::env::var_os("WHISKER_JAVA11_HOME").map(PathBuf::from) {
358        if p.is_dir() {
359            return Some(p);
360        }
361    }
362    let home = std::env::var_os("HOME").map(PathBuf::from)?;
363    [
364        home.join("work/java11/jdk-11.0.25+9/Contents/Home"),
365        home.join("work/java11/jdk-11.0.25+9"),
366        PathBuf::from("/Library/Java/JavaVirtualMachines/temurin-11.jdk/Contents/Home"),
367    ]
368    .into_iter()
369    .find(|cand| cand.is_dir())
370}
371
372// ----- iOS -------------------------------------------------------------------
373
374fn check_ios() -> Vec<Check> {
375    let mut out = Vec::new();
376    if !cfg!(target_os = "macos") {
377        out.push(Check::warn(
378            "host OS",
379            "iOS builds require macOS — skipping",
380        ));
381        return out;
382    }
383
384    match run_capture("xcode-select", &["-p"]) {
385        Ok(s) => out.push(Check::ok("Xcode", s.trim().to_string())),
386        Err(_) => out.push(Check::err(
387            "Xcode",
388            "command-line tools not configured — `xcode-select --install`",
389        )),
390    }
391
392    match run_capture("xcrun", &["simctl", "help"]) {
393        Ok(_) => out.push(Check::ok("xcrun simctl", "available")),
394        Err(_) => out.push(Check::err(
395            "xcrun simctl",
396            "not available — required for Simulator launches",
397        )),
398    }
399
400    out
401}
402
403// ----- Tiny helpers ----------------------------------------------------------
404
405fn run_capture(cmd: &str, args: &[&str]) -> Result<String> {
406    let out = Command::new(cmd).args(args).output()?;
407    if !out.status.success() {
408        anyhow::bail!("{cmd} exited {}", out.status);
409    }
410    Ok(String::from_utf8_lossy(&out.stdout).to_string())
411}
412
413fn which(cmd: &str) -> Option<PathBuf> {
414    let path = std::env::var_os("PATH")?;
415    for dir in std::env::split_paths(&path) {
416        let cand = dir.join(cmd);
417        if cand.is_file() {
418            return Some(cand);
419        }
420    }
421    None
422}
423
424// =============================================================================
425// Tests
426// =============================================================================
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    // ----- parse_rustc_version ------------------------------------------------
433
434    #[test]
435    fn parse_rustc_version_extracts_major_minor() {
436        assert_eq!(
437            parse_rustc_version("rustc 1.91.0 (f8297e351 2025-10-28)"),
438            Some((1, 91)),
439        );
440    }
441
442    #[test]
443    fn parse_rustc_version_handles_no_metadata() {
444        assert_eq!(parse_rustc_version("rustc 1.85.2"), Some((1, 85)));
445    }
446
447    #[test]
448    fn parse_rustc_version_handles_pre_release_channel() {
449        // Real-world nightly: "rustc 1.93.0-nightly (abc 2026-01-01)"
450        assert_eq!(
451            parse_rustc_version("rustc 1.93.0-nightly (abcdef 2026-01-01)"),
452            Some((1, 93)),
453        );
454    }
455
456    #[test]
457    fn parse_rustc_version_rejects_garbage() {
458        assert_eq!(parse_rustc_version(""), None);
459        assert_eq!(parse_rustc_version("cargo 1.91.0"), None);
460        assert_eq!(parse_rustc_version("rustc not-a-version"), None);
461        assert_eq!(parse_rustc_version("rustc 1"), None);
462    }
463
464    // ----- visible_width ------------------------------------------------------
465
466    #[test]
467    fn visible_width_counts_plain_ascii() {
468        assert_eq!(visible_width(""), 0);
469        assert_eq!(visible_width("hello"), 5);
470    }
471
472    #[test]
473    fn visible_width_ignores_ansi_color_escapes() {
474        // "\x1b[32m✓\x1b[0m" should report 1 visible char (✓).
475        assert_eq!(visible_width("\x1b[32m✓\x1b[0m"), 1);
476        // Mixed: "  ✓  hello" -> 10 visible chars.
477        assert_eq!(visible_width("  \x1b[32m✓\x1b[0m  hello"), 10);
478    }
479
480    #[test]
481    fn visible_width_ignores_long_ansi_sequences() {
482        // 38;5;n colour selector
483        assert_eq!(visible_width("\x1b[38;5;208mhi\x1b[0m"), 2);
484    }
485
486    // ----- Check / Status constructors ----------------------------------------
487
488    #[test]
489    fn check_constructors_set_status() {
490        assert!(matches!(Check::ok("n", "d").status, Status::Ok));
491        assert!(matches!(Check::warn("n", "d").status, Status::Warn));
492        assert!(matches!(Check::err("n", "d").status, Status::Err));
493    }
494
495    #[test]
496    fn check_constructors_store_strings() {
497        let c = Check::ok("rustc", "1.91.0");
498        assert_eq!(c.name, "rustc");
499        assert_eq!(c.detail, "1.91.0");
500    }
501
502    // ----- tally --------------------------------------------------------------
503
504    #[test]
505    fn tally_counts_each_status_bucket() {
506        let checks = vec![
507            Check::ok("a", ""),
508            Check::ok("b", ""),
509            Check::warn("c", ""),
510            Check::err("d", ""),
511            Check::err("e", ""),
512            Check::err("f", ""),
513        ];
514        assert_eq!(tally(&checks), (2, 1, 3));
515    }
516
517    #[test]
518    fn tally_of_empty_is_all_zero() {
519        assert_eq!(tally(&[]), (0, 0, 0));
520    }
521
522    // ----- Report::has_errors -------------------------------------------------
523
524    #[test]
525    fn report_has_errors_only_when_err_nonzero() {
526        let mut r = Report::default();
527        assert!(!r.has_errors());
528        r.warn = 5;
529        assert!(!r.has_errors(), "warnings alone don't constitute errors");
530        r.err = 1;
531        assert!(r.has_errors());
532    }
533}