Skip to main content

fission_command_run/
doctor.rs

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}