Skip to main content

waterui_cli/toolchain/
doctor.rs

1//! Toolchain diagnostics for the `water doctor` command.
2//!
3//! [`doctor`] runs every check against an explicit [`Host`], so the report is
4//! fully determined by that host's environment, PATH, and filesystem — never
5//! by ambient process state. Each [`DoctorItem`] carries a stable
6//! machine-readable `id` (`DoctorItem::id`) for `--json` output and tests.
7
8use std::borrow::Cow;
9use std::future::Future;
10use std::pin::Pin;
11
12use crate::{
13    android::{
14        device::AndroidDevice,
15        platform::AndroidPlatform,
16        toolchain::{
17            AndroidBuildTools, AndroidNdk, AndroidPlatformTools, AndroidRustTargets, AndroidSdk,
18            AndroidSdkPlatforms, Java, Kotlin,
19        },
20    },
21    apple::{
22        device::AppleSimulator,
23        toolchain::{AppleSdk, Xcode},
24    },
25    device::Device,
26    gtk4::toolchain::Gtk4Toolchain,
27    toolchain::{
28        Host, Installation, Toolchain, ToolchainError, UnfixableToolchain,
29        cmake::Cmake,
30        linux::LinuxSystemToolchain,
31        rust::RustToolchain,
32        sccache::Sccache,
33        web::{PackageManagerToolchain, Wasm32UnknownUnknownTarget, WasmPack},
34        windows_arm64_llvm::WindowsArm64LlvmToolchain,
35    },
36};
37use eyre;
38use serde::{Deserialize, Serialize};
39
40/// Status of a toolchain check.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CheckStatus {
43    /// Toolchain is available and working.
44    Ok,
45    /// Toolchain is missing or misconfigured.
46    Missing,
47    /// Toolchain check was skipped (e.g., not applicable on this platform).
48    Skipped,
49}
50
51impl CheckStatus {
52    /// The stable `snake_case` label emitted in `--json` records.
53    #[must_use]
54    pub const fn as_str(&self) -> &'static str {
55        match self {
56            Self::Ok => "ok",
57            Self::Missing => "missing",
58            Self::Skipped => "skipped",
59        }
60    }
61}
62
63/// A boxed async function that performs an installation.
64pub type BoxedInstallFn =
65    Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send>> + Send>;
66
67/// A single item in the doctor report.
68pub struct DoctorItem {
69    /// Stable machine-readable identifier (e.g. `android-sdk`).
70    pub id: &'static str,
71    /// Human-readable name of the toolchain or component.
72    pub name: &'static str,
73    /// Status of the check.
74    pub status: CheckStatus,
75    /// Optional message with details or suggestions.
76    pub message: Option<String>,
77    /// Optional installation function if the issue can be fixed automatically.
78    pub install_fn: Option<BoxedInstallFn>,
79}
80
81impl std::fmt::Debug for DoctorItem {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("DoctorItem")
84            .field("id", &self.id)
85            .field("name", &self.name)
86            .field("status", &self.status)
87            .field("message", &self.message)
88            .field("install_fn", &self.install_fn.as_ref().map(|_| "..."))
89            .finish()
90    }
91}
92
93/// The JSON record emitted for each [`DoctorItem`] by `water doctor --json`.
94///
95/// Lives in the library (not the shell) so integration tests deserialize the
96/// binary's stdout with the same schema the command serializes. Fields are
97/// `Cow` so serialization borrows the static strings while deserialization
98/// (the `--json` smoke test) produces owned values.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct DoctorItemRecord {
101    /// Record discriminator, like the shell's other typed records.
102    #[serde(rename = "type")]
103    pub ty: Cow<'static, str>,
104    /// Stable machine-readable item identifier.
105    pub id: Cow<'static, str>,
106    /// Human-readable item name.
107    pub name: Cow<'static, str>,
108    /// `ok`, `missing`, or `skipped`.
109    pub status: Cow<'static, str>,
110    /// Whether `--fix` can remediate the item automatically.
111    pub fixable: bool,
112    /// Detail or suggestion shown to the user, when present.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub message: Option<String>,
115}
116
117impl From<&DoctorItem> for DoctorItemRecord {
118    fn from(item: &DoctorItem) -> Self {
119        Self {
120            ty: Cow::Borrowed("doctor-item"),
121            id: Cow::Borrowed(item.id),
122            name: Cow::Borrowed(item.name),
123            status: Cow::Borrowed(item.status.as_str()),
124            fixable: item.is_fixable(),
125            message: item.message.clone(),
126        }
127    }
128}
129
130impl DoctorItem {
131    const fn ok(id: &'static str, name: &'static str) -> Self {
132        Self {
133            id,
134            name,
135            status: CheckStatus::Ok,
136            message: None,
137            install_fn: None,
138        }
139    }
140
141    fn missing(id: &'static str, name: &'static str, message: impl Into<String>) -> Self {
142        Self {
143            id,
144            name,
145            status: CheckStatus::Missing,
146            message: Some(message.into()),
147            install_fn: None,
148        }
149    }
150
151    fn fixable<I: Installation + Send + 'static>(
152        id: &'static str,
153        name: &'static str,
154        message: impl Into<String>,
155        installation: I,
156        host: &Host,
157    ) -> Self {
158        let host = host.clone();
159        Self {
160            id,
161            name,
162            status: CheckStatus::Missing,
163            message: Some(message.into()),
164            install_fn: Some(Box::new(move || {
165                Box::pin(async move { installation.install(&host).await.map_err(Into::into) })
166            })),
167        }
168    }
169
170    const fn skipped(id: &'static str, name: &'static str) -> Self {
171        Self {
172            id,
173            name,
174            status: CheckStatus::Skipped,
175            message: None,
176            install_fn: None,
177        }
178    }
179
180    fn skipped_with_message(
181        id: &'static str,
182        name: &'static str,
183        message: impl Into<String>,
184    ) -> Self {
185        Self {
186            id,
187            name,
188            status: CheckStatus::Skipped,
189            message: Some(message.into()),
190            install_fn: None,
191        }
192    }
193
194    /// Returns `true` if the issue can be fixed automatically.
195    #[must_use]
196    pub const fn is_fixable(&self) -> bool {
197        self.install_fn.is_some()
198    }
199}
200
201/// Stable identifiers for every item in the doctor report.
202///
203/// These are the contract asserted by `water doctor --json` consumers and the
204/// integration test; renaming one is a breaking change to that stream.
205pub mod ids {
206    /// `xcodebuild`/`xcode-select` presence.
207    pub const XCODE: &str = "xcode";
208    /// iOS device SDK via `xcrun --sdk iphoneos`.
209    pub const IOS_SDK: &str = "ios-sdk";
210    /// iOS simulator SDK via `xcrun --sdk iphonesimulator`.
211    pub const IOS_SIMULATOR_SDK: &str = "ios-simulator-sdk";
212    /// At least one iOS simulator runtime/device.
213    pub const IOS_SIMULATORS: &str = "ios-simulators";
214    /// macOS SDK via `xcrun --sdk macosx`.
215    pub const MACOS_SDK: &str = "macos-sdk";
216    /// rustup-managed Rust toolchain, version floor, and host target.
217    pub const RUST: &str = "rust";
218    /// Android SDK root + `sdkmanager`.
219    pub const ANDROID_SDK: &str = "android-sdk";
220    /// `platform-tools` (`adb`).
221    pub const ANDROID_PLATFORM_TOOLS: &str = "android-platform-tools";
222    /// `platforms;android-*` packages (`android.jar`).
223    pub const ANDROID_SDK_PLATFORMS: &str = "android-sdk-platforms";
224    /// `build-tools;*` packages (`d8`).
225    pub const ANDROID_BUILD_TOOLS: &str = "android-build-tools";
226    /// Android NDK + host clang.
227    pub const ANDROID_NDK: &str = "android-ndk";
228    /// rustup Android targets for the configured ABIs.
229    pub const ANDROID_RUST_TARGETS: &str = "android-rust-targets";
230    /// A connected device or an emulator AVD to run on.
231    pub const ANDROID_RUN_TARGETS: &str = "android-run-targets";
232    /// Host `cmake`.
233    pub const CMAKE: &str = "cmake";
234    /// LLVM `clang-cl`/`llvm-lib` for Windows ARM64 assembly deps.
235    pub const WINDOWS_ARM64_LLVM: &str = "windows-arm64-llvm";
236    /// Java runtime for Gradle.
237    pub const JAVA: &str = "java";
238    /// `kotlinc` compiler.
239    pub const KOTLIN: &str = "kotlin";
240    /// `wasm32-unknown-unknown` rustup target.
241    pub const WASM32_TARGET: &str = "wasm32-target";
242    /// `wasm-pack` binary.
243    pub const WASM_PACK: &str = "wasm-pack";
244    /// Distribution packages the Linux backends build against.
245    pub const LINUX_SYSTEM_PACKAGES: &str = "linux-system-packages";
246    /// GTK4/pango pkg-config probes.
247    pub const GTK4: &str = "gtk4";
248    /// `sccache` compile cache.
249    pub const SCCACHE: &str = "sccache";
250    /// The `[web] package_manager` the current project's `Water.toml` declares.
251    pub const WEB_PACKAGE_MANAGER: &str = "web-package-manager";
252
253    /// Every doctor item id in emission order.
254    ///
255    /// This is the single source of truth for the report's identity set:
256    /// [`crate::toolchain::doctor::doctor`], the lib-level ordering test, and
257    /// the `water doctor --json` integration test all assert against it.
258    pub const ALL: &[&str] = &[
259        XCODE,
260        IOS_SDK,
261        IOS_SIMULATOR_SDK,
262        IOS_SIMULATORS,
263        MACOS_SDK,
264        RUST,
265        ANDROID_SDK,
266        ANDROID_PLATFORM_TOOLS,
267        ANDROID_SDK_PLATFORMS,
268        ANDROID_BUILD_TOOLS,
269        ANDROID_NDK,
270        ANDROID_RUST_TARGETS,
271        ANDROID_RUN_TARGETS,
272        CMAKE,
273        WINDOWS_ARM64_LLVM,
274        JAVA,
275        KOTLIN,
276        WASM32_TARGET,
277        WASM_PACK,
278        LINUX_SYSTEM_PACKAGES,
279        GTK4,
280        SCCACHE,
281        WEB_PACKAGE_MANAGER,
282    ];
283}
284
285fn unfixable_message(error: &UnfixableToolchain) -> String {
286    format!(
287        "Cannot auto-fix: {}. Next step: {}",
288        error.message(),
289        error.suggestion()
290    )
291}
292
293async fn push_toolchain_check<T>(
294    host: &Host,
295    items: &mut Vec<DoctorItem>,
296    id: &'static str,
297    name: &'static str,
298    fixable_message: &'static str,
299    toolchain: T,
300) where
301    T: Toolchain,
302    T::Installation: Send + 'static,
303{
304    match toolchain.check(host).await {
305        Ok(()) => items.push(DoctorItem::ok(id, name)),
306        Err(ToolchainError::Fixable(installation)) => {
307            items.push(DoctorItem::fixable(
308                id,
309                name,
310                fixable_message,
311                installation,
312                host,
313            ));
314        }
315        Err(ToolchainError::Unfixable(error)) => {
316            items.push(DoctorItem::missing(id, name, unfixable_message(&error)));
317        }
318    }
319}
320
321async fn push_toolchain_check_with_unfixable<T, F>(
322    host: &Host,
323    items: &mut Vec<DoctorItem>,
324    id: &'static str,
325    name: &'static str,
326    fixable_message: &'static str,
327    toolchain: T,
328    unfixable_message_fn: F,
329) where
330    T: Toolchain,
331    T::Installation: Send + 'static,
332    F: FnOnce(&UnfixableToolchain) -> String,
333{
334    match toolchain.check(host).await {
335        Ok(()) => items.push(DoctorItem::ok(id, name)),
336        Err(ToolchainError::Fixable(installation)) => {
337            items.push(DoctorItem::fixable(
338                id,
339                name,
340                fixable_message,
341                installation,
342                host,
343            ));
344        }
345        Err(ToolchainError::Unfixable(error)) => {
346            items.push(DoctorItem::missing(id, name, unfixable_message_fn(&error)));
347        }
348    }
349}
350
351async fn push_apple_checks(host: &Host, items: &mut Vec<DoctorItem>) {
352    if !cfg!(target_os = "macos") {
353        items.push(DoctorItem::skipped(ids::XCODE, "Xcode"));
354        items.push(DoctorItem::skipped(ids::IOS_SDK, "iOS SDK"));
355        items.push(DoctorItem::skipped(
356            ids::IOS_SIMULATOR_SDK,
357            "iOS Simulator SDK",
358        ));
359        items.push(DoctorItem::skipped(ids::IOS_SIMULATORS, "iOS Simulators"));
360        items.push(DoctorItem::skipped(ids::MACOS_SDK, "macOS SDK"));
361        return;
362    }
363
364    push_simple_check(items, ids::XCODE, "Xcode", Xcode.check(host).await);
365    push_simple_check(
366        items,
367        ids::IOS_SDK,
368        "iOS SDK",
369        AppleSdk::Ios.check(host).await,
370    );
371    push_simple_check(
372        items,
373        ids::IOS_SIMULATOR_SDK,
374        "iOS Simulator SDK",
375        AppleSdk::IosSimulator.check(host).await,
376    );
377    push_ios_simulator_check(host, items).await;
378    push_simple_check(
379        items,
380        ids::MACOS_SDK,
381        "macOS SDK",
382        AppleSdk::Macos.check(host).await,
383    );
384}
385
386fn push_simple_check(
387    items: &mut Vec<DoctorItem>,
388    id: &'static str,
389    name: &'static str,
390    result: Result<(), impl std::fmt::Display>,
391) {
392    match result {
393        Ok(()) => items.push(DoctorItem::ok(id, name)),
394        Err(error) => items.push(DoctorItem::missing(id, name, error.to_string())),
395    }
396}
397
398async fn push_ios_simulator_check(host: &Host, items: &mut Vec<DoctorItem>) {
399    match AppleSimulator::scan_ios(host).await {
400        Ok(simulators) if simulators.is_empty() => items.push(DoctorItem::missing(
401            ids::IOS_SIMULATORS,
402            "iOS Simulators",
403            "No iOS simulators available. Install a simulator runtime in Xcode Settings > Platforms.",
404        )),
405        Ok(_) => items.push(DoctorItem::ok(ids::IOS_SIMULATORS, "iOS Simulators")),
406        Err(error) => items.push(DoctorItem::missing(
407            ids::IOS_SIMULATORS,
408            "iOS Simulators",
409            format!("Failed to list iOS simulators: {error}"),
410        )),
411    }
412}
413
414async fn push_android_sdk_checks(host: &Host, items: &mut Vec<DoctorItem>) -> bool {
415    push_toolchain_check(
416        host,
417        items,
418        ids::ANDROID_SDK,
419        "Android SDK",
420        "Android SDK is missing (automatic install is supported on this host)",
421        AndroidSdk,
422    )
423    .await;
424
425    AndroidSdk::sdkmanager_path(host).await.is_some()
426}
427
428async fn push_android_component_checks(host: &Host, items: &mut Vec<DoctorItem>, sdk_ready: bool) {
429    if !sdk_ready {
430        push_blocked_android_component_checks(items);
431        return;
432    }
433
434    push_toolchain_check(
435        host,
436        items,
437        ids::ANDROID_PLATFORM_TOOLS,
438        "Android Platform-Tools (adb)",
439        "Required for `water run --platform android`",
440        AndroidPlatformTools,
441    )
442    .await;
443    push_toolchain_check(
444        host,
445        items,
446        ids::ANDROID_SDK_PLATFORMS,
447        "Android SDK Platforms",
448        "Required for Android build/package workflows",
449        AndroidSdkPlatforms,
450    )
451    .await;
452    push_toolchain_check(
453        host,
454        items,
455        ids::ANDROID_BUILD_TOOLS,
456        "Android SDK Build-Tools (d8)",
457        "Required for Android build/package workflows",
458        AndroidBuildTools,
459    )
460    .await;
461    push_toolchain_check(
462        host,
463        items,
464        ids::ANDROID_NDK,
465        "Android NDK",
466        "Required for Android build/package workflows",
467        AndroidNdk,
468    )
469    .await;
470    push_toolchain_check(
471        host,
472        items,
473        ids::ANDROID_RUST_TARGETS,
474        "Android Rust Targets",
475        "Required for Android Rust cross-compilation",
476        AndroidRustTargets::default(),
477    )
478    .await;
479}
480
481/// Items emitted when the SDK is missing, in the same order as the probed
482/// branch above so `--json` ordering does not depend on the diagnosis path.
483fn push_blocked_android_component_checks(items: &mut Vec<DoctorItem>) {
484    for (id, name) in [
485        (ids::ANDROID_PLATFORM_TOOLS, "Android Platform-Tools (adb)"),
486        (ids::ANDROID_SDK_PLATFORMS, "Android SDK Platforms"),
487        (ids::ANDROID_BUILD_TOOLS, "Android SDK Build-Tools (d8)"),
488        (ids::ANDROID_NDK, "Android NDK"),
489        (ids::ANDROID_RUST_TARGETS, "Android Rust Targets"),
490    ] {
491        items.push(DoctorItem::missing(
492            id,
493            name,
494            "Blocked: Android SDK / `sdkmanager` is not ready yet. Fix Android SDK first.",
495        ));
496    }
497}
498
499async fn push_android_run_target_check(host: &Host, items: &mut Vec<DoctorItem>) {
500    if AndroidSdk::adb_path(host).is_none() {
501        items.push(DoctorItem::missing(
502            ids::ANDROID_RUN_TARGETS,
503            "Android Run Targets",
504            "Blocked: Android Platform-Tools (`adb`) is not ready yet.",
505        ));
506        return;
507    }
508
509    match AndroidDevice::scan(host).await {
510        Ok(devices) if !devices.is_empty() => {
511            items.push(DoctorItem::ok(ids::ANDROID_RUN_TARGETS, "Android Run Targets"));
512        }
513        Ok(_) => match AndroidPlatform::list_avds(host).await {
514            Ok(avds) if !avds.is_empty() => {
515                items.push(DoctorItem::ok(ids::ANDROID_RUN_TARGETS, "Android Run Targets"));
516            }
517            Ok(_) => items.push(DoctorItem::missing(
518                ids::ANDROID_RUN_TARGETS,
519                "Android Run Targets",
520                "No connected Android devices and no emulator AVDs were found. Connect a device or create an AVD.",
521            )),
522            Err(error) => items.push(DoctorItem::missing(
523                ids::ANDROID_RUN_TARGETS,
524                "Android Run Targets",
525                format!(
526                    "No connected Android devices and failed to list AVDs: {error}. Install Android emulator components or connect a device."
527                ),
528            )),
529        },
530        Err(error) => items.push(DoctorItem::missing(
531            ids::ANDROID_RUN_TARGETS,
532            "Android Run Targets",
533            format!("Failed to query Android devices via adb: {error}"),
534        )),
535    }
536}
537
538async fn push_desktop_and_web_checks(host: &Host, items: &mut Vec<DoctorItem>) {
539    push_toolchain_check(
540        host,
541        items,
542        ids::CMAKE,
543        "Host CMake",
544        "Required for native Rust dependencies in Android builds",
545        Cmake::default(),
546    )
547    .await;
548
549    if WindowsArm64LlvmToolchain::required_on_host() {
550        push_toolchain_check(
551            host,
552            items,
553            ids::WINDOWS_ARM64_LLVM,
554            "Windows ARM64 LLVM toolchain",
555            "Required by native assembly dependencies in Windows ARM64 hydrolysis builds",
556            WindowsArm64LlvmToolchain,
557        )
558        .await;
559    } else {
560        items.push(DoctorItem::skipped_with_message(
561            ids::WINDOWS_ARM64_LLVM,
562            "Windows ARM64 LLVM toolchain",
563            "Only required on Windows ARM64 hosts for native assembly dependencies.",
564        ));
565    }
566
567    push_toolchain_check(
568        host,
569        items,
570        ids::JAVA,
571        "Java",
572        "Required for Android Gradle builds",
573        Java,
574    )
575    .await;
576    push_toolchain_check(
577        host,
578        items,
579        ids::KOTLIN,
580        "Kotlin",
581        "Required for Android Kotlin helper compilation",
582        Kotlin,
583    )
584    .await;
585    push_toolchain_check_with_unfixable(
586        host,
587        items,
588        ids::WASM32_TARGET,
589        "Rust wasm32 target",
590        "wasm32-unknown-unknown target not installed",
591        Wasm32UnknownUnknownTarget,
592        ToString::to_string,
593    )
594    .await;
595    push_toolchain_check_with_unfixable(
596        host,
597        items,
598        ids::WASM_PACK,
599        "wasm-pack",
600        "wasm-pack not found (required for web packaging)",
601        WasmPack,
602        ToString::to_string,
603    )
604    .await;
605}
606
607async fn push_linux_checks(host: &Host, items: &mut Vec<DoctorItem>) {
608    if !cfg!(target_os = "linux") {
609        items.push(DoctorItem::skipped(
610            ids::LINUX_SYSTEM_PACKAGES,
611            "Linux system packages",
612        ));
613        items.push(DoctorItem::skipped(ids::GTK4, "GTK4"));
614        return;
615    }
616
617    let linux_packages_fixable = match LinuxSystemToolchain.check(host).await {
618        Ok(()) => {
619            items.push(DoctorItem::ok(
620                ids::LINUX_SYSTEM_PACKAGES,
621                "Linux system packages",
622            ));
623            false
624        }
625        Err(ToolchainError::Fixable(installation)) => {
626            let msg = format!(
627                "Missing packages for {}: {}. Install command: {}",
628                installation.package_manager_name(),
629                installation.missing_packages().join(", "),
630                installation.install_command_hint(),
631            );
632            items.push(DoctorItem::fixable(
633                ids::LINUX_SYSTEM_PACKAGES,
634                "Linux system packages",
635                msg,
636                installation,
637                host,
638            ));
639            true
640        }
641        Err(ToolchainError::Unfixable(error)) => {
642            items.push(DoctorItem::missing(
643                ids::LINUX_SYSTEM_PACKAGES,
644                "Linux system packages",
645                unfixable_message(&error),
646            ));
647            false
648        }
649    };
650
651    match Gtk4Toolchain.check(host).await {
652        Ok(()) => items.push(DoctorItem::ok(ids::GTK4, "GTK4")),
653        Err(ToolchainError::Fixable(installation)) => {
654            items.push(DoctorItem::fixable(
655                ids::GTK4,
656                "GTK4",
657                "GTK4 dependencies are missing",
658                installation,
659                host,
660            ));
661        }
662        Err(ToolchainError::Unfixable(error)) => {
663            if linux_packages_fixable {
664                items.push(DoctorItem::missing(
665                    ids::GTK4,
666                    "GTK4",
667                    "GTK4 probe failed because required Linux packages are missing. Run `water doctor --fix` to install Linux system packages, then re-run `water doctor`.",
668                ));
669            } else {
670                items.push(DoctorItem::missing(
671                    ids::GTK4,
672                    "GTK4",
673                    unfixable_message(&error),
674                ));
675            }
676        }
677    }
678}
679
680/// Run diagnostics on all toolchains on `host` and return a report.
681///
682/// Item order is fixed and platform branching is driven by `cfg!`, so two runs
683/// on equal hosts produce identical item sequences — the property the
684/// orchestration tests and `--json` consumers rely on.
685pub async fn doctor(host: &Host) -> Vec<DoctorItem> {
686    let mut items = Vec::new();
687    push_apple_checks(host, &mut items).await;
688    push_rust_toolchain_check(host, &mut items).await;
689    let sdk_ready = push_android_sdk_checks(host, &mut items).await;
690    push_android_component_checks(host, &mut items, sdk_ready).await;
691    push_android_run_target_check(host, &mut items).await;
692    push_desktop_and_web_checks(host, &mut items).await;
693    push_linux_checks(host, &mut items).await;
694    push_toolchain_check(
695        host,
696        &mut items,
697        ids::SCCACHE,
698        "sccache",
699        "sccache not found (recommended for faster builds)",
700        Sccache,
701    )
702    .await;
703    push_web_package_manager_check(host, &mut items).await;
704
705    items
706}
707
708/// Checks the `[web] package_manager` the current directory's `Water.toml`
709/// declares. Only the declared manager is probed — a project on `pnpm` is
710/// never reported healthy because `bun` happens to be installed.
711async fn push_web_package_manager_check(host: &Host, items: &mut Vec<DoctorItem>) {
712    let Ok(cwd) = std::env::current_dir() else {
713        return;
714    };
715    let Ok(manifest_text) = smol::fs::read_to_string(cwd.join("Water.toml")).await else {
716        return;
717    };
718    let Ok(manifest) = toml::from_str::<crate::project::Manifest>(&manifest_text) else {
719        return;
720    };
721    let Some(web) = manifest.web else {
722        return;
723    };
724    let package_manager = web.package_manager;
725    let name: &'static str = match package_manager {
726        crate::web::PackageManager::Bun => "bun (web package manager)",
727        crate::web::PackageManager::Pnpm => "pnpm (web package manager)",
728        crate::web::PackageManager::Npm => "npm (web package manager)",
729        crate::web::PackageManager::Yarn => "yarn (web package manager)",
730    };
731    push_toolchain_check(
732        host,
733        items,
734        ids::WEB_PACKAGE_MANAGER,
735        name,
736        package_manager.install_hint(),
737        PackageManagerToolchain(package_manager),
738    )
739    .await;
740}
741
742async fn push_rust_toolchain_check(host: &Host, items: &mut Vec<DoctorItem>) {
743    match RustToolchain.check(host).await {
744        Ok(()) => items.push(DoctorItem::ok(ids::RUST, "Rust toolchain")),
745        Err(ToolchainError::Fixable(installation)) => {
746            items.push(DoctorItem::fixable(
747                ids::RUST,
748                "Rust toolchain",
749                format!(
750                    "Rust toolchain is missing, outdated, or incomplete. Planned automatic fixes: {}",
751                    installation.summary()
752                ),
753                installation,
754                host,
755            ));
756        }
757        Err(ToolchainError::Unfixable(error)) => items.push(DoctorItem::missing(
758            ids::RUST,
759            "Rust toolchain",
760            unfixable_message(&error),
761        )),
762    }
763}
764#[cfg(test)]
765mod tests {
766    use super::{CheckStatus, doctor, ids};
767    use crate::toolchain::testing::TestMachine;
768
769    const ANDROID_COMPONENT_IDS: &[&str] = &[
770        ids::ANDROID_PLATFORM_TOOLS,
771        ids::ANDROID_SDK_PLATFORMS,
772        ids::ANDROID_BUILD_TOOLS,
773        ids::ANDROID_NDK,
774        ids::ANDROID_RUST_TARGETS,
775    ];
776
777    fn ids_of(items: &[super::DoctorItem]) -> Vec<&'static str> {
778        items.iter().map(|item| item.id).collect()
779    }
780
781    fn item<'a>(items: &'a [super::DoctorItem], id: &str) -> &'a super::DoctorItem {
782        items
783            .iter()
784            .find(|item| item.id == id)
785            .unwrap_or_else(|| panic!("doctor report must contain `{id}`"))
786    }
787
788    #[test]
789    fn doctor_emits_every_item_in_stable_order() {
790        let machine = TestMachine::new();
791        let host = machine.host(Vec::<(String, String)>::new());
792        let items = smol::block_on(doctor(&host));
793        // `WEB_PACKAGE_MANAGER` only emits when the current directory's
794        // `Water.toml` declares a `[web]` section; the test CWD has none.
795        let expected: Vec<&'static str> = ids::ALL
796            .iter()
797            .copied()
798            .filter(|id| *id != ids::WEB_PACKAGE_MANAGER)
799            .collect();
800        assert_eq!(ids_of(&items), expected);
801    }
802
803    #[test]
804    fn doctor_blocks_android_components_when_sdk_absent() {
805        let machine = TestMachine::new();
806        let host = machine.host(Vec::<(String, String)>::new());
807        let items = smol::block_on(doctor(&host));
808
809        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Missing);
810        for id in ANDROID_COMPONENT_IDS {
811            let component = item(&items, id);
812            assert_eq!(component.status, CheckStatus::Missing, "{id}");
813            assert!(
814                component
815                    .message
816                    .as_deref()
817                    .is_some_and(|message| message.contains("Blocked")),
818                "{id} must carry the blocked diagnostic: {:?}",
819                component.message
820            );
821            assert!(
822                !component.is_fixable(),
823                "blocked {id} must not offer an install"
824            );
825        }
826
827        let run_targets = item(&items, ids::ANDROID_RUN_TARGETS);
828        assert_eq!(run_targets.status, CheckStatus::Missing);
829        assert!(
830            run_targets
831                .message
832                .as_deref()
833                .is_some_and(|message| message.contains("Blocked"))
834        );
835    }
836
837    #[test]
838    fn doctor_probes_android_components_when_sdk_ready() {
839        let machine = TestMachine::new();
840        let sdk = machine.install_android_sdk();
841        let host = machine.host([(
842            String::from("ANDROID_SDK_ROOT"),
843            sdk.as_os_str().to_os_string(),
844        )]);
845        let items = smol::block_on(doctor(&host));
846
847        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Ok);
848        for id in ANDROID_COMPONENT_IDS {
849            let component = item(&items, id);
850            assert_eq!(component.status, CheckStatus::Missing, "{id}");
851            assert!(
852                !component
853                    .message
854                    .as_deref()
855                    .is_some_and(|message| message.contains("Blocked")),
856                "{id} must be a real diagnosis, not the blocked marker: {:?}",
857                component.message
858            );
859        }
860
861        // adb / platforms / build-tools / NDK are installable via sdkmanager.
862        for id in [
863            ids::ANDROID_PLATFORM_TOOLS,
864            ids::ANDROID_SDK_PLATFORMS,
865            ids::ANDROID_BUILD_TOOLS,
866            ids::ANDROID_NDK,
867        ] {
868            assert!(item(&items, id).is_fixable(), "{id} must be fixable");
869        }
870        // No rustup on the fake PATH → Android Rust targets are unfixable.
871        assert!(!item(&items, ids::ANDROID_RUST_TARGETS).is_fixable());
872    }
873
874    #[test]
875    fn doctor_apple_items_match_platform() {
876        let machine = TestMachine::new();
877        let host = machine.host(Vec::<(String, String)>::new());
878        let items = smol::block_on(doctor(&host));
879        for id in [
880            ids::XCODE,
881            ids::IOS_SDK,
882            ids::IOS_SIMULATOR_SDK,
883            ids::IOS_SIMULATORS,
884            ids::MACOS_SDK,
885        ] {
886            let status = item(&items, id).status;
887            if cfg!(target_os = "macos") {
888                assert_eq!(
889                    status,
890                    CheckStatus::Missing,
891                    "{id} is probed on macOS and missing on a bare host"
892                );
893            } else {
894                assert_eq!(
895                    status,
896                    CheckStatus::Skipped,
897                    "{id} must be skipped off macOS"
898                );
899            }
900        }
901    }
902
903    /// The staged `simctl list devices --json` transcript reports one healthy
904    /// iPhone, so `ios-simulators` comes back `Ok` — the fake `xcrun` must
905    /// answer the query and the transcript's `dataPath` must exist.
906    #[test]
907    #[cfg(target_os = "macos")]
908    fn doctor_ios_simulators_ok_when_simctl_reports_healthy_device() {
909        let machine = TestMachine::new();
910        machine.install("xcrun");
911        // Retarget the transcript's `/fake/...` paths into the scratch root
912        // so `data_path.exists()` holds on the declared host.
913        machine.dir(
914            "Library/Developer/CoreSimulator/Devices/3E8B0C4F-0000-4000-8000-000000000001/data",
915        );
916        let transcript = include_str!("testdata/simctl_devices.json")
917            .replace("/fake/", &format!("{}/", machine.root().display()));
918        machine.respond("XCRUN_SIMCTL_DEVICES", &transcript);
919        let host = machine.host(Vec::<(String, String)>::new());
920        let items = smol::block_on(doctor(&host));
921        assert_eq!(
922            item(&items, ids::IOS_SIMULATORS).status,
923            CheckStatus::Ok,
924            "a healthy simctl device must satisfy ios-simulators"
925        );
926    }
927
928    #[test]
929    fn doctor_linux_items_match_platform() {
930        let machine = TestMachine::new();
931        let host = machine.host(Vec::<(String, String)>::new());
932        let items = smol::block_on(doctor(&host));
933        for id in [ids::LINUX_SYSTEM_PACKAGES, ids::GTK4] {
934            let status = item(&items, id).status;
935            if cfg!(target_os = "linux") {
936                assert_eq!(
937                    status,
938                    CheckStatus::Missing,
939                    "{id} is probed on Linux and missing on a bare host"
940                );
941            } else {
942                assert_eq!(
943                    status,
944                    CheckStatus::Skipped,
945                    "{id} must be skipped off Linux"
946                );
947            }
948        }
949    }
950
951    #[test]
952    fn doctor_windows_llvm_skipped_where_not_required() {
953        let machine = TestMachine::new();
954        let host = machine.host(Vec::<(String, String)>::new());
955        let items = smol::block_on(doctor(&host));
956        let status = item(&items, ids::WINDOWS_ARM64_LLVM).status;
957        if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
958            assert_eq!(status, CheckStatus::Missing);
959        } else {
960            assert_eq!(status, CheckStatus::Skipped);
961        }
962    }
963
964    #[test]
965    fn doctor_fixable_and_manual_classification() {
966        let machine = TestMachine::new();
967        let host = machine.host(Vec::<(String, String)>::new());
968        let items = smol::block_on(doctor(&host));
969
970        // No rust tools at all → manual fix required.
971        let rust = item(&items, ids::RUST);
972        assert_eq!(rust.status, CheckStatus::Missing);
973        assert!(!rust.is_fixable());
974
975        // wasm-pack installs via `cargo install` → always fixable.
976        let wasm_pack = item(&items, ids::WASM_PACK);
977        assert_eq!(wasm_pack.status, CheckStatus::Missing);
978        assert!(wasm_pack.is_fixable());
979
980        // On Linux a bare host still plans an SDK install into ~/Android/Sdk.
981        #[cfg(target_os = "linux")]
982        assert!(item(&items, ids::ANDROID_SDK).is_fixable());
983    }
984
985    #[test]
986    #[cfg(unix)]
987    fn doctor_reports_complete_android_chain_when_fully_staged() {
988        let machine = TestMachine::new();
989        let sdk = machine.install_android_sdk();
990        machine.install_adb();
991        machine.install_android_platform("android-37.0");
992        machine.install_android_build_tools("37.0.0");
993        machine.install_android_ndk("29.0.14206865");
994        machine.install_android_emulator();
995        machine.install("rustup");
996        machine.respond("EMULATOR_AVDS", "Medium_Phone_API_37\n");
997        machine.respond(
998            "RUSTUP_INSTALLED_TARGETS",
999            &[
1000                "aarch64-linux-android",
1001                "armv7-linux-androideabi",
1002                "i686-linux-android",
1003                "x86_64-linux-android",
1004            ]
1005            .join("\n"),
1006        );
1007        let host = machine.host([(
1008            String::from("ANDROID_SDK_ROOT"),
1009            sdk.as_os_str().to_os_string(),
1010        )]);
1011        let items = smol::block_on(doctor(&host));
1012        for id in [
1013            ids::ANDROID_SDK,
1014            ids::ANDROID_PLATFORM_TOOLS,
1015            ids::ANDROID_SDK_PLATFORMS,
1016            ids::ANDROID_BUILD_TOOLS,
1017            ids::ANDROID_NDK,
1018            ids::ANDROID_RUST_TARGETS,
1019            ids::ANDROID_RUN_TARGETS,
1020        ] {
1021            assert_eq!(
1022                item(&items, id).status,
1023                CheckStatus::Ok,
1024                "{id} must be ok on a fully staged SDK: {:?}",
1025                item(&items, id).message
1026            );
1027        }
1028    }
1029}