1use 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 #[arg(long)]
26 pub no_ios: bool,
27 #[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 report.print_summary();
55 if report.has_errors() {
56 std::process::exit(1);
57 }
58 Ok(())
59}
60
61const 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 println!("{BOLD}{name}{RESET} {DIM}{summary}{RESET}");
129
130 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
208fn 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
229fn 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
291fn 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 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 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 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
372fn 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
403fn 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#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[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 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 #[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 assert_eq!(visible_width("\x1b[32m✓\x1b[0m"), 1);
476 assert_eq!(visible_width(" \x1b[32m✓\x1b[0m hello"), 10);
478 }
479
480 #[test]
481 fn visible_width_ignores_long_ansi_sequences() {
482 assert_eq!(visible_width("\x1b[38;5;208mhi\x1b[0m"), 2);
484 }
485
486 #[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 #[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 #[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}