1use anyhow::{bail, Result};
2use fission_command_core::Target;
3use std::env;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10enum Status {
11 Ok,
12 Warn,
13 Error,
14}
15
16#[derive(Debug)]
17struct Check {
18 area: &'static str,
19 name: String,
20 status: Status,
21 detail: String,
22 suggestion: Option<String>,
23}
24
25impl Check {
26 fn ok(area: &'static str, name: impl Into<String>, detail: impl Into<String>) -> Self {
27 Self {
28 area,
29 name: name.into(),
30 status: Status::Ok,
31 detail: detail.into(),
32 suggestion: None,
33 }
34 }
35
36 fn warn(
37 area: &'static str,
38 name: impl Into<String>,
39 detail: impl Into<String>,
40 suggestion: impl Into<String>,
41 ) -> Self {
42 Self {
43 area,
44 name: name.into(),
45 status: Status::Warn,
46 detail: detail.into(),
47 suggestion: Some(suggestion.into()),
48 }
49 }
50
51 fn error(
52 area: &'static str,
53 name: impl Into<String>,
54 detail: impl Into<String>,
55 suggestion: impl Into<String>,
56 ) -> Self {
57 Self {
58 area,
59 name: name.into(),
60 status: Status::Error,
61 detail: detail.into(),
62 suggestion: Some(suggestion.into()),
63 }
64 }
65}
66
67pub fn run_doctor(project_dir: &Path, targets: &[Target], strict: bool) -> Result<()> {
68 let targets = normalized_targets(targets);
69 let mut checks = Vec::new();
70
71 checks.extend(check_project(project_dir, &targets));
72 checks.extend(check_rust_targets(&targets));
73
74 if targets.contains(&Target::Web) {
75 checks.extend(check_web());
76 }
77 if targets.contains(&Target::Android) {
78 checks.extend(check_android());
79 }
80 if targets.contains(&Target::Ios) {
81 checks.extend(check_ios());
82 }
83
84 print_checks(project_dir, &targets, &checks);
85
86 if strict && checks.iter().any(|check| check.status == Status::Error) {
87 bail!("fission doctor found required toolchain errors");
88 }
89
90 Ok(())
91}
92
93fn normalized_targets(targets: &[Target]) -> Vec<Target> {
94 if targets.is_empty() {
95 return vec![Target::Web, Target::Ios, Target::Android];
96 }
97
98 targets.iter().copied().collect()
99}
100
101fn check_project(project_dir: &Path, targets: &[Target]) -> Vec<Check> {
102 let mut checks = Vec::new();
103 let fission_toml = project_dir.join("fission.toml");
104 if fission_toml.exists() {
105 checks.push(Check::ok(
106 "project",
107 "fission.toml",
108 format!("found {}", fission_toml.display()),
109 ));
110 } else {
111 checks.push(Check::warn(
112 "project",
113 "fission.toml",
114 format!("{} does not exist", fission_toml.display()),
115 "run doctor from a Fission app root, or pass --project-dir <path>",
116 ));
117 }
118
119 for target in targets {
120 let path = project_dir.join(target.scaffold_relative_path());
121 if path.exists() {
122 checks.push(Check::ok(
123 "project",
124 format!("{} scaffold", target.as_str()),
125 format!("found {}", path.display()),
126 ));
127 } else {
128 checks.push(Check::warn(
129 "project",
130 format!("{} scaffold", target.as_str()),
131 format!("{} does not exist", path.display()),
132 format!(
133 "run `fission add-target {} --project-dir {}`",
134 target.as_str(),
135 project_dir.display()
136 ),
137 ));
138 }
139 }
140
141 checks
142}
143
144fn check_rust_targets(targets: &[Target]) -> Vec<Check> {
145 let mut checks = Vec::new();
146 let Some(rustup) = find_in_path("rustup") else {
147 checks.push(Check::error(
148 "rust",
149 "rustup",
150 "rustup is not on PATH",
151 "install Rust from https://rustup.rs/ and restart your shell",
152 ));
153 return checks;
154 };
155
156 checks.push(Check::ok(
157 "rust",
158 "rustup",
159 format!("found {}", rustup.display()),
160 ));
161
162 let installed = command_stdout(&rustup, ["target", "list", "--installed"])
163 .unwrap_or_else(|_| String::new());
164 for rust_target in required_rust_targets(targets) {
165 if installed.lines().any(|line| line.trim() == rust_target) {
166 checks.push(Check::ok(
167 "rust",
168 rust_target,
169 "target is installed".to_string(),
170 ));
171 } else {
172 checks.push(Check::error(
173 "rust",
174 rust_target,
175 "target is not installed".to_string(),
176 format!("run `rustup target add {}`", rust_target),
177 ));
178 }
179 }
180
181 checks
182}
183
184fn required_rust_targets(targets: &[Target]) -> Vec<&'static str> {
185 let mut required = Vec::new();
186 if targets.contains(&Target::Web) {
187 required.push("wasm32-unknown-unknown");
188 }
189 if targets.contains(&Target::Android) {
190 required.push("aarch64-linux-android");
191 }
192 if targets.contains(&Target::Ios) {
193 required.push("aarch64-apple-ios");
194 required.push("aarch64-apple-ios-sim");
195 }
196 required
197}
198
199fn check_web() -> Vec<Check> {
200 let mut checks = Vec::new();
201 check_command(
202 &mut checks,
203 "web",
204 "wasm-pack",
205 "install with `cargo install wasm-pack`",
206 );
207 check_command(
208 &mut checks,
209 "web",
210 "python3",
211 "install Python 3 so generated web scripts can serve the app locally",
212 );
213 check_node_websocket(&mut checks);
214
215 match detect_chrome() {
216 Some(path) => checks.push(Check::ok(
217 "web",
218 "Chrome/Chromium",
219 format!("found {}", path.display()),
220 )),
221 None => checks.push(Check::warn(
222 "web",
223 "Chrome/Chromium",
224 "no Chrome-compatible browser was detected for headless CDP smoke tests",
225 "install Chrome/Chromium, or set FISSION_CHROME=/path/to/chrome",
226 )),
227 }
228
229 checks
230}
231
232fn check_node_websocket(checks: &mut Vec<Check>) {
233 let Some(node) = find_in_path("node") else {
234 checks.push(Check::error(
235 "web",
236 "Node.js",
237 "node is not on PATH",
238 "install Node 22+ so generated browser smoke tests can inspect Chrome CDP console/runtime errors",
239 ));
240 return;
241 };
242
243 let supports_websocket = Command::new(&node)
244 .args([
245 "-e",
246 "process.exit(typeof WebSocket === 'function' ? 0 : 1)",
247 ])
248 .status()
249 .map(|status| status.success())
250 .unwrap_or(false);
251 if supports_websocket {
252 checks.push(Check::ok(
253 "web",
254 "Node.js WebSocket",
255 format!("found {}", node.display()),
256 ));
257 } else {
258 checks.push(Check::error(
259 "web",
260 "Node.js WebSocket",
261 format!(
262 "{} does not expose the built-in WebSocket client",
263 node.display()
264 ),
265 "install Node 22+ for Chrome CDP smoke tests",
266 ));
267 }
268}
269
270fn check_android() -> Vec<Check> {
271 let mut checks = Vec::new();
272 let android_home = detect_android_home();
273 if android_home.is_dir() {
274 checks.push(Check::ok(
275 "android",
276 "ANDROID_HOME",
277 format!("using {}", android_home.display()),
278 ));
279 } else {
280 checks.push(Check::error(
281 "android",
282 "ANDROID_HOME",
283 format!("{} does not exist", android_home.display()),
284 "install Android Studio/SDK, or set ANDROID_HOME to your SDK path",
285 ));
286 return checks;
287 }
288
289 check_file(
290 &mut checks,
291 "android",
292 "adb",
293 android_home.join("platform-tools/adb"),
294 "install Android platform-tools with sdkmanager",
295 );
296 check_file(
297 &mut checks,
298 "android",
299 "emulator",
300 android_home.join("emulator/emulator"),
301 "install the Android emulator package with sdkmanager",
302 );
303 check_file(
304 &mut checks,
305 "android",
306 "sdkmanager",
307 android_home.join("cmdline-tools/latest/bin/sdkmanager"),
308 "install Android SDK command-line tools",
309 );
310 check_file(
311 &mut checks,
312 "android",
313 "avdmanager",
314 android_home.join("cmdline-tools/latest/bin/avdmanager"),
315 "install Android SDK command-line tools",
316 );
317
318 let platforms_dir = android_home.join("platforms");
319 match latest_android_api(&platforms_dir) {
320 Some(api) => checks.push(Check::ok(
321 "android",
322 "Android platform",
323 format!("latest installed API level is {}", api),
324 )),
325 None => checks.push(Check::error(
326 "android",
327 "Android platform",
328 format!("no platforms were found under {}", platforms_dir.display()),
329 "install one with `sdkmanager \"platforms;android-35\"` or newer",
330 )),
331 }
332
333 match latest_android_system_image_api(&android_home) {
334 Some(api) => checks.push(Check::ok(
335 "android",
336 "Android emulator image",
337 format!("latest installed google_apis arm64 image is API {}", api),
338 )),
339 None => checks.push(Check::warn(
340 "android",
341 "Android emulator image",
342 format!(
343 "no google_apis arm64 emulator image found under {}",
344 android_home.join("system-images").display()
345 ),
346 "install one with `sdkmanager \"system-images;android-35;google_apis;arm64-v8a\"` or set ANDROID_SYSTEM_IMAGE",
347 )),
348 }
349
350 let build_tools_dir = android_home.join("build-tools");
351 match latest_child_dir(&build_tools_dir) {
352 Some(path) => checks.push(Check::ok(
353 "android",
354 "build-tools",
355 format!("using {}", path.display()),
356 )),
357 None => checks.push(Check::error(
358 "android",
359 "build-tools",
360 format!(
361 "no build-tools were found under {}",
362 build_tools_dir.display()
363 ),
364 "install build-tools with `sdkmanager \"build-tools;35.0.0\"` or newer",
365 )),
366 }
367
368 let ndk = detect_android_ndk(&android_home);
369 if ndk.is_dir() {
370 checks.push(Check::ok(
371 "android",
372 "Android NDK",
373 format!("using {}", ndk.display()),
374 ));
375 } else {
376 checks.push(Check::error(
377 "android",
378 "Android NDK",
379 format!("{} does not exist", ndk.display()),
380 "install the NDK with sdkmanager, or set ANDROID_NDK to an installed NDK path",
381 ));
382 return checks;
383 }
384
385 match detect_android_toolchain(&ndk) {
386 Some(toolchain) => {
387 checks.push(Check::ok(
388 "android",
389 "NDK LLVM toolchain",
390 format!("using {}", toolchain.display()),
391 ));
392 let min_api = env::var("ANDROID_MIN_API_LEVEL").unwrap_or_else(|_| "24".to_string());
393 let clang = toolchain.join(format!("aarch64-linux-android{}-clang", min_api));
394 if clang.exists() {
395 checks.push(Check::ok(
396 "android",
397 "aarch64 clang",
398 format!("found {}", clang.display()),
399 ));
400 } else {
401 checks.push(Check::error(
402 "android",
403 "aarch64 clang",
404 format!("{} does not exist", clang.display()),
405 "set ANDROID_MIN_API_LEVEL to an API supported by the installed NDK",
406 ));
407 }
408 }
409 None => checks.push(Check::error(
410 "android",
411 "NDK LLVM toolchain",
412 format!(
413 "no prebuilt LLVM toolchain found under {}",
414 ndk.join("toolchains/llvm/prebuilt").display()
415 ),
416 "set ANDROID_TOOLCHAIN to the NDK LLVM bin directory",
417 )),
418 }
419
420 checks
421}
422
423fn check_ios() -> Vec<Check> {
424 let mut checks = Vec::new();
425 if !cfg!(target_os = "macos") {
426 checks.push(Check::error(
427 "ios",
428 "macOS host",
429 "iOS builds require macOS with Xcode",
430 "run iOS packaging on macOS",
431 ));
432 return checks;
433 }
434
435 check_command(
436 &mut checks,
437 "ios",
438 "xcrun",
439 "install Xcode and run `xcode-select --install` if command-line tools are missing",
440 );
441
442 match command_stdout("xcrun", ["--sdk", "iphonesimulator", "--show-sdk-path"]) {
443 Ok(path) if !path.trim().is_empty() => checks.push(Check::ok(
444 "ios",
445 "iPhoneSimulator SDK",
446 path.trim().to_string(),
447 )),
448 _ => checks.push(Check::error(
449 "ios",
450 "iPhoneSimulator SDK",
451 "xcrun could not resolve the iPhoneSimulator SDK",
452 "install Xcode and open it once to finish component installation",
453 )),
454 }
455
456 match command_stdout("xcrun", ["simctl", "list", "devices", "available", "-j"]) {
457 Ok(output) if output.contains("iPhone") => checks.push(Check::ok(
458 "ios",
459 "iPhone simulator",
460 "at least one available iPhone simulator is installed",
461 )),
462 Ok(_) => checks.push(Check::error(
463 "ios",
464 "iPhone simulator",
465 "simctl did not report any available iPhone simulator",
466 "install an iOS simulator runtime from Xcode Settings > Platforms",
467 )),
468 Err(_) => checks.push(Check::error(
469 "ios",
470 "simctl",
471 "xcrun simctl is not available",
472 "install Xcode and ensure xcrun is on PATH",
473 )),
474 }
475
476 checks
477}
478
479fn check_command(checks: &mut Vec<Check>, area: &'static str, name: &str, suggestion: &str) {
480 match find_in_path(name) {
481 Some(path) => checks.push(Check::ok(area, name, format!("found {}", path.display()))),
482 None => checks.push(Check::error(
483 area,
484 name,
485 format!("{} is not on PATH", name),
486 suggestion,
487 )),
488 }
489}
490
491fn check_file(
492 checks: &mut Vec<Check>,
493 area: &'static str,
494 name: &str,
495 path: PathBuf,
496 suggestion: &str,
497) {
498 if path.exists() {
499 checks.push(Check::ok(area, name, format!("found {}", path.display())));
500 } else {
501 checks.push(Check::error(
502 area,
503 name,
504 format!("{} does not exist", path.display()),
505 suggestion,
506 ));
507 }
508}
509
510fn print_checks(project_dir: &Path, targets: &[Target], checks: &[Check]) {
511 println!("Fission doctor");
512 println!("Project: {}", project_dir.display());
513 println!(
514 "Targets: {}",
515 targets
516 .iter()
517 .map(|target| target.as_str())
518 .collect::<Vec<_>>()
519 .join(", ")
520 );
521 println!();
522
523 for check in checks {
524 let marker = match check.status {
525 Status::Ok => "[ok]",
526 Status::Warn => "[warn]",
527 Status::Error => "[error]",
528 };
529 println!(
530 "{} {} - {}: {}",
531 marker, check.area, check.name, check.detail
532 );
533 if let Some(suggestion) = &check.suggestion {
534 println!(" suggestion: {}", suggestion);
535 }
536 }
537
538 let ok = checks
539 .iter()
540 .filter(|check| check.status == Status::Ok)
541 .count();
542 let warn = checks
543 .iter()
544 .filter(|check| check.status == Status::Warn)
545 .count();
546 let error = checks
547 .iter()
548 .filter(|check| check.status == Status::Error)
549 .count();
550 println!();
551 println!("Summary: {} ok, {} warnings, {} errors", ok, warn, error);
552}
553
554fn command_stdout<I, S>(program: impl AsRef<OsStr>, args: I) -> Result<String>
555where
556 I: IntoIterator<Item = S>,
557 S: AsRef<OsStr>,
558{
559 let output = Command::new(program).args(args).output()?;
560 if !output.status.success() {
561 bail!("command exited with {}", output.status);
562 }
563 Ok(String::from_utf8_lossy(&output.stdout).to_string())
564}
565
566fn find_in_path(name: &str) -> Option<PathBuf> {
567 let explicit = Path::new(name);
568 if explicit.components().count() > 1 && explicit.exists() {
569 return Some(explicit.to_path_buf());
570 }
571
572 let path = env::var_os("PATH")?;
573 for dir in env::split_paths(&path) {
574 let candidate = dir.join(name);
575 if candidate.exists() {
576 return Some(candidate);
577 }
578 #[cfg(windows)]
579 {
580 let candidate = dir.join(format!("{}.exe", name));
581 if candidate.exists() {
582 return Some(candidate);
583 }
584 }
585 }
586 None
587}
588
589fn detect_chrome() -> Option<PathBuf> {
590 for var in ["FISSION_CHROME", "CHROME", "CHROME_BIN"] {
591 if let Ok(value) = env::var(var) {
592 let path = PathBuf::from(value);
593 if path.exists() {
594 return Some(path);
595 }
596 }
597 }
598
599 for name in ["google-chrome", "chromium", "chromium-browser", "chrome"] {
600 if let Some(path) = find_in_path(name) {
601 return Some(path);
602 }
603 }
604
605 for path in [
606 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
607 "/Applications/Chromium.app/Contents/MacOS/Chromium",
608 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
609 ] {
610 let path = PathBuf::from(path);
611 if path.exists() {
612 return Some(path);
613 }
614 }
615
616 None
617}
618
619fn detect_android_home() -> PathBuf {
620 if let Ok(value) = env::var("ANDROID_HOME").or_else(|_| env::var("ANDROID_SDK_ROOT")) {
621 return PathBuf::from(value);
622 }
623 default_android_home()
624}
625
626fn default_android_home() -> PathBuf {
627 let home = env::var_os("HOME")
628 .or_else(|| env::var_os("USERPROFILE"))
629 .map(PathBuf::from)
630 .unwrap_or_else(|| PathBuf::from("."));
631
632 if cfg!(target_os = "macos") {
633 home.join("Library/Android/sdk")
634 } else if cfg!(target_os = "windows") {
635 env::var_os("LOCALAPPDATA")
636 .map(PathBuf::from)
637 .unwrap_or(home)
638 .join("Android/Sdk")
639 } else {
640 home.join("Android/Sdk")
641 }
642}
643
644fn detect_android_ndk(android_home: &Path) -> PathBuf {
645 if let Ok(value) = env::var("ANDROID_NDK").or_else(|_| env::var("ANDROID_NDK_HOME")) {
646 return PathBuf::from(value);
647 }
648 latest_child_dir(&android_home.join("ndk")).unwrap_or_else(|| android_home.join("ndk"))
649}
650
651fn detect_android_toolchain(ndk: &Path) -> Option<PathBuf> {
652 if let Ok(value) = env::var("ANDROID_TOOLCHAIN") {
653 let path = PathBuf::from(value);
654 if path.exists() {
655 return Some(path);
656 }
657 }
658
659 let prebuilt = ndk.join("toolchains/llvm/prebuilt");
660 for host in android_host_candidates() {
661 let path = prebuilt.join(host).join("bin");
662 if path.exists() {
663 return Some(path);
664 }
665 }
666
667 latest_child_dir(&prebuilt).map(|path| path.join("bin"))
668}
669
670fn android_host_candidates() -> Vec<&'static str> {
671 if cfg!(target_os = "macos") {
672 vec!["darwin-aarch64", "darwin-x86_64"]
673 } else if cfg!(target_os = "windows") {
674 vec!["windows-x86_64"]
675 } else {
676 vec!["linux-x86_64"]
677 }
678}
679
680fn latest_android_api(platforms_dir: &Path) -> Option<u32> {
681 let mut apis = Vec::new();
682 for entry in fs::read_dir(platforms_dir).ok()? {
683 let entry = entry.ok()?;
684 let name = entry.file_name();
685 let name = name.to_string_lossy();
686 if let Some(value) = name.strip_prefix("android-") {
687 if let Ok(api) = value.parse::<u32>() {
688 apis.push(api);
689 }
690 }
691 }
692 apis.into_iter().max()
693}
694
695fn latest_android_system_image_api(android_home: &Path) -> Option<u32> {
696 let root = android_home.join("system-images");
697 let mut apis = Vec::new();
698 for entry in fs::read_dir(&root).ok()? {
699 let entry = entry.ok()?;
700 let name = entry.file_name();
701 let name = name.to_string_lossy();
702 let Some(value) = name.strip_prefix("android-") else {
703 continue;
704 };
705 let Ok(api) = value.parse::<u32>() else {
706 continue;
707 };
708 if entry.path().join("google_apis/arm64-v8a").is_dir() {
709 apis.push(api);
710 }
711 }
712 apis.into_iter().max()
713}
714
715fn latest_child_dir(path: &Path) -> Option<PathBuf> {
716 let mut dirs = fs::read_dir(path)
717 .ok()?
718 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
719 .filter(|path| path.is_dir())
720 .collect::<Vec<_>>();
721 dirs.sort_by(|left, right| {
722 version_key(left)
723 .cmp(&version_key(right))
724 .then_with(|| left.cmp(right))
725 });
726 dirs.pop()
727}
728
729fn version_key(path: &Path) -> Vec<u32> {
730 path.file_name()
731 .and_then(|name| name.to_str())
732 .unwrap_or_default()
733 .split(|ch: char| !ch.is_ascii_digit())
734 .filter(|part| !part.is_empty())
735 .filter_map(|part| part.parse::<u32>().ok())
736 .collect()
737}