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::path::Path;
11use std::pin::Pin;
12
13use semver::Version;
14
15use crate::{
16    android::{
17        device::AndroidDevice,
18        platform::AndroidPlatform,
19        toolchain::{
20            AndroidBuildTools, AndroidNdk, AndroidPlatformTools, AndroidRustTargets, AndroidSdk,
21            AndroidSdkPlatforms, Java, Kotlin,
22        },
23    },
24    apple::{
25        device::AppleSimulator,
26        toolchain::{AppleSdk, Xcode},
27    },
28    device::Device,
29    esp32::{chip::Esp32Chip, toolchain::Esp32Toolchain},
30    framework::manifest_rust_version,
31    gtk4::toolchain::Gtk4Toolchain,
32    platform::TargetPlatform,
33    project::{Manifest, PackageType},
34    toolchain::{
35        Host, Installation, Toolchain, ToolchainError, UnfixableToolchain,
36        cargo_helpers::CargoHelpers,
37        cmake::Cmake,
38        linux::LinuxSystemToolchain,
39        rust::{CLI_MINIMUM_RUST_VERSION, RustToolchain},
40        sccache::Sccache,
41        web::{PackageManagerToolchain, WasmPack, wasm32_target},
42        windows_arm64_llvm::WindowsArm64LlvmToolchain,
43    },
44    utils::parse_semver_version,
45    winui::toolchain::WinUiToolchain,
46};
47use serde::{Deserialize, Serialize};
48
49/// Status of a toolchain check.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CheckStatus {
52    /// Toolchain is available and working.
53    Ok,
54    /// Toolchain is missing or misconfigured.
55    Missing,
56    /// Toolchain check was skipped (e.g., not applicable on this platform).
57    Skipped,
58}
59
60impl CheckStatus {
61    /// The stable `snake_case` label emitted in `--json` records.
62    #[must_use]
63    pub const fn as_str(&self) -> &'static str {
64        match self {
65            Self::Ok => "ok",
66            Self::Missing => "missing",
67            Self::Skipped => "skipped",
68        }
69    }
70}
71
72/// A boxed async function that performs an installation.
73pub type BoxedInstallFn =
74    Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send>> + Send>;
75
76/// A single item in the doctor report.
77pub struct DoctorItem {
78    /// Stable machine-readable identifier (e.g. `android-sdk`).
79    pub id: &'static str,
80    /// Human-readable name of the toolchain or component.
81    pub name: &'static str,
82    /// Status of the check.
83    pub status: CheckStatus,
84    /// Optional message with details or suggestions.
85    pub message: Option<String>,
86    /// Optional installation function if the issue can be fixed automatically.
87    pub install_fn: Option<BoxedInstallFn>,
88}
89
90impl std::fmt::Debug for DoctorItem {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("DoctorItem")
93            .field("id", &self.id)
94            .field("name", &self.name)
95            .field("status", &self.status)
96            .field("message", &self.message)
97            .field("install_fn", &self.install_fn.as_ref().map(|_| "..."))
98            .finish()
99    }
100}
101
102/// The JSON record emitted for each [`DoctorItem`] by `water doctor --json`.
103///
104/// Lives in the library (not the shell) so integration tests deserialize the
105/// binary's stdout with the same schema the command serializes. Fields are
106/// `Cow` so serialization borrows the static strings while deserialization
107/// (the `--json` smoke test) produces owned values.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct DoctorItemRecord {
110    /// Record discriminator, like the shell's other typed records.
111    #[serde(rename = "type")]
112    pub ty: Cow<'static, str>,
113    /// Stable machine-readable item identifier.
114    pub id: Cow<'static, str>,
115    /// Human-readable item name.
116    pub name: Cow<'static, str>,
117    /// `ok`, `missing`, or `skipped`.
118    pub status: Cow<'static, str>,
119    /// Whether `--fix` can remediate the item automatically.
120    pub fixable: bool,
121    /// Detail or suggestion shown to the user, when present.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub message: Option<String>,
124}
125
126impl From<&DoctorItem> for DoctorItemRecord {
127    fn from(item: &DoctorItem) -> Self {
128        Self {
129            ty: Cow::Borrowed("doctor-item"),
130            id: Cow::Borrowed(item.id),
131            name: Cow::Borrowed(item.name),
132            status: Cow::Borrowed(item.status.as_str()),
133            fixable: item.is_fixable(),
134            message: item.message.clone(),
135        }
136    }
137}
138
139impl DoctorItem {
140    const fn ok(id: &'static str, name: &'static str) -> Self {
141        Self {
142            id,
143            name,
144            status: CheckStatus::Ok,
145            message: None,
146            install_fn: None,
147        }
148    }
149
150    fn missing(id: &'static str, name: &'static str, message: impl Into<String>) -> Self {
151        Self {
152            id,
153            name,
154            status: CheckStatus::Missing,
155            message: Some(message.into()),
156            install_fn: None,
157        }
158    }
159
160    fn fixable<I: Installation + Send + 'static>(
161        id: &'static str,
162        name: &'static str,
163        message: impl Into<String>,
164        installation: I,
165        host: &Host,
166    ) -> Self {
167        let host = host.clone();
168        Self {
169            id,
170            name,
171            status: CheckStatus::Missing,
172            message: Some(message.into()),
173            install_fn: Some(Box::new(move || {
174                Box::pin(async move { installation.install(&host).await.map_err(Into::into) })
175            })),
176        }
177    }
178
179    const fn skipped(id: &'static str, name: &'static str) -> Self {
180        Self {
181            id,
182            name,
183            status: CheckStatus::Skipped,
184            message: None,
185            install_fn: None,
186        }
187    }
188
189    fn skipped_with_message(
190        id: &'static str,
191        name: &'static str,
192        message: impl Into<String>,
193    ) -> Self {
194        Self {
195            id,
196            name,
197            status: CheckStatus::Skipped,
198            message: Some(message.into()),
199            install_fn: None,
200        }
201    }
202
203    /// Returns `true` if the issue can be fixed automatically.
204    #[must_use]
205    pub const fn is_fixable(&self) -> bool {
206        self.install_fn.is_some()
207    }
208}
209
210/// Stable identifiers for every item in the doctor report.
211///
212/// These are the contract asserted by `water doctor --json` consumers and the
213/// integration test; renaming one is a breaking change to that stream.
214pub mod ids {
215    /// `xcodebuild`/`xcode-select` presence.
216    pub const XCODE: &str = "xcode";
217    /// iOS device SDK via `xcrun --sdk iphoneos`.
218    pub const IOS_SDK: &str = "ios-sdk";
219    /// iOS simulator SDK via `xcrun --sdk iphonesimulator`.
220    pub const IOS_SIMULATOR_SDK: &str = "ios-simulator-sdk";
221    /// At least one iOS simulator runtime/device.
222    pub const IOS_SIMULATORS: &str = "ios-simulators";
223    /// macOS SDK via `xcrun --sdk macosx`.
224    pub const MACOS_SDK: &str = "macos-sdk";
225    /// rustup-managed Rust toolchain, version floor, and host target.
226    pub const RUST: &str = "rust";
227    /// iOS device and simulator rustup targets on the selected toolchain.
228    pub const APPLE_RUST_TARGETS: &str = "apple-rust-targets";
229    /// Android SDK root + `sdkmanager`.
230    pub const ANDROID_SDK: &str = "android-sdk";
231    /// `platform-tools` (`adb`).
232    pub const ANDROID_PLATFORM_TOOLS: &str = "android-platform-tools";
233    /// `platforms;android-*` packages (`android.jar`).
234    pub const ANDROID_SDK_PLATFORMS: &str = "android-sdk-platforms";
235    /// `build-tools;*` packages (`d8`).
236    pub const ANDROID_BUILD_TOOLS: &str = "android-build-tools";
237    /// Android NDK + host clang.
238    pub const ANDROID_NDK: &str = "android-ndk";
239    /// rustup Android targets for the configured ABIs.
240    pub const ANDROID_RUST_TARGETS: &str = "android-rust-targets";
241    /// A connected device or an emulator AVD to run on.
242    pub const ANDROID_RUN_TARGETS: &str = "android-run-targets";
243    /// Host `cmake`.
244    pub const CMAKE: &str = "cmake";
245    /// LLVM `clang-cl`/`llvm-lib` for Windows ARM64 assembly deps.
246    pub const WINDOWS_ARM64_LLVM: &str = "windows-arm64-llvm";
247    /// Java runtime for Gradle.
248    pub const JAVA: &str = "java";
249    /// `kotlinc` compiler.
250    pub const KOTLIN: &str = "kotlin";
251    /// `wasm32-unknown-unknown` rustup target.
252    pub const WASM32_TARGET: &str = "wasm32-target";
253    /// `wasm-pack` binary.
254    pub const WASM_PACK: &str = "wasm-pack";
255    /// The Espressif `esp` toolchain, its clang/GCC/`rust-src` pieces, and the
256    /// `espflash`/`ldproxy` helpers an ESP32 build drives.
257    pub const ESP32_TOOLCHAIN: &str = "esp32-toolchain";
258    /// Cargo-installed helper binaries the CLI's workflows invoke
259    /// (`cargo-nextest` for `water bench`).
260    pub const CARGO_HELPERS: &str = "cargo-helpers";
261    /// Distribution packages the Linux backends build against.
262    pub const LINUX_SYSTEM_PACKAGES: &str = "linux-system-packages";
263    /// GTK4/pango pkg-config probes.
264    pub const GTK4: &str = "gtk4";
265    /// `WinUI` build prerequisites on Windows hosts.
266    pub const WINUI: &str = "winui";
267    /// `sccache` compile cache.
268    pub const SCCACHE: &str = "sccache";
269    /// The `[web] package_manager` the current project's `Water.toml` declares.
270    pub const WEB_PACKAGE_MANAGER: &str = "web-package-manager";
271
272    /// Every doctor item id in emission order.
273    ///
274    /// This is the single source of truth for the report's identity set:
275    /// [`crate::toolchain::doctor::doctor`], the lib-level ordering test, and
276    /// the `water doctor --json` integration test all assert against it.
277    pub const ALL: &[&str] = &[
278        XCODE,
279        IOS_SDK,
280        IOS_SIMULATOR_SDK,
281        IOS_SIMULATORS,
282        MACOS_SDK,
283        RUST,
284        APPLE_RUST_TARGETS,
285        ANDROID_SDK,
286        ANDROID_PLATFORM_TOOLS,
287        ANDROID_SDK_PLATFORMS,
288        ANDROID_BUILD_TOOLS,
289        ANDROID_NDK,
290        ANDROID_RUST_TARGETS,
291        ANDROID_RUN_TARGETS,
292        CMAKE,
293        WINDOWS_ARM64_LLVM,
294        JAVA,
295        KOTLIN,
296        WASM32_TARGET,
297        WASM_PACK,
298        ESP32_TOOLCHAIN,
299        CARGO_HELPERS,
300        LINUX_SYSTEM_PACKAGES,
301        GTK4,
302        WINUI,
303        SCCACHE,
304        WEB_PACKAGE_MANAGER,
305    ];
306}
307
308fn unfixable_message(error: &UnfixableToolchain) -> String {
309    format!(
310        "Cannot auto-fix: {}. Next step: {}",
311        error.message(),
312        error.suggestion()
313    )
314}
315
316/// What `host.cwd()` tells doctor about the surrounding project: the
317/// `Water.toml` manifest when the working directory is a project, and the
318/// Rust floor the `rust` item enforces — the maximum of the CLI's own
319/// `rust-version`, the project's `Cargo.toml` `rust-version`, and the
320/// selected framework's.
321struct ProjectContext {
322    manifest: Option<Manifest>,
323    rust_floor: Version,
324}
325
326impl ProjectContext {
327    /// Whether the project selects a backend — always true for a playground,
328    /// whose platform projects the CLI manages on demand.
329    fn selects(&self, selected: impl Fn(&crate::backend::Backends) -> bool) -> bool {
330        self.manifest.as_ref().is_some_and(|manifest| {
331            manifest.package.package_type == PackageType::Playground || selected(&manifest.backends)
332        })
333    }
334
335    /// The chips the project's ESP32 (Dew) backend can target: the chip
336    /// `[backends.esp32]` declares, or every supported chip for a playground.
337    /// `None` when no project is present or no ESP32 backend is selected.
338    fn esp32_chips(&self) -> Option<eyre::Result<Vec<Esp32Chip>>> {
339        let manifest = self.manifest.as_ref()?;
340        if let Some(backend) = manifest.backends.esp32() {
341            return Some(backend.resolved_chip().map(|chip| vec![chip]));
342        }
343        (manifest.package.package_type == PackageType::Playground).then(|| {
344            Ok(vec![
345                Esp32Chip::Esp32S3,
346                Esp32Chip::Esp32C3,
347                Esp32Chip::Esp32P4,
348            ])
349        })
350    }
351}
352
353/// The `rust-version` a `Cargo.toml` root manifest declares, when it parses.
354async fn cargo_manifest_rust_version(path: &Path) -> Option<Version> {
355    let manifest: toml::Value = toml::from_str(&smol::fs::read_to_string(path).await.ok()?).ok()?;
356    manifest_rust_version(&manifest).ok().flatten()
357}
358
359async fn project_context(host: &Host) -> ProjectContext {
360    let manifest = Manifest::open(host.cwd().join("Water.toml")).await.ok();
361    let mut rust_floor = parse_semver_version(CLI_MINIMUM_RUST_VERSION)
362        .unwrap_or_else(|_| unreachable!("CARGO_PKG_RUST_VERSION is valid semver"));
363    if let Some(manifest) = &manifest {
364        // The project's own `rust-version` and the selected framework's both
365        // raise the floor; a `waterui_path` checkout's root manifest carries
366        // the framework's.
367        if let Some(floor) = cargo_manifest_rust_version(&host.cwd().join("Cargo.toml")).await {
368            rust_floor = rust_floor.max(floor);
369        }
370        let framework_floor = match (&manifest.framework, &manifest.waterui_path) {
371            (Some(framework), _) => framework.rust_version().cloned(),
372            (None, Some(waterui_path)) => {
373                let path = Path::new(waterui_path);
374                let root = if path.is_absolute() {
375                    path.to_path_buf()
376                } else {
377                    host.cwd().join(path)
378                };
379                cargo_manifest_rust_version(&root.join("Cargo.toml")).await
380            }
381            (None, None) => None,
382        };
383        if let Some(floor) = framework_floor {
384            rust_floor = rust_floor.max(floor);
385        }
386    }
387    ProjectContext {
388        manifest,
389        rust_floor,
390    }
391}
392
393async fn push_toolchain_check<T>(
394    host: &Host,
395    items: &mut Vec<DoctorItem>,
396    id: &'static str,
397    name: &'static str,
398    fixable_message: &'static str,
399    toolchain: T,
400) where
401    T: Toolchain,
402    T::Installation: Send + 'static,
403{
404    match toolchain.check(host).await {
405        Ok(()) => items.push(DoctorItem::ok(id, name)),
406        Err(ToolchainError::Fixable(installation)) => {
407            items.push(DoctorItem::fixable(
408                id,
409                name,
410                fixable_message,
411                installation,
412                host,
413            ));
414        }
415        Err(ToolchainError::Unfixable(error)) => {
416            items.push(DoctorItem::missing(id, name, unfixable_message(&error)));
417        }
418    }
419}
420
421async fn push_toolchain_check_with_unfixable<T, F>(
422    host: &Host,
423    items: &mut Vec<DoctorItem>,
424    id: &'static str,
425    name: &'static str,
426    fixable_message: &'static str,
427    toolchain: T,
428    unfixable_message_fn: F,
429) where
430    T: Toolchain,
431    T::Installation: Send + 'static,
432    F: FnOnce(&UnfixableToolchain) -> String,
433{
434    match toolchain.check(host).await {
435        Ok(()) => items.push(DoctorItem::ok(id, name)),
436        Err(ToolchainError::Fixable(installation)) => {
437            items.push(DoctorItem::fixable(
438                id,
439                name,
440                fixable_message,
441                installation,
442                host,
443            ));
444        }
445        Err(ToolchainError::Unfixable(error)) => {
446            items.push(DoctorItem::missing(id, name, unfixable_message_fn(&error)));
447        }
448    }
449}
450
451async fn push_apple_checks(host: &Host, items: &mut Vec<DoctorItem>) {
452    if !cfg!(target_os = "macos") {
453        items.push(DoctorItem::skipped(ids::XCODE, "Xcode"));
454        items.push(DoctorItem::skipped(ids::IOS_SDK, "iOS SDK"));
455        items.push(DoctorItem::skipped(
456            ids::IOS_SIMULATOR_SDK,
457            "iOS Simulator SDK",
458        ));
459        items.push(DoctorItem::skipped(ids::IOS_SIMULATORS, "iOS Simulators"));
460        items.push(DoctorItem::skipped(ids::MACOS_SDK, "macOS SDK"));
461        return;
462    }
463
464    push_simple_check(items, ids::XCODE, "Xcode", Xcode.check(host).await);
465    push_simple_check(
466        items,
467        ids::IOS_SDK,
468        "iOS SDK",
469        AppleSdk::Ios.check(host).await,
470    );
471    push_simple_check(
472        items,
473        ids::IOS_SIMULATOR_SDK,
474        "iOS Simulator SDK",
475        AppleSdk::IosSimulator.check(host).await,
476    );
477    push_ios_simulator_check(host, items).await;
478    push_simple_check(
479        items,
480        ids::MACOS_SDK,
481        "macOS SDK",
482        AppleSdk::Macos.check(host).await,
483    );
484}
485
486fn push_simple_check(
487    items: &mut Vec<DoctorItem>,
488    id: &'static str,
489    name: &'static str,
490    result: Result<(), impl std::fmt::Display>,
491) {
492    match result {
493        Ok(()) => items.push(DoctorItem::ok(id, name)),
494        Err(error) => items.push(DoctorItem::missing(id, name, error.to_string())),
495    }
496}
497
498async fn push_ios_simulator_check(host: &Host, items: &mut Vec<DoctorItem>) {
499    match AppleSimulator::scan_ios(host).await {
500        Ok(simulators) if simulators.is_empty() => items.push(DoctorItem::missing(
501            ids::IOS_SIMULATORS,
502            "iOS Simulators",
503            "No iOS simulators available. Install a simulator runtime in Xcode Settings > Platforms.",
504        )),
505        Ok(_) => items.push(DoctorItem::ok(ids::IOS_SIMULATORS, "iOS Simulators")),
506        Err(error) => items.push(DoctorItem::missing(
507            ids::IOS_SIMULATORS,
508            "iOS Simulators",
509            format!("Failed to list iOS simulators: {error}"),
510        )),
511    }
512}
513
514async fn push_android_sdk_checks(host: &Host, items: &mut Vec<DoctorItem>) -> bool {
515    push_toolchain_check(
516        host,
517        items,
518        ids::ANDROID_SDK,
519        "Android SDK",
520        "Android SDK is missing (automatic install is supported on this host)",
521        AndroidSdk,
522    )
523    .await;
524
525    AndroidSdk::sdkmanager_path(host).await.is_some()
526}
527
528async fn push_android_component_checks(
529    host: &Host,
530    items: &mut Vec<DoctorItem>,
531    sdk_ready: bool,
532    project: &ProjectContext,
533) {
534    if sdk_ready {
535        push_toolchain_check(
536            host,
537            items,
538            ids::ANDROID_PLATFORM_TOOLS,
539            "Android Platform-Tools (adb)",
540            "Required for `water run --platform android`",
541            AndroidPlatformTools,
542        )
543        .await;
544        push_toolchain_check(
545            host,
546            items,
547            ids::ANDROID_SDK_PLATFORMS,
548            "Android SDK Platforms",
549            "Required for Android build/package workflows",
550            AndroidSdkPlatforms,
551        )
552        .await;
553        push_toolchain_check(
554            host,
555            items,
556            ids::ANDROID_BUILD_TOOLS,
557            "Android SDK Build-Tools (d8)",
558            "Required for Android build/package workflows",
559            AndroidBuildTools,
560        )
561        .await;
562        push_toolchain_check(
563            host,
564            items,
565            ids::ANDROID_NDK,
566            "Android NDK",
567            "Required for Android build/package workflows",
568            AndroidNdk,
569        )
570        .await;
571    } else {
572        push_blocked_android_component_checks(items);
573    }
574
575    // The rustup targets only need rustup — they are probed regardless of
576    // SDK state, and only when the project actually builds for Android.
577    if project.selects(|backends| backends.android().is_some()) {
578        push_toolchain_check(
579            host,
580            items,
581            ids::ANDROID_RUST_TARGETS,
582            "Android Rust Targets",
583            "Required for Android Rust cross-compilation",
584            AndroidRustTargets::default(),
585        )
586        .await;
587    } else {
588        items.push(DoctorItem::skipped_with_message(
589            ids::ANDROID_RUST_TARGETS,
590            "Android Rust Targets",
591            "No Android backend is selected in this project's Water.toml.",
592        ));
593    }
594}
595
596/// Items emitted when the SDK is missing, in the same order as the probed
597/// branch above so `--json` ordering does not depend on the diagnosis path.
598/// `android-rust-targets` is deliberately absent: it needs only rustup, so it
599/// is probed (or skipped) independently of the SDK.
600fn push_blocked_android_component_checks(items: &mut Vec<DoctorItem>) {
601    for (id, name) in [
602        (ids::ANDROID_PLATFORM_TOOLS, "Android Platform-Tools (adb)"),
603        (ids::ANDROID_SDK_PLATFORMS, "Android SDK Platforms"),
604        (ids::ANDROID_BUILD_TOOLS, "Android SDK Build-Tools (d8)"),
605        (ids::ANDROID_NDK, "Android NDK"),
606    ] {
607        items.push(DoctorItem::missing(
608            id,
609            name,
610            "Blocked: Android SDK / `sdkmanager` is not ready yet. Fix Android SDK first.",
611        ));
612    }
613}
614
615async fn push_android_run_target_check(host: &Host, items: &mut Vec<DoctorItem>) {
616    if AndroidSdk::adb_path(host).is_none() {
617        items.push(DoctorItem::missing(
618            ids::ANDROID_RUN_TARGETS,
619            "Android Run Targets",
620            "Blocked: Android Platform-Tools (`adb`) is not ready yet.",
621        ));
622        return;
623    }
624
625    match AndroidDevice::scan(host).await {
626        Ok(devices) if !devices.is_empty() => {
627            items.push(DoctorItem::ok(ids::ANDROID_RUN_TARGETS, "Android Run Targets"));
628        }
629        Ok(_) => match AndroidPlatform::list_avds(host).await {
630            Ok(avds) if !avds.is_empty() => {
631                items.push(DoctorItem::ok(ids::ANDROID_RUN_TARGETS, "Android Run Targets"));
632            }
633            Ok(_) => items.push(DoctorItem::missing(
634                ids::ANDROID_RUN_TARGETS,
635                "Android Run Targets",
636                "No connected Android devices and no emulator AVDs were found. Connect a device or create an AVD.",
637            )),
638            Err(error) => items.push(DoctorItem::missing(
639                ids::ANDROID_RUN_TARGETS,
640                "Android Run Targets",
641                format!(
642                    "No connected Android devices and failed to list AVDs: {error}. Install Android emulator components or connect a device."
643                ),
644            )),
645        },
646        Err(error) => items.push(DoctorItem::missing(
647            ids::ANDROID_RUN_TARGETS,
648            "Android Run Targets",
649            format!("Failed to query Android devices via adb: {error}"),
650        )),
651    }
652}
653
654async fn push_desktop_and_web_checks(
655    host: &Host,
656    items: &mut Vec<DoctorItem>,
657    project: &ProjectContext,
658) {
659    push_toolchain_check(
660        host,
661        items,
662        ids::CMAKE,
663        "Host CMake",
664        "Required for native Rust dependencies in Android builds",
665        Cmake::default(),
666    )
667    .await;
668
669    if WindowsArm64LlvmToolchain::required_on_host() {
670        push_toolchain_check(
671            host,
672            items,
673            ids::WINDOWS_ARM64_LLVM,
674            "Windows ARM64 LLVM toolchain",
675            "Required by native assembly dependencies in Windows ARM64 hydrolysis builds",
676            WindowsArm64LlvmToolchain,
677        )
678        .await;
679    } else {
680        items.push(DoctorItem::skipped_with_message(
681            ids::WINDOWS_ARM64_LLVM,
682            "Windows ARM64 LLVM toolchain",
683            "Only required on Windows ARM64 hosts for native assembly dependencies.",
684        ));
685    }
686
687    push_toolchain_check(
688        host,
689        items,
690        ids::JAVA,
691        "Java",
692        "Required for Android Gradle builds",
693        Java,
694    )
695    .await;
696    push_toolchain_check(
697        host,
698        items,
699        ids::KOTLIN,
700        "Kotlin",
701        "Required for Android Kotlin helper compilation",
702        Kotlin,
703    )
704    .await;
705
706    if project.selects(|backends| backends.hydrolysis().is_some()) {
707        push_toolchain_check_with_unfixable(
708            host,
709            items,
710            ids::WASM32_TARGET,
711            "Rust wasm32 target",
712            "wasm32-unknown-unknown target not installed",
713            wasm32_target(),
714            ToString::to_string,
715        )
716        .await;
717        push_toolchain_check_with_unfixable(
718            host,
719            items,
720            ids::WASM_PACK,
721            "wasm-pack",
722            "wasm-pack not found (required for web packaging)",
723            WasmPack,
724            ToString::to_string,
725        )
726        .await;
727    } else {
728        items.push(DoctorItem::skipped_with_message(
729            ids::WASM32_TARGET,
730            "Rust wasm32 target",
731            "No hydrolysis (web) backend is selected in this project's Water.toml.",
732        ));
733        items.push(DoctorItem::skipped_with_message(
734            ids::WASM_PACK,
735            "wasm-pack",
736            "No hydrolysis (web) backend is selected in this project's Water.toml.",
737        ));
738    }
739}
740
741/// The Espressif-side toolchain — `esp` Rust fork, clang/GCC, `rust-src`,
742/// `espflash`/`ldproxy`, QEMU — when the project selects a Dew/ESP32 backend.
743async fn push_esp32_check(host: &Host, items: &mut Vec<DoctorItem>, project: &ProjectContext) {
744    const NAME: &str = "ESP32 toolchain";
745    let Some(chips) = project.esp32_chips() else {
746        items.push(DoctorItem::skipped_with_message(
747            ids::ESP32_TOOLCHAIN,
748            NAME,
749            "No ESP32 backend is selected in this project's Water.toml.",
750        ));
751        return;
752    };
753    let chips = match chips {
754        Ok(chips) => chips,
755        Err(error) => {
756            items.push(DoctorItem::missing(
757                ids::ESP32_TOOLCHAIN,
758                NAME,
759                format!("Invalid `[backends.esp32]` configuration: {error}"),
760            ));
761            return;
762        }
763    };
764    match Esp32Toolchain::new(chips).check(host).await {
765        Ok(()) => items.push(DoctorItem::ok(ids::ESP32_TOOLCHAIN, NAME)),
766        Err(ToolchainError::Fixable(installation)) => items.push(DoctorItem::fixable(
767            ids::ESP32_TOOLCHAIN,
768            NAME,
769            installation.describe(),
770            installation,
771            host,
772        )),
773        Err(ToolchainError::Unfixable(error)) => items.push(DoctorItem::missing(
774            ids::ESP32_TOOLCHAIN,
775            NAME,
776            unfixable_message(&error),
777        )),
778    }
779}
780
781/// The cargo-installed helper binaries a project's workflows invoke —
782/// `cargo-nextest` for `water bench`. Platform helpers that are also cargo
783/// installs (`wasm-pack`, `espflash`/`ldproxy`) are covered by their own
784/// platform items.
785async fn push_cargo_helpers_check(host: &Host, items: &mut Vec<DoctorItem>) {
786    const NAME: &str = "Cargo helpers";
787    match CargoHelpers::new(["cargo-nextest"]).check(host).await {
788        Ok(()) => items.push(DoctorItem::ok(ids::CARGO_HELPERS, NAME)),
789        Err(ToolchainError::Fixable(installation)) => items.push(DoctorItem::fixable(
790            ids::CARGO_HELPERS,
791            NAME,
792            installation.describe(),
793            installation,
794            host,
795        )),
796        Err(ToolchainError::Unfixable(error)) => items.push(DoctorItem::missing(
797            ids::CARGO_HELPERS,
798            NAME,
799            unfixable_message(&error),
800        )),
801    }
802}
803
804async fn push_linux_checks(host: &Host, items: &mut Vec<DoctorItem>) {
805    if !cfg!(target_os = "linux") {
806        items.push(DoctorItem::skipped(
807            ids::LINUX_SYSTEM_PACKAGES,
808            "Linux system packages",
809        ));
810        items.push(DoctorItem::skipped(ids::GTK4, "GTK4"));
811        return;
812    }
813
814    let linux_packages_fixable = match LinuxSystemToolchain.check(host).await {
815        Ok(()) => {
816            items.push(DoctorItem::ok(
817                ids::LINUX_SYSTEM_PACKAGES,
818                "Linux system packages",
819            ));
820            false
821        }
822        Err(ToolchainError::Fixable(installation)) => {
823            let msg = format!(
824                "Missing packages for {}: {}. Install command: {}",
825                installation.package_manager_name(),
826                installation.missing_packages().join(", "),
827                installation.install_command_hint(),
828            );
829            items.push(DoctorItem::fixable(
830                ids::LINUX_SYSTEM_PACKAGES,
831                "Linux system packages",
832                msg,
833                installation,
834                host,
835            ));
836            true
837        }
838        Err(ToolchainError::Unfixable(error)) => {
839            items.push(DoctorItem::missing(
840                ids::LINUX_SYSTEM_PACKAGES,
841                "Linux system packages",
842                unfixable_message(&error),
843            ));
844            false
845        }
846    };
847
848    match Gtk4Toolchain.check(host).await {
849        Ok(()) => items.push(DoctorItem::ok(ids::GTK4, "GTK4")),
850        Err(ToolchainError::Fixable(installation)) => {
851            items.push(DoctorItem::fixable(
852                ids::GTK4,
853                "GTK4",
854                "GTK4 dependencies are missing",
855                installation,
856                host,
857            ));
858        }
859        Err(ToolchainError::Unfixable(error)) => {
860            if linux_packages_fixable {
861                items.push(DoctorItem::missing(
862                    ids::GTK4,
863                    "GTK4",
864                    "GTK4 probe failed because required Linux packages are missing. Run `water doctor --fix` to install Linux system packages, then re-run `water doctor`.",
865                ));
866            } else {
867                items.push(DoctorItem::missing(
868                    ids::GTK4,
869                    "GTK4",
870                    unfixable_message(&error),
871                ));
872            }
873        }
874    }
875}
876
877async fn push_windows_checks(host: &Host, items: &mut Vec<DoctorItem>) {
878    if !cfg!(target_os = "windows") {
879        items.push(DoctorItem::skipped(ids::WINUI, "WinUI"));
880        return;
881    }
882
883    push_toolchain_check(
884        host,
885        items,
886        ids::WINUI,
887        "WinUI",
888        "WinUI build prerequisites are missing",
889        WinUiToolchain,
890    )
891    .await;
892}
893
894/// Run diagnostics on all toolchains on `host` and return a report.
895///
896/// Item order is fixed and platform branching is driven by `cfg!` plus the
897/// project context `host.cwd()` resolves, so two runs on equal hosts in equal
898/// projects produce identical item sequences — the property the
899/// orchestration tests and `--json` consumers rely on. Without a `Water.toml`
900/// the host-level checks still run and every project-gated item reports
901/// `skipped`.
902pub async fn doctor(host: &Host) -> Vec<DoctorItem> {
903    let project = project_context(host).await;
904    let mut items = Vec::new();
905    push_apple_checks(host, &mut items).await;
906    push_rust_toolchain_check(host, &mut items, &project).await;
907    push_apple_rust_targets(host, &mut items, &project).await;
908    let sdk_ready = push_android_sdk_checks(host, &mut items).await;
909    push_android_component_checks(host, &mut items, sdk_ready, &project).await;
910    push_android_run_target_check(host, &mut items).await;
911    push_desktop_and_web_checks(host, &mut items, &project).await;
912    push_esp32_check(host, &mut items, &project).await;
913    push_cargo_helpers_check(host, &mut items).await;
914    push_linux_checks(host, &mut items).await;
915    push_windows_checks(host, &mut items).await;
916    push_toolchain_check(
917        host,
918        &mut items,
919        ids::SCCACHE,
920        "sccache",
921        "sccache not found (recommended for faster builds)",
922        Sccache,
923    )
924    .await;
925    push_web_package_manager_check(host, &mut items, &project).await;
926
927    items
928}
929
930/// The iOS device and simulator rustup targets an Apple-backend project
931/// needs on its selected toolchain. (The macOS target is the host triple the
932/// `rust` item already requires.)
933async fn push_apple_rust_targets(
934    host: &Host,
935    items: &mut Vec<DoctorItem>,
936    project: &ProjectContext,
937) {
938    const NAME: &str = "Apple Rust targets";
939    if !cfg!(target_os = "macos") {
940        items.push(DoctorItem::skipped_with_message(
941            ids::APPLE_RUST_TARGETS,
942            NAME,
943            "Apple platforms can only be built on macOS.",
944        ));
945        return;
946    }
947    if !project.selects(|backends| backends.apple().is_some()) {
948        items.push(DoctorItem::skipped_with_message(
949            ids::APPLE_RUST_TARGETS,
950            NAME,
951            "No Apple backend is selected in this project's Water.toml.",
952        ));
953        return;
954    }
955    push_toolchain_check(
956        host,
957        items,
958        ids::APPLE_RUST_TARGETS,
959        NAME,
960        "Required iOS targets are missing on the selected Rust toolchain",
961        crate::toolchain::rust::SelectedToolchainTargets::new(vec![
962            TargetPlatform::IOS.triple().to_string(),
963            TargetPlatform::IOSSimulator.triple().to_string(),
964        ]),
965    )
966    .await;
967}
968
969/// Checks the `[web] package_manager` the project's `Water.toml` declares.
970/// Only the declared manager is probed — a project on `pnpm` is never
971/// reported healthy because `bun` happens to be installed.
972async fn push_web_package_manager_check(
973    host: &Host,
974    items: &mut Vec<DoctorItem>,
975    project: &ProjectContext,
976) {
977    let Some(web) = project
978        .manifest
979        .as_ref()
980        .and_then(|manifest| manifest.web.as_ref())
981    else {
982        return;
983    };
984    let package_manager = web.package_manager;
985    let name: &'static str = match package_manager {
986        crate::web::PackageManager::Bun => "bun (web package manager)",
987        crate::web::PackageManager::Pnpm => "pnpm (web package manager)",
988        crate::web::PackageManager::Npm => "npm (web package manager)",
989        crate::web::PackageManager::Yarn => "yarn (web package manager)",
990    };
991    push_toolchain_check(
992        host,
993        items,
994        ids::WEB_PACKAGE_MANAGER,
995        name,
996        package_manager.install_hint(),
997        PackageManagerToolchain(package_manager),
998    )
999    .await;
1000}
1001
1002async fn push_rust_toolchain_check(
1003    host: &Host,
1004    items: &mut Vec<DoctorItem>,
1005    project: &ProjectContext,
1006) {
1007    match RustToolchain::new(&project.rust_floor).check(host).await {
1008        Ok(()) => items.push(DoctorItem::ok(ids::RUST, "Rust toolchain")),
1009        Err(ToolchainError::Fixable(installation)) => {
1010            items.push(DoctorItem::fixable(
1011                ids::RUST,
1012                "Rust toolchain",
1013                format!(
1014                    "Rust toolchain is missing, outdated, or incomplete. Planned automatic fixes: {}",
1015                    installation.summary()
1016                ),
1017                installation,
1018                host,
1019            ));
1020        }
1021        Err(ToolchainError::Unfixable(error)) => items.push(DoctorItem::missing(
1022            ids::RUST,
1023            "Rust toolchain",
1024            unfixable_message(&error),
1025        )),
1026    }
1027}
1028#[cfg(test)]
1029mod tests {
1030    use super::{CheckStatus, doctor, ids};
1031    use crate::toolchain::testing::TestMachine;
1032
1033    const ANDROID_COMPONENT_IDS: &[&str] = &[
1034        ids::ANDROID_PLATFORM_TOOLS,
1035        ids::ANDROID_SDK_PLATFORMS,
1036        ids::ANDROID_BUILD_TOOLS,
1037        ids::ANDROID_NDK,
1038    ];
1039
1040    /// A minimal `Water.toml` app manifest; `extra` is appended verbatim
1041    /// (`[backends.*]`, `[web]`, ...).
1042    fn manifest(extra: &str) -> String {
1043        format!(
1044            "[package]\ntype = \"app\"\nname = \"Fixture\"\nbundle_identifier = \"dev.waterui.fixture\"\n\n{extra}"
1045        )
1046    }
1047
1048    fn ids_of(items: &[super::DoctorItem]) -> Vec<&'static str> {
1049        items.iter().map(|item| item.id).collect()
1050    }
1051
1052    fn item<'a>(items: &'a [super::DoctorItem], id: &str) -> &'a super::DoctorItem {
1053        items
1054            .iter()
1055            .find(|item| item.id == id)
1056            .unwrap_or_else(|| panic!("doctor report must contain `{id}`"))
1057    }
1058
1059    #[test]
1060    fn doctor_emits_every_item_in_stable_order() {
1061        let machine = TestMachine::new();
1062        let host = machine.host(Vec::<(String, String)>::new());
1063        let items = smol::block_on(doctor(&host));
1064        // `WEB_PACKAGE_MANAGER` only emits when the current directory's
1065        // `Water.toml` declares a `[web]` section; the test CWD has none.
1066        let expected: Vec<&'static str> = ids::ALL
1067            .iter()
1068            .copied()
1069            .filter(|id| *id != ids::WEB_PACKAGE_MANAGER)
1070            .collect();
1071        assert_eq!(ids_of(&items), expected);
1072    }
1073
1074    #[test]
1075    fn doctor_blocks_android_components_when_sdk_absent() {
1076        let machine = TestMachine::new();
1077        let host = machine.host(Vec::<(String, String)>::new());
1078        let items = smol::block_on(doctor(&host));
1079
1080        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Missing);
1081        for id in ANDROID_COMPONENT_IDS {
1082            let component = item(&items, id);
1083            assert_eq!(component.status, CheckStatus::Missing, "{id}");
1084            assert!(
1085                component
1086                    .message
1087                    .as_deref()
1088                    .is_some_and(|message| message.contains("Blocked")),
1089                "{id} must carry the blocked diagnostic: {:?}",
1090                component.message
1091            );
1092            assert!(
1093                !component.is_fixable(),
1094                "blocked {id} must not offer an install"
1095            );
1096        }
1097
1098        // Without a manifest the Android rust targets are not required, so
1099        // the item is skipped rather than blocked or probed.
1100        assert_eq!(
1101            item(&items, ids::ANDROID_RUST_TARGETS).status,
1102            CheckStatus::Skipped
1103        );
1104
1105        let run_targets = item(&items, ids::ANDROID_RUN_TARGETS);
1106        assert_eq!(run_targets.status, CheckStatus::Missing);
1107        assert!(
1108            run_targets
1109                .message
1110                .as_deref()
1111                .is_some_and(|message| message.contains("Blocked"))
1112        );
1113    }
1114
1115    #[test]
1116    fn doctor_probes_android_components_when_sdk_ready() {
1117        let machine = TestMachine::new();
1118        machine.file("Water.toml", &manifest("[backends.android]\n"));
1119        let sdk = machine.install_android_sdk();
1120        let host = machine.host([(
1121            String::from("ANDROID_SDK_ROOT"),
1122            sdk.as_os_str().to_os_string(),
1123        )]);
1124        let items = smol::block_on(doctor(&host));
1125
1126        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Ok);
1127        for id in ANDROID_COMPONENT_IDS {
1128            let component = item(&items, id);
1129            assert_eq!(component.status, CheckStatus::Missing, "{id}");
1130            assert!(
1131                !component
1132                    .message
1133                    .as_deref()
1134                    .is_some_and(|message| message.contains("Blocked")),
1135                "{id} must be a real diagnosis, not the blocked marker: {:?}",
1136                component.message
1137            );
1138        }
1139
1140        // adb / platforms / build-tools / NDK are installable via sdkmanager.
1141        for id in [
1142            ids::ANDROID_PLATFORM_TOOLS,
1143            ids::ANDROID_SDK_PLATFORMS,
1144            ids::ANDROID_BUILD_TOOLS,
1145            ids::ANDROID_NDK,
1146        ] {
1147            assert!(item(&items, id).is_fixable(), "{id} must be fixable");
1148        }
1149        // The manifest selects the Android backend, so the rustup targets are
1150        // probed; with no rustup on the fake PATH they are unfixable.
1151        let rust_targets = item(&items, ids::ANDROID_RUST_TARGETS);
1152        assert_eq!(rust_targets.status, CheckStatus::Missing);
1153        assert!(!rust_targets.is_fixable());
1154    }
1155
1156    #[test]
1157    fn doctor_apple_items_match_platform() {
1158        let machine = TestMachine::new();
1159        let host = machine.host(Vec::<(String, String)>::new());
1160        let items = smol::block_on(doctor(&host));
1161        for id in [
1162            ids::XCODE,
1163            ids::IOS_SDK,
1164            ids::IOS_SIMULATOR_SDK,
1165            ids::IOS_SIMULATORS,
1166            ids::MACOS_SDK,
1167        ] {
1168            let status = item(&items, id).status;
1169            if cfg!(target_os = "macos") {
1170                assert_eq!(
1171                    status,
1172                    CheckStatus::Missing,
1173                    "{id} is probed on macOS and missing on a bare host"
1174                );
1175            } else {
1176                assert_eq!(
1177                    status,
1178                    CheckStatus::Skipped,
1179                    "{id} must be skipped off macOS"
1180                );
1181            }
1182        }
1183    }
1184
1185    /// The staged `simctl list devices --json` transcript reports one healthy
1186    /// iPhone, so `ios-simulators` comes back `Ok` — the fake `xcrun` must
1187    /// answer the query and the transcript's `dataPath` must exist.
1188    #[test]
1189    #[cfg(target_os = "macos")]
1190    fn doctor_ios_simulators_ok_when_simctl_reports_healthy_device() {
1191        let machine = TestMachine::new();
1192        machine.install("xcrun");
1193        // Retarget the transcript's `/fake/...` paths into the scratch root
1194        // so `data_path.exists()` holds on the declared host.
1195        machine.dir(
1196            "Library/Developer/CoreSimulator/Devices/3E8B0C4F-0000-4000-8000-000000000001/data",
1197        );
1198        let transcript = include_str!("testdata/simctl_devices.json")
1199            .replace("/fake/", &format!("{}/", machine.root().display()));
1200        machine.respond("XCRUN_SIMCTL_DEVICES", &transcript);
1201        let host = machine.host(Vec::<(String, String)>::new());
1202        let items = smol::block_on(doctor(&host));
1203        assert_eq!(
1204            item(&items, ids::IOS_SIMULATORS).status,
1205            CheckStatus::Ok,
1206            "a healthy simctl device must satisfy ios-simulators"
1207        );
1208    }
1209
1210    #[test]
1211    fn doctor_linux_items_match_platform() {
1212        let machine = TestMachine::new();
1213        let host = machine.host(Vec::<(String, String)>::new());
1214        let items = smol::block_on(doctor(&host));
1215        for id in [ids::LINUX_SYSTEM_PACKAGES, ids::GTK4] {
1216            let status = item(&items, id).status;
1217            if cfg!(target_os = "linux") {
1218                assert_eq!(
1219                    status,
1220                    CheckStatus::Missing,
1221                    "{id} is probed on Linux and missing on a bare host"
1222                );
1223            } else {
1224                assert_eq!(
1225                    status,
1226                    CheckStatus::Skipped,
1227                    "{id} must be skipped off Linux"
1228                );
1229            }
1230        }
1231    }
1232
1233    #[test]
1234    fn doctor_windows_llvm_skipped_where_not_required() {
1235        let machine = TestMachine::new();
1236        let host = machine.host(Vec::<(String, String)>::new());
1237        let items = smol::block_on(doctor(&host));
1238        let status = item(&items, ids::WINDOWS_ARM64_LLVM).status;
1239        if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
1240            assert_eq!(status, CheckStatus::Missing);
1241        } else {
1242            assert_eq!(status, CheckStatus::Skipped);
1243        }
1244    }
1245
1246    #[test]
1247    fn doctor_fixable_and_manual_classification() {
1248        let machine = TestMachine::new();
1249        let host = machine.host(Vec::<(String, String)>::new());
1250        let items = smol::block_on(doctor(&host));
1251
1252        // No rust tools at all → manual fix required.
1253        let rust = item(&items, ids::RUST);
1254        assert_eq!(rust.status, CheckStatus::Missing);
1255        assert!(!rust.is_fixable());
1256
1257        // The cargo helpers need `cargo` to install → manual without it.
1258        let cargo_helpers = item(&items, ids::CARGO_HELPERS);
1259        assert_eq!(cargo_helpers.status, CheckStatus::Missing);
1260        assert!(!cargo_helpers.is_fixable());
1261
1262        // On Linux a bare host still plans an SDK install into ~/Android/Sdk.
1263        #[cfg(target_os = "linux")]
1264        assert!(item(&items, ids::ANDROID_SDK).is_fixable());
1265    }
1266
1267    /// With a hydrolysis backend selected and `cargo` on PATH, a missing
1268    /// `wasm-pack` is a `cargo install` away → fixable.
1269    #[test]
1270    fn doctor_wasm_pack_fixable_when_hydrolysis_selected() {
1271        let machine = TestMachine::new();
1272        machine.file("Water.toml", &manifest("[backends.hydrolysis]\n"));
1273        machine.install("cargo");
1274        let host = machine.host(Vec::<(String, String)>::new());
1275        let items = smol::block_on(doctor(&host));
1276
1277        let wasm_pack = item(&items, ids::WASM_PACK);
1278        assert_eq!(wasm_pack.status, CheckStatus::Missing);
1279        assert!(wasm_pack.is_fixable());
1280    }
1281
1282    /// Project-gated items must not report failures for projects that do not
1283    /// select the platform.
1284    #[test]
1285    fn doctor_skips_platform_items_no_project_selects() {
1286        let machine = TestMachine::new();
1287        let host = machine.host(Vec::<(String, String)>::new());
1288        let items = smol::block_on(doctor(&host));
1289
1290        for id in [
1291            ids::ANDROID_RUST_TARGETS,
1292            ids::WASM32_TARGET,
1293            ids::WASM_PACK,
1294            ids::ESP32_TOOLCHAIN,
1295            ids::APPLE_RUST_TARGETS,
1296        ] {
1297            assert_eq!(
1298                item(&items, id).status,
1299                CheckStatus::Skipped,
1300                "{id} must be skipped on a project-less host"
1301            );
1302        }
1303    }
1304
1305    /// Every platform the manifest selects is probed, even on a bare host.
1306    #[test]
1307    fn doctor_probes_the_backends_a_manifest_selects() {
1308        let machine = TestMachine::new();
1309        machine.file(
1310            "Water.toml",
1311            &manifest(
1312                "[backends.android]\n\n[backends.hydrolysis]\n\n[backends.esp32]\nchip = \"esp32c3\"\n\n[backends.apple]\nscheme = \"Fixture\"\n",
1313            ),
1314        );
1315        let host = machine.host(Vec::<(String, String)>::new());
1316        let items = smol::block_on(doctor(&host));
1317
1318        for id in [
1319            ids::ANDROID_RUST_TARGETS,
1320            ids::WASM32_TARGET,
1321            ids::WASM_PACK,
1322            ids::ESP32_TOOLCHAIN,
1323        ] {
1324            assert_eq!(
1325                item(&items, id).status,
1326                CheckStatus::Missing,
1327                "selected {id} must be probed on a bare host"
1328            );
1329        }
1330        if cfg!(target_os = "macos") {
1331            assert_eq!(
1332                item(&items, ids::APPLE_RUST_TARGETS).status,
1333                CheckStatus::Missing
1334            );
1335        }
1336    }
1337
1338    /// An `[backends.esp32]` chip the CLI does not support is a diagnostic,
1339    /// not a skipped item.
1340    #[test]
1341    fn doctor_reports_invalid_esp32_chip() {
1342        let machine = TestMachine::new();
1343        machine.file(
1344            "Water.toml",
1345            &manifest("[backends.esp32]\nchip = \"atmega328p\"\n"),
1346        );
1347        let host = machine.host(Vec::<(String, String)>::new());
1348        let items = smol::block_on(doctor(&host));
1349
1350        let esp32 = item(&items, ids::ESP32_TOOLCHAIN);
1351        assert_eq!(esp32.status, CheckStatus::Missing);
1352        assert!(
1353            esp32
1354                .message
1355                .as_deref()
1356                .is_some_and(|message| message.contains("Invalid")),
1357            "the invalid chip must be diagnosed: {:?}",
1358            esp32.message
1359        );
1360    }
1361
1362    /// A `--fix` pass runs each fixable item's install; a re-diagnosis must
1363    /// then observe the repair — the fix-loop property `water doctor --fix`
1364    /// relies on.
1365    #[test]
1366    #[cfg(unix)]
1367    fn doctor_fix_loop_repairs_pinned_toolchain() {
1368        let machine = TestMachine::new();
1369        machine.file("Water.toml", &manifest(""));
1370        machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1371        for tool in ["rustup", "cargo", "rustc"] {
1372            machine.install(tool);
1373        }
1374        let host = machine.host([
1375            (
1376                String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1377                String::from("1.90"),
1378            ),
1379            (
1380                String::from("WATERUI_FAKE_RUSTC_VERSION"),
1381                String::from("99.0.0"),
1382            ),
1383            (
1384                String::from("WATERUI_FAKE_RUSTC_HOST"),
1385                String::from("x86_64-unknown-fake"),
1386            ),
1387            (
1388                String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1389                String::from("x86_64-unknown-fake"),
1390            ),
1391        ]);
1392
1393        let items = smol::block_on(doctor(&host));
1394        let rust = items
1395            .into_iter()
1396            .find(|item| item.id == ids::RUST)
1397            .expect("rust item");
1398        assert_eq!(rust.status, CheckStatus::Missing);
1399        let install = rust.install_fn.expect("the pin repair must be fixable");
1400        smol::block_on(install()).expect("install must succeed on the fake host");
1401
1402        let items = smol::block_on(doctor(&host));
1403        assert_eq!(
1404            item(&items, ids::RUST).status,
1405            CheckStatus::Ok,
1406            "after `rustup toolchain install 1.90` the rust item must be ok"
1407        );
1408    }
1409
1410    #[test]
1411    #[cfg(unix)]
1412    fn doctor_reports_complete_android_chain_when_fully_staged() {
1413        let machine = TestMachine::new();
1414        let sdk = machine.install_android_sdk();
1415        machine.install_adb();
1416        machine.install_android_platform("android-37.0");
1417        machine.install_android_build_tools("37.0.0");
1418        machine.install_android_ndk("29.0.14206865");
1419        machine.install_android_emulator();
1420        machine.install("rustup");
1421        machine.file("Water.toml", &manifest("[backends.android]\n"));
1422        machine.respond("EMULATOR_AVDS", "Medium_Phone_API_37\n");
1423        machine.respond(
1424            "RUSTUP_ACTIVE_TOOLCHAIN",
1425            "stable-x86_64-unknown-fake (default)",
1426        );
1427        machine.respond(
1428            "RUSTUP_INSTALLED_TARGETS",
1429            &[
1430                "aarch64-linux-android",
1431                "armv7-linux-androideabi",
1432                "i686-linux-android",
1433                "x86_64-linux-android",
1434            ]
1435            .join("\n"),
1436        );
1437        let host = machine.host([(
1438            String::from("ANDROID_SDK_ROOT"),
1439            sdk.as_os_str().to_os_string(),
1440        )]);
1441        let items = smol::block_on(doctor(&host));
1442        for id in [
1443            ids::ANDROID_SDK,
1444            ids::ANDROID_PLATFORM_TOOLS,
1445            ids::ANDROID_SDK_PLATFORMS,
1446            ids::ANDROID_BUILD_TOOLS,
1447            ids::ANDROID_NDK,
1448            ids::ANDROID_RUST_TARGETS,
1449            ids::ANDROID_RUN_TARGETS,
1450        ] {
1451            assert_eq!(
1452                item(&items, id).status,
1453                CheckStatus::Ok,
1454                "{id} must be ok on a fully staged SDK: {:?}",
1455                item(&items, id).message
1456            );
1457        }
1458    }
1459}