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//! the [`DoctorGroup`] it is reported under, and whether the backend it
8//! belongs to is in scope for the surrounding project (or, without one, for
9//! the host).
10
11use std::borrow::Cow;
12use std::future::Future;
13use std::path::Path;
14use std::pin::Pin;
15
16use semver::Version;
17
18use crate::platform::TargetBackend;
19use crate::{
20    android::{
21        device::AndroidDevice,
22        platform::AndroidPlatform,
23        toolchain::{
24            AndroidBuildTools, AndroidNdk, AndroidPlatformTools, AndroidRustTargets, AndroidSdk,
25            AndroidSdkPlatforms, Java, Kotlin,
26        },
27    },
28    apple::{
29        device::AppleSimulator,
30        toolchain::{AppleSdk, Xcode},
31    },
32    device::Device,
33    esp32::{chip::Esp32Chip, toolchain::Esp32Toolchain},
34    framework::manifest_rust_version,
35    gtk4::toolchain::Gtk4Toolchain,
36    platform::TargetPlatform,
37    project::{Manifest, PackageType},
38    toolchain::{
39        Host, Installation, Toolchain, ToolchainError, UnfixableToolchain,
40        cargo_helpers::CargoHelpers,
41        cmake::Cmake,
42        dxc::Dxc,
43        linux::LinuxSystemToolchain,
44        msvc::MsvcBuildTools,
45        rust::{CLI_MINIMUM_RUST_VERSION, RustToolchain},
46        sccache::Sccache,
47        web::{PackageManagerToolchain, WasmPack, wasm32_target},
48        windows_arm64_llvm::WindowsArm64LlvmToolchain,
49    },
50    utils::parse_semver_version,
51    winui::toolchain::WinUiToolchain,
52};
53use futures_util::join;
54use serde::{Deserialize, Serialize};
55
56/// Status of a toolchain check.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckStatus {
59    /// Toolchain is available and working.
60    Ok,
61    /// Toolchain is missing or misconfigured.
62    Missing,
63    /// Toolchain check was skipped (e.g., not applicable on this platform).
64    Skipped,
65}
66
67impl CheckStatus {
68    /// The stable `snake_case` label emitted in `--json` records.
69    #[must_use]
70    pub const fn as_str(&self) -> &'static str {
71        match self {
72            Self::Ok => "ok",
73            Self::Missing => "missing",
74            Self::Skipped => "skipped",
75        }
76    }
77}
78
79/// A boxed async function that performs an installation.
80pub type BoxedInstallFn =
81    Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send>> + Send>;
82
83/// The heading an item is reported under.
84///
85/// The Rust toolchain, one backend, or the build helpers. Backends a host
86/// builds for by default come before the optional ones in
87/// [`DoctorGroup::ORDER`], the order a new user needs them.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum DoctorGroup {
90    /// The rustup-managed Rust toolchain every backend builds with.
91    Rust,
92    /// Xcode, Apple SDKs, simulators, and the Apple rustup targets.
93    Apple,
94    /// The Hydrolysis renderer: native desktop prerequisites and the web pieces.
95    Hydrolysis,
96    /// `WinUI` build prerequisites.
97    WinUi,
98    /// GTK4 development libraries.
99    Gtk4,
100    /// The Android SDK chain and its Java/Kotlin/CMake helpers.
101    Android,
102    /// The Espressif toolchain the Dew backend flashes with.
103    Esp32,
104    /// Build accelerators and cargo helper binaries.
105    Helpers,
106}
107
108impl DoctorGroup {
109    /// Every group in report order: Rust, the backends (host-capable ones
110    /// first), then helpers.
111    pub const ORDER: &[Self] = &[
112        Self::Rust,
113        Self::Apple,
114        Self::Hydrolysis,
115        Self::WinUi,
116        Self::Gtk4,
117        Self::Android,
118        Self::Esp32,
119        Self::Helpers,
120    ];
121
122    /// The stable `snake_case` label emitted in `--json` records.
123    #[must_use]
124    pub const fn as_str(&self) -> &'static str {
125        match self {
126            Self::Rust => "rust",
127            Self::Apple => "apple",
128            Self::Hydrolysis => "hydrolysis",
129            Self::WinUi => "winui",
130            Self::Gtk4 => "gtk4",
131            Self::Android => "android",
132            Self::Esp32 => "esp32",
133            Self::Helpers => "helpers",
134        }
135    }
136
137    /// The heading the terminal prints for the group.
138    #[must_use]
139    pub const fn title(&self) -> &'static str {
140        match self {
141            Self::Rust => "Rust toolchain",
142            Self::Apple => "Apple (iOS, macOS)",
143            Self::Hydrolysis => "Hydrolysis (desktop, web)",
144            Self::WinUi => "WinUI",
145            Self::Gtk4 => "GTK4",
146            Self::Android => "Android",
147            Self::Esp32 => "ESP32 (Dew)",
148            Self::Helpers => "Build helpers",
149        }
150    }
151
152    /// The backend the group checks for; `None` for the Rust toolchain and
153    /// the helpers, which are always in scope.
154    #[must_use]
155    pub const fn backend(&self) -> Option<TargetBackend> {
156        match self {
157            Self::Rust | Self::Helpers => None,
158            Self::Apple => Some(TargetBackend::Apple),
159            Self::Hydrolysis => Some(TargetBackend::Hydrolysis),
160            Self::WinUi => Some(TargetBackend::WinUi),
161            Self::Gtk4 => Some(TargetBackend::Gtk4),
162            Self::Android => Some(TargetBackend::Android),
163            Self::Esp32 => Some(TargetBackend::Dew),
164        }
165    }
166
167    /// The group an item id is reported under.
168    ///
169    /// # Panics
170    /// Panics on an id outside [`ids::ALL`]; every item constructor passes a
171    /// constant from that module.
172    #[must_use]
173    pub fn of(id: &str) -> Self {
174        match id {
175            ids::RUST => Self::Rust,
176            ids::XCODE
177            | ids::IOS_SDK
178            | ids::IOS_SIMULATOR_SDK
179            | ids::IOS_SIMULATORS
180            | ids::MACOS_SDK
181            | ids::APPLE_RUST_TARGETS => Self::Apple,
182            ids::LINUX_SYSTEM_PACKAGES
183            | ids::MSVC_BUILD_TOOLS
184            | ids::DXC
185            | ids::WINDOWS_ARM64_LLVM
186            | ids::WASM32_TARGET
187            | ids::WASM_PACK
188            | ids::WEB_PACKAGE_MANAGER => Self::Hydrolysis,
189            ids::WINUI => Self::WinUi,
190            ids::GTK4 => Self::Gtk4,
191            ids::ANDROID_SDK
192            | ids::ANDROID_PLATFORM_TOOLS
193            | ids::ANDROID_SDK_PLATFORMS
194            | ids::ANDROID_BUILD_TOOLS
195            | ids::ANDROID_NDK
196            | ids::ANDROID_RUST_TARGETS
197            | ids::ANDROID_RUN_TARGETS
198            | ids::CMAKE
199            | ids::JAVA
200            | ids::KOTLIN => Self::Android,
201            ids::ESP32_TOOLCHAIN => Self::Esp32,
202            ids::SCCACHE | ids::CARGO_HELPERS => Self::Helpers,
203            other => unreachable!("doctor item id `{other}` is not in `ids::ALL`"),
204        }
205    }
206}
207
208/// One heading of the rendered report: a group, whether it is optional for
209/// the current project or host, and its items in emission order.
210#[derive(Debug)]
211pub struct DoctorSection {
212    /// The group the items belong to.
213    pub group: DoctorGroup,
214    /// `true` when the group's backend is out of scope, so its missing items
215    /// are hints rather than failures.
216    pub optional: bool,
217    /// The group's items, in [`ids::ALL`] order.
218    pub items: Vec<DoctorItem>,
219}
220
221/// Group `items` by [`DoctorGroup`] in report order: in-scope groups in
222/// [`DoctorGroup::ORDER`], then optional groups in the same order, with
223/// helpers last. Groups with no items are omitted.
224#[must_use]
225pub fn sections(items: Vec<DoctorItem>) -> Vec<DoctorSection> {
226    let mut sections: Vec<DoctorSection> = Vec::new();
227    for item in items {
228        match sections
229            .iter_mut()
230            .find(|section| section.group == item.group)
231        {
232            Some(section) => section.items.push(item),
233            None => sections.push(DoctorSection {
234                group: item.group,
235                optional: item.optional,
236                items: vec![item],
237            }),
238        }
239    }
240    let position = |group: DoctorGroup| {
241        DoctorGroup::ORDER
242            .iter()
243            .position(|candidate| *candidate == group)
244            .unwrap_or_else(|| unreachable!("`DoctorGroup::ORDER` lists every group"))
245    };
246    sections.sort_by_key(|section| {
247        (
248            section.group == DoctorGroup::Helpers,
249            section.optional,
250            position(section.group),
251        )
252    });
253    sections
254}
255
256/// A single item in the doctor report.
257pub struct DoctorItem {
258    /// Stable machine-readable identifier (e.g. `android-sdk`).
259    pub id: &'static str,
260    /// Human-readable name of the toolchain or component.
261    pub name: &'static str,
262    /// The heading the item is reported under.
263    pub group: DoctorGroup,
264    /// `true` when the item's backend is out of scope for the current project
265    /// or host: it is reported with its hint but neither fails the run nor
266    /// counts toward `--fix`.
267    pub optional: bool,
268    /// Status of the check.
269    pub status: CheckStatus,
270    /// Optional message with details or suggestions.
271    pub message: Option<String>,
272    /// `true` when running the fix modifies the machine outside `~/.water`
273    /// (a system installer, `msiexec`, ...): `water doctor --fix` asks before
274    /// it runs unless `--yes` was passed or the shell is non-interactive
275    /// without consent semantics. Set from [`Installation::modifies_system`].
276    pub system_wide: bool,
277    /// Optional installation function if the issue can be fixed automatically.
278    pub install_fn: Option<BoxedInstallFn>,
279}
280
281impl std::fmt::Debug for DoctorItem {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("DoctorItem")
284            .field("id", &self.id)
285            .field("name", &self.name)
286            .field("group", &self.group)
287            .field("optional", &self.optional)
288            .field("status", &self.status)
289            .field("message", &self.message)
290            .field("system_wide", &self.system_wide)
291            .field("install_fn", &self.install_fn.as_ref().map(|_| "..."))
292            .finish()
293    }
294}
295
296/// The JSON record emitted for each [`DoctorItem`] by `water doctor --json`.
297///
298/// Lives in the library (not the shell) so integration tests deserialize the
299/// binary's stdout with the same schema the command serializes. Fields are
300/// `Cow` so serialization borrows the static strings while deserialization
301/// (the `--json` smoke test) produces owned values.
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303pub struct DoctorItemRecord {
304    /// Record discriminator, like the shell's other typed records.
305    #[serde(rename = "type")]
306    pub ty: Cow<'static, str>,
307    /// Stable machine-readable item identifier.
308    pub id: Cow<'static, str>,
309    /// Human-readable item name.
310    pub name: Cow<'static, str>,
311    /// The [`DoctorGroup`] label the item is reported under.
312    pub group: Cow<'static, str>,
313    /// Whether the item's backend is out of scope for the project or host.
314    pub optional: bool,
315    /// `ok`, `missing`, or `skipped`.
316    pub status: Cow<'static, str>,
317    /// Whether `--fix` can remediate the item automatically.
318    pub fixable: bool,
319    /// Detail or suggestion shown to the user, when present.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub message: Option<String>,
322}
323
324impl From<&DoctorItem> for DoctorItemRecord {
325    fn from(item: &DoctorItem) -> Self {
326        Self {
327            ty: Cow::Borrowed("doctor-item"),
328            id: Cow::Borrowed(item.id),
329            name: Cow::Borrowed(item.name),
330            group: Cow::Borrowed(item.group.as_str()),
331            optional: item.optional,
332            status: Cow::Borrowed(item.status.as_str()),
333            fixable: item.is_fixable(),
334            message: item.message.clone(),
335        }
336    }
337}
338
339impl DoctorItem {
340    fn ok(id: &'static str, name: &'static str) -> Self {
341        Self {
342            id,
343            name,
344            group: DoctorGroup::of(id),
345            optional: false,
346            status: CheckStatus::Ok,
347            message: None,
348            system_wide: false,
349            install_fn: None,
350        }
351    }
352
353    fn missing(id: &'static str, name: &'static str, message: impl Into<String>) -> Self {
354        Self {
355            id,
356            name,
357            group: DoctorGroup::of(id),
358            optional: false,
359            status: CheckStatus::Missing,
360            message: Some(message.into()),
361            system_wide: false,
362            install_fn: None,
363        }
364    }
365
366    fn fixable<I: Installation + Send + 'static>(
367        id: &'static str,
368        name: &'static str,
369        message: impl Into<String>,
370        installation: I,
371        host: &Host,
372    ) -> Self {
373        let host = host.clone();
374        let system_wide = installation.modifies_system();
375        Self {
376            id,
377            name,
378            group: DoctorGroup::of(id),
379            optional: false,
380            status: CheckStatus::Missing,
381            message: Some(message.into()),
382            system_wide,
383            install_fn: Some(Box::new(move || {
384                Box::pin(async move { installation.install(&host).await.map_err(Into::into) })
385            })),
386        }
387    }
388
389    fn skipped(id: &'static str, name: &'static str) -> Self {
390        Self {
391            id,
392            name,
393            group: DoctorGroup::of(id),
394            optional: false,
395            status: CheckStatus::Skipped,
396            message: None,
397            system_wide: false,
398            install_fn: None,
399        }
400    }
401
402    fn skipped_with_message(
403        id: &'static str,
404        name: &'static str,
405        message: impl Into<String>,
406    ) -> Self {
407        Self {
408            id,
409            name,
410            group: DoctorGroup::of(id),
411            optional: false,
412            status: CheckStatus::Skipped,
413            message: Some(message.into()),
414            system_wide: false,
415            install_fn: None,
416        }
417    }
418
419    /// Returns `true` if the issue can be fixed automatically.
420    #[must_use]
421    pub const fn is_fixable(&self) -> bool {
422        self.install_fn.is_some()
423    }
424}
425
426/// Stable identifiers for every item in the doctor report.
427///
428/// These are the contract asserted by `water doctor --json` consumers and the
429/// integration test; renaming one is a breaking change to that stream.
430pub mod ids {
431    /// `xcodebuild`/`xcode-select` presence.
432    pub const XCODE: &str = "xcode";
433    /// iOS device SDK via `xcrun --sdk iphoneos`.
434    pub const IOS_SDK: &str = "ios-sdk";
435    /// iOS simulator SDK via `xcrun --sdk iphonesimulator`.
436    pub const IOS_SIMULATOR_SDK: &str = "ios-simulator-sdk";
437    /// At least one iOS simulator runtime/device.
438    pub const IOS_SIMULATORS: &str = "ios-simulators";
439    /// macOS SDK via `xcrun --sdk macosx`.
440    pub const MACOS_SDK: &str = "macos-sdk";
441    /// rustup-managed Rust toolchain, version floor, and host target.
442    pub const RUST: &str = "rust";
443    /// iOS device and simulator rustup targets on the selected toolchain.
444    pub const APPLE_RUST_TARGETS: &str = "apple-rust-targets";
445    /// Android SDK root + `sdkmanager`.
446    pub const ANDROID_SDK: &str = "android-sdk";
447    /// `platform-tools` (`adb`).
448    pub const ANDROID_PLATFORM_TOOLS: &str = "android-platform-tools";
449    /// `platforms;android-*` packages (`android.jar`).
450    pub const ANDROID_SDK_PLATFORMS: &str = "android-sdk-platforms";
451    /// `build-tools;*` packages (`d8`).
452    pub const ANDROID_BUILD_TOOLS: &str = "android-build-tools";
453    /// Android NDK + host clang.
454    pub const ANDROID_NDK: &str = "android-ndk";
455    /// rustup Android targets for the configured ABIs.
456    pub const ANDROID_RUST_TARGETS: &str = "android-rust-targets";
457    /// A connected device or an emulator AVD to run on.
458    pub const ANDROID_RUN_TARGETS: &str = "android-run-targets";
459    /// Host `cmake`.
460    pub const CMAKE: &str = "cmake";
461    /// LLVM `clang-cl`/`llvm-lib` for Windows ARM64 assembly deps.
462    pub const WINDOWS_ARM64_LLVM: &str = "windows-arm64-llvm";
463    /// Java runtime for Gradle.
464    pub const JAVA: &str = "java";
465    /// `kotlinc` compiler.
466    pub const KOTLIN: &str = "kotlin";
467    /// `wasm32-unknown-unknown` rustup target.
468    pub const WASM32_TARGET: &str = "wasm32-target";
469    /// `wasm-pack` binary.
470    pub const WASM_PACK: &str = "wasm-pack";
471    /// The Espressif `esp` toolchain, its clang/GCC/`rust-src` pieces, and the
472    /// `espflash`/`ldproxy` helpers an ESP32 build drives.
473    pub const ESP32_TOOLCHAIN: &str = "esp32-toolchain";
474    /// Cargo-installed helper binaries the CLI's workflows invoke
475    /// (`cargo-nextest` for `water bench`).
476    pub const CARGO_HELPERS: &str = "cargo-helpers";
477    /// Distribution packages the Linux backends build against.
478    pub const LINUX_SYSTEM_PACKAGES: &str = "linux-system-packages";
479    /// MSVC C++ build tools (`link.exe`) required to link Windows binaries.
480    pub const MSVC_BUILD_TOOLS: &str = "msvc-build-tools";
481    /// `dxc` (DirectX Shader Compiler) for Hydrolysis shader builds.
482    pub const DXC: &str = "dxc";
483    /// GTK4/pango pkg-config probes.
484    pub const GTK4: &str = "gtk4";
485    /// `WinUI` build prerequisites on Windows hosts.
486    pub const WINUI: &str = "winui";
487    /// `sccache` compile cache.
488    pub const SCCACHE: &str = "sccache";
489    /// The `[web] package_manager` the current project's `Water.toml` declares.
490    pub const WEB_PACKAGE_MANAGER: &str = "web-package-manager";
491
492    /// Every doctor item id in emission order — grouped by
493    /// [`super::DoctorGroup`] in [`super::DoctorGroup::ORDER`].
494    ///
495    /// This is the single source of truth for the report's identity set:
496    /// [`crate::toolchain::doctor::doctor`], the lib-level ordering test, and
497    /// the `water doctor --json` integration test all assert against it.
498    pub const ALL: &[&str] = &[
499        RUST,
500        XCODE,
501        IOS_SDK,
502        IOS_SIMULATOR_SDK,
503        IOS_SIMULATORS,
504        MACOS_SDK,
505        APPLE_RUST_TARGETS,
506        LINUX_SYSTEM_PACKAGES,
507        MSVC_BUILD_TOOLS,
508        DXC,
509        WINDOWS_ARM64_LLVM,
510        WASM32_TARGET,
511        WASM_PACK,
512        WEB_PACKAGE_MANAGER,
513        WINUI,
514        GTK4,
515        ANDROID_SDK,
516        ANDROID_PLATFORM_TOOLS,
517        ANDROID_SDK_PLATFORMS,
518        ANDROID_BUILD_TOOLS,
519        ANDROID_NDK,
520        ANDROID_RUST_TARGETS,
521        ANDROID_RUN_TARGETS,
522        CMAKE,
523        JAVA,
524        KOTLIN,
525        ESP32_TOOLCHAIN,
526        SCCACHE,
527        CARGO_HELPERS,
528    ];
529}
530
531fn unfixable_message(error: &UnfixableToolchain) -> String {
532    format!(
533        "Cannot auto-fix: {}. Next step: {}",
534        error.message(),
535        error.suggestion()
536    )
537}
538
539/// Why a backend is (or is not) checked in the current run.
540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541enum BackendScope {
542    /// The project's `Water.toml` selects the backend (or is a playground).
543    Selected,
544    /// No project surrounds the run and the host can build for the backend.
545    HostDefault,
546    /// Neither: the backend is reported with its hints but does not fail
547    /// the run.
548    Optional,
549}
550
551impl BackendScope {
552    const fn is_in_scope(self) -> bool {
553        matches!(self, Self::Selected | Self::HostDefault)
554    }
555}
556
557/// What `host.cwd()` tells doctor about the surrounding project: the
558/// `Water.toml` manifest when the working directory is a project, and the
559/// Rust floor the `rust` item enforces — the maximum of the CLI's own
560/// `rust-version`, the project's `Cargo.toml` `rust-version`, and the
561/// selected framework's.
562struct ProjectContext {
563    manifest: Option<Manifest>,
564    rust_floor: Version,
565}
566
567impl ProjectContext {
568    /// Whether `backend` is checked in this run and why.
569    ///
570    /// Inside a project the manifest decides: a selected backend (every
571    /// backend, for a playground) is [`BackendScope::Selected`], anything
572    /// else optional. Outside a project the host decides: the backends the
573    /// machine can build for — Apple on macOS, `WinUI` on Windows, GTK4 on
574    /// Linux, Hydrolysis on every desktop host — are
575    /// [`BackendScope::HostDefault`]; Android and Dew need a project to
576    /// select them and are optional everywhere.
577    fn scope(&self, backend: TargetBackend) -> BackendScope {
578        self.manifest.as_ref().map_or_else(
579            || {
580                let host_builds = match backend {
581                    TargetBackend::Apple => cfg!(target_os = "macos"),
582                    TargetBackend::WinUi => cfg!(target_os = "windows"),
583                    TargetBackend::Gtk4 => cfg!(target_os = "linux"),
584                    TargetBackend::Hydrolysis => true,
585                    TargetBackend::Android | TargetBackend::Dew => false,
586                };
587                if host_builds {
588                    BackendScope::HostDefault
589                } else {
590                    BackendScope::Optional
591                }
592            },
593            |manifest| {
594                let selected = match backend {
595                    TargetBackend::Apple => manifest.backends.apple().is_some(),
596                    TargetBackend::Android => manifest.backends.android().is_some(),
597                    TargetBackend::Gtk4 => manifest.backends.gtk4().is_some(),
598                    TargetBackend::Hydrolysis => manifest.backends.hydrolysis().is_some(),
599                    TargetBackend::WinUi => manifest.backends.winui().is_some(),
600                    TargetBackend::Dew => manifest.backends.esp32().is_some(),
601                };
602                if manifest.package.package_type == PackageType::Playground || selected {
603                    BackendScope::Selected
604                } else {
605                    BackendScope::Optional
606                }
607            },
608        )
609    }
610
611    fn in_scope(&self, backend: TargetBackend) -> bool {
612        self.scope(backend).is_in_scope()
613    }
614
615    /// The `skipped` message for a project-gated item whose backend is out
616    /// of scope: names the manifest table that would bring it in.
617    fn out_of_scope_message(&self, backend: &str, table: &str) -> String {
618        if self.manifest.is_some() {
619            format!("No {backend} backend is selected in this project's Water.toml.")
620        } else {
621            format!(
622                "Optional: checked inside a project whose Water.toml has a `[backends.{table}]` section."
623            )
624        }
625    }
626
627    /// The chips the project's ESP32 (Dew) backend can target: the chip
628    /// `[backends.esp32]` declares, or every supported chip for a playground.
629    /// `None` when no project is present or no ESP32 backend is selected.
630    fn esp32_chips(&self) -> Option<eyre::Result<Vec<Esp32Chip>>> {
631        let manifest = self.manifest.as_ref()?;
632        if let Some(backend) = manifest.backends.esp32() {
633            return Some(backend.resolved_chip().map(|chip| vec![chip]));
634        }
635        (manifest.package.package_type == PackageType::Playground).then(|| {
636            Ok(vec![
637                Esp32Chip::Esp32S3,
638                Esp32Chip::Esp32C3,
639                Esp32Chip::Esp32P4,
640            ])
641        })
642    }
643}
644
645/// The `rust-version` a `Cargo.toml` root manifest declares, when it parses.
646async fn cargo_manifest_rust_version(path: &Path) -> Option<Version> {
647    let manifest: toml::Value = toml::from_str(&smol::fs::read_to_string(path).await.ok()?).ok()?;
648    manifest_rust_version(&manifest).ok().flatten()
649}
650
651async fn project_context(host: &Host) -> ProjectContext {
652    let manifest = Manifest::open(host.cwd().join("Water.toml")).await.ok();
653    let mut rust_floor = parse_semver_version(CLI_MINIMUM_RUST_VERSION)
654        .unwrap_or_else(|_| unreachable!("CARGO_PKG_RUST_VERSION is valid semver"));
655    if let Some(manifest) = &manifest {
656        // The project's own `rust-version` and the selected framework's both
657        // raise the floor; a `waterui_path` checkout's root manifest carries
658        // the framework's.
659        if let Some(floor) = cargo_manifest_rust_version(&host.cwd().join("Cargo.toml")).await {
660            rust_floor = rust_floor.max(floor);
661        }
662        let framework_floor = match (&manifest.framework, &manifest.waterui_path) {
663            (Some(framework), _) => framework.rust_version().cloned(),
664            (None, Some(waterui_path)) => {
665                let path = Path::new(waterui_path);
666                let root = if path.is_absolute() {
667                    path.to_path_buf()
668                } else {
669                    host.cwd().join(path)
670                };
671                cargo_manifest_rust_version(&root.join("Cargo.toml")).await
672            }
673            (None, None) => None,
674        };
675        if let Some(floor) = framework_floor {
676            rust_floor = rust_floor.max(floor);
677        }
678    }
679    ProjectContext {
680        manifest,
681        rust_floor,
682    }
683}
684
685async fn toolchain_check<T>(
686    host: &Host,
687    id: &'static str,
688    name: &'static str,
689    fixable_message: &'static str,
690    toolchain: T,
691) -> DoctorItem
692where
693    T: Toolchain,
694    T::Installation: Send + 'static,
695{
696    toolchain_check_with_unfixable(
697        host,
698        id,
699        name,
700        fixable_message,
701        toolchain,
702        unfixable_message,
703    )
704    .await
705}
706
707async fn toolchain_check_with_unfixable<T, F>(
708    host: &Host,
709    id: &'static str,
710    name: &'static str,
711    fixable_message: &'static str,
712    toolchain: T,
713    unfixable_message_fn: F,
714) -> DoctorItem
715where
716    T: Toolchain,
717    T::Installation: Send + 'static,
718    F: FnOnce(&UnfixableToolchain) -> String,
719{
720    match toolchain.check(host).await {
721        Ok(()) => DoctorItem::ok(id, name),
722        Err(ToolchainError::Fixable(installation)) => {
723            DoctorItem::fixable(id, name, fixable_message, installation, host)
724        }
725        Err(ToolchainError::Unfixable(error)) => {
726            DoctorItem::missing(id, name, unfixable_message_fn(&error))
727        }
728    }
729}
730
731/// Xcode, the Apple SDKs, the iOS simulators, and the Apple rustup targets.
732/// The `xcrun` probes are independent and run concurrently.
733async fn apple_checks(host: &Host, project: &ProjectContext) -> Vec<DoctorItem> {
734    if !cfg!(target_os = "macos") {
735        return vec![
736            DoctorItem::skipped(ids::XCODE, "Xcode"),
737            DoctorItem::skipped(ids::IOS_SDK, "iOS SDK"),
738            DoctorItem::skipped(ids::IOS_SIMULATOR_SDK, "iOS Simulator SDK"),
739            DoctorItem::skipped(ids::IOS_SIMULATORS, "iOS Simulators"),
740            DoctorItem::skipped(ids::MACOS_SDK, "macOS SDK"),
741            DoctorItem::skipped_with_message(
742                ids::APPLE_RUST_TARGETS,
743                "Apple Rust targets",
744                "Apple platforms can only be built on macOS.",
745            ),
746        ];
747    }
748
749    let (xcode, ios_sdk, ios_simulator_sdk, ios_simulators, macos_sdk, rust_targets) = join!(
750        Xcode.check(host),
751        AppleSdk::Ios.check(host),
752        AppleSdk::IosSimulator.check(host),
753        ios_simulator_check(host),
754        AppleSdk::Macos.check(host),
755        apple_rust_targets_check(host, project),
756    );
757    vec![
758        simple_check(ids::XCODE, "Xcode", xcode),
759        simple_check(ids::IOS_SDK, "iOS SDK", ios_sdk),
760        simple_check(
761            ids::IOS_SIMULATOR_SDK,
762            "iOS Simulator SDK",
763            ios_simulator_sdk,
764        ),
765        ios_simulators,
766        simple_check(ids::MACOS_SDK, "macOS SDK", macos_sdk),
767        rust_targets,
768    ]
769}
770
771fn simple_check(
772    id: &'static str,
773    name: &'static str,
774    result: Result<(), impl std::fmt::Display>,
775) -> DoctorItem {
776    match result {
777        Ok(()) => DoctorItem::ok(id, name),
778        Err(error) => DoctorItem::missing(id, name, error.to_string()),
779    }
780}
781
782async fn ios_simulator_check(host: &Host) -> DoctorItem {
783    const NAME: &str = "iOS Simulators";
784    match AppleSimulator::scan_ios(host).await {
785        Ok(simulators) if simulators.is_empty() => DoctorItem::missing(
786            ids::IOS_SIMULATORS,
787            NAME,
788            "No iOS simulators available. Install a simulator runtime in Xcode Settings > Platforms.",
789        ),
790        Ok(_) => DoctorItem::ok(ids::IOS_SIMULATORS, NAME),
791        Err(error) => DoctorItem::missing(
792            ids::IOS_SIMULATORS,
793            NAME,
794            format!("Failed to list iOS simulators: {error}"),
795        ),
796    }
797}
798
799/// The iOS device and simulator rustup targets an Apple-backend project
800/// needs on its selected toolchain. (The macOS target is the host triple the
801/// `rust` item already requires.)
802async fn apple_rust_targets_check(host: &Host, project: &ProjectContext) -> DoctorItem {
803    const NAME: &str = "Apple Rust targets";
804    if !project.in_scope(TargetBackend::Apple) {
805        return DoctorItem::skipped_with_message(
806            ids::APPLE_RUST_TARGETS,
807            NAME,
808            project.out_of_scope_message("Apple", "apple"),
809        );
810    }
811    toolchain_check(
812        host,
813        ids::APPLE_RUST_TARGETS,
814        NAME,
815        "Required iOS targets are missing on the selected Rust toolchain",
816        crate::toolchain::rust::SelectedToolchainTargets::new(vec![
817            TargetPlatform::IOS.triple().to_string(),
818            TargetPlatform::IOSSimulator.triple().to_string(),
819        ]),
820    )
821    .await
822}
823
824/// The Android SDK chain. The component probes need `sdkmanager`, so they
825/// follow the SDK probe; the rustup targets, run targets, and the `CMake` /
826/// Java / Kotlin helpers are independent of it and run alongside.
827async fn android_checks(host: &Host, project: &ProjectContext) -> Vec<DoctorItem> {
828    let components = async {
829        let sdk = toolchain_check(
830            host,
831            ids::ANDROID_SDK,
832            "Android SDK",
833            "Android SDK is missing (automatic install is supported on this host)",
834            AndroidSdk,
835        )
836        .await;
837        let mut items = vec![sdk];
838        if AndroidSdk::sdkmanager_path(host).await.is_some() {
839            let components = join!(
840                toolchain_check(
841                    host,
842                    ids::ANDROID_PLATFORM_TOOLS,
843                    "Android Platform-Tools (adb)",
844                    "Required for `water run --platform android`",
845                    AndroidPlatformTools,
846                ),
847                toolchain_check(
848                    host,
849                    ids::ANDROID_SDK_PLATFORMS,
850                    "Android SDK Platforms",
851                    "Required for Android build/package workflows",
852                    AndroidSdkPlatforms,
853                ),
854                toolchain_check(
855                    host,
856                    ids::ANDROID_BUILD_TOOLS,
857                    "Android SDK Build-Tools (d8)",
858                    "Required for Android build/package workflows",
859                    AndroidBuildTools,
860                ),
861                toolchain_check(
862                    host,
863                    ids::ANDROID_NDK,
864                    "Android NDK",
865                    "Required for Android build/package workflows",
866                    AndroidNdk,
867                ),
868            );
869            items.extend(<[DoctorItem; 4]>::from(components));
870        } else {
871            items.extend(blocked_android_component_checks());
872        }
873        items
874    };
875    let rust_targets = async {
876        // The rustup targets only need rustup — they are probed regardless
877        // of SDK state, and only when Android is in scope.
878        if project.in_scope(TargetBackend::Android) {
879            toolchain_check(
880                host,
881                ids::ANDROID_RUST_TARGETS,
882                "Android Rust Targets",
883                "Required for Android Rust cross-compilation",
884                AndroidRustTargets::default(),
885            )
886            .await
887        } else {
888            DoctorItem::skipped_with_message(
889                ids::ANDROID_RUST_TARGETS,
890                "Android Rust Targets",
891                project.out_of_scope_message("Android", "android"),
892            )
893        }
894    };
895    let (mut items, rust_targets, run_targets, cmake, java, kotlin) = join!(
896        components,
897        rust_targets,
898        android_run_target_check(host),
899        toolchain_check(
900            host,
901            ids::CMAKE,
902            "Host CMake",
903            "Required for native Rust dependencies in Android builds",
904            Cmake::default(),
905        ),
906        toolchain_check(
907            host,
908            ids::JAVA,
909            "Java",
910            "Required for Android Gradle builds",
911            Java,
912        ),
913        toolchain_check(
914            host,
915            ids::KOTLIN,
916            "Kotlin",
917            "Required for Android Kotlin helper compilation",
918            Kotlin,
919        ),
920    );
921    items.extend([rust_targets, run_targets, cmake, java, kotlin]);
922    items
923}
924
925/// Items emitted when the SDK is missing, in the same order as the probed
926/// branch above so `--json` ordering does not depend on the diagnosis path.
927/// `android-rust-targets` is deliberately absent: it needs only rustup, so it
928/// is probed (or skipped) independently of the SDK.
929fn blocked_android_component_checks() -> impl Iterator<Item = DoctorItem> {
930    [
931        (ids::ANDROID_PLATFORM_TOOLS, "Android Platform-Tools (adb)"),
932        (ids::ANDROID_SDK_PLATFORMS, "Android SDK Platforms"),
933        (ids::ANDROID_BUILD_TOOLS, "Android SDK Build-Tools (d8)"),
934        (ids::ANDROID_NDK, "Android NDK"),
935    ]
936    .into_iter()
937    .map(|(id, name)| {
938        DoctorItem::missing(
939            id,
940            name,
941            "Blocked: Android SDK / `sdkmanager` is not ready yet. Fix Android SDK first.",
942        )
943    })
944}
945
946async fn android_run_target_check(host: &Host) -> DoctorItem {
947    const NAME: &str = "Android Run Targets";
948    if AndroidSdk::adb_path(host).is_none() {
949        return DoctorItem::missing(
950            ids::ANDROID_RUN_TARGETS,
951            NAME,
952            "Blocked: Android Platform-Tools (`adb`) is not ready yet.",
953        );
954    }
955
956    match AndroidDevice::scan(host).await {
957        Ok(devices) if !devices.is_empty() => DoctorItem::ok(ids::ANDROID_RUN_TARGETS, NAME),
958        Ok(_) => match AndroidPlatform::list_avds(host).await {
959            Ok(avds) if !avds.is_empty() => DoctorItem::ok(ids::ANDROID_RUN_TARGETS, NAME),
960            Ok(_) => DoctorItem::missing(
961                ids::ANDROID_RUN_TARGETS,
962                NAME,
963                "No connected Android devices and no emulator AVDs were found. Connect a device or create an AVD.",
964            ),
965            Err(error) => DoctorItem::missing(
966                ids::ANDROID_RUN_TARGETS,
967                NAME,
968                format!(
969                    "No connected Android devices and failed to list AVDs: {error}. Install Android emulator components or connect a device."
970                ),
971            ),
972        },
973        Err(error) => DoctorItem::missing(
974            ids::ANDROID_RUN_TARGETS,
975            NAME,
976            format!("Failed to query Android devices via adb: {error}"),
977        ),
978    }
979}
980
981/// The Hydrolysis backend beyond the Linux system packages (probed with
982/// GTK4 in [`linux_checks`]): the Windows-native prerequisites (`link.exe`,
983/// `dxc`, the ARM64 LLVM pieces) and the web target's `wasm32` target,
984/// `wasm-pack`, and declared package manager.
985async fn hydrolysis_checks(host: &Host, project: &ProjectContext) -> Vec<DoctorItem> {
986    let windows_arm64_llvm = async {
987        if WindowsArm64LlvmToolchain::required_on_host() {
988            toolchain_check(
989                host,
990                ids::WINDOWS_ARM64_LLVM,
991                "Windows ARM64 LLVM toolchain",
992                "Required by native assembly dependencies in Windows ARM64 hydrolysis builds",
993                WindowsArm64LlvmToolchain,
994            )
995            .await
996        } else {
997            DoctorItem::skipped_with_message(
998                ids::WINDOWS_ARM64_LLVM,
999                "Windows ARM64 LLVM toolchain",
1000                "Only required on Windows ARM64 hosts for native assembly dependencies.",
1001            )
1002        }
1003    };
1004    let web = async {
1005        if project.in_scope(TargetBackend::Hydrolysis) {
1006            join!(
1007                toolchain_check_with_unfixable(
1008                    host,
1009                    ids::WASM32_TARGET,
1010                    "Rust wasm32 target",
1011                    "wasm32-unknown-unknown target not installed",
1012                    wasm32_target(),
1013                    ToString::to_string,
1014                ),
1015                toolchain_check_with_unfixable(
1016                    host,
1017                    ids::WASM_PACK,
1018                    "wasm-pack",
1019                    "wasm-pack not found (required for web packaging)",
1020                    WasmPack,
1021                    ToString::to_string,
1022                ),
1023            )
1024        } else {
1025            (
1026                DoctorItem::skipped_with_message(
1027                    ids::WASM32_TARGET,
1028                    "Rust wasm32 target",
1029                    project.out_of_scope_message("hydrolysis (web)", "hydrolysis"),
1030                ),
1031                DoctorItem::skipped_with_message(
1032                    ids::WASM_PACK,
1033                    "wasm-pack",
1034                    project.out_of_scope_message("hydrolysis (web)", "hydrolysis"),
1035                ),
1036            )
1037        }
1038    };
1039    let ((msvc_build_tools, dxc), windows_arm64_llvm, (wasm32, wasm_pack), web_package_manager) = join!(
1040        windows_checks(host),
1041        windows_arm64_llvm,
1042        web,
1043        web_package_manager_check(host, project)
1044    );
1045    let mut items = vec![msvc_build_tools, dxc, windows_arm64_llvm, wasm32, wasm_pack];
1046    items.extend(web_package_manager);
1047    items
1048}
1049
1050/// The Windows-only prerequisites of a Hydrolysis build: the MSVC C++ build
1051/// tools (linker) and `dxc` (shader compiler). Both are skipped items on
1052/// other hosts.
1053async fn windows_checks(host: &Host) -> (DoctorItem, DoctorItem) {
1054    if cfg!(target_os = "windows") {
1055        join!(
1056            toolchain_check(
1057                host,
1058                ids::MSVC_BUILD_TOOLS,
1059                "MSVC C++ build tools",
1060                "MSVC C++ build tools are missing (`link.exe` is required to link Windows binaries). `--fix` downloads the Visual Studio Build Tools installer and adds the 'C++ build tools' workload (~2 GB download, ~6 GB installed, requires administrator rights and modifies the system outside ~/.water).",
1061                MsvcBuildTools,
1062            ),
1063            toolchain_check(
1064                host,
1065                ids::DXC,
1066                "DirectX Shader Compiler (dxc)",
1067                "dxc is missing (Hydrolysis shader builds invoke it on Windows). `--fix` unpacks a pinned microsoft/DirectXShaderCompiler release into ~/.water/tools.",
1068                Dxc,
1069            ),
1070        )
1071    } else {
1072        (
1073            DoctorItem::skipped_with_message(
1074                ids::MSVC_BUILD_TOOLS,
1075                "MSVC C++ build tools",
1076                "Only required on Windows hosts.",
1077            ),
1078            DoctorItem::skipped_with_message(
1079                ids::DXC,
1080                "DirectX Shader Compiler (dxc)",
1081                "Only required on Windows hosts.",
1082            ),
1083        )
1084    }
1085}
1086
1087/// The Espressif-side toolchain — `esp` Rust fork, clang/GCC, `rust-src`,
1088/// `espflash`/`ldproxy`, QEMU — when the project selects a Dew/ESP32 backend.
1089async fn esp32_check(host: &Host, project: &ProjectContext) -> DoctorItem {
1090    const NAME: &str = "ESP32 toolchain";
1091    let Some(chips) = project.esp32_chips() else {
1092        return DoctorItem::skipped_with_message(
1093            ids::ESP32_TOOLCHAIN,
1094            NAME,
1095            project.out_of_scope_message("ESP32", "esp32"),
1096        );
1097    };
1098    let chips = match chips {
1099        Ok(chips) => chips,
1100        Err(error) => {
1101            return DoctorItem::missing(
1102                ids::ESP32_TOOLCHAIN,
1103                NAME,
1104                format!("Invalid `[backends.esp32]` configuration: {error}"),
1105            );
1106        }
1107    };
1108    match Esp32Toolchain::new(chips).check(host).await {
1109        Ok(()) => DoctorItem::ok(ids::ESP32_TOOLCHAIN, NAME),
1110        Err(ToolchainError::Fixable(installation)) => DoctorItem::fixable(
1111            ids::ESP32_TOOLCHAIN,
1112            NAME,
1113            installation.describe(),
1114            installation,
1115            host,
1116        ),
1117        Err(ToolchainError::Unfixable(error)) => {
1118            DoctorItem::missing(ids::ESP32_TOOLCHAIN, NAME, unfixable_message(&error))
1119        }
1120    }
1121}
1122
1123/// The cargo-installed helper binaries a project's workflows invoke —
1124/// `cargo-nextest` for `water bench`. Platform helpers that are also cargo
1125/// installs (`wasm-pack`, `espflash`/`ldproxy`) are covered by their own
1126/// platform items.
1127async fn cargo_helpers_check(host: &Host) -> DoctorItem {
1128    const NAME: &str = "Cargo helpers";
1129    match CargoHelpers::new(["cargo-nextest"]).check(host).await {
1130        Ok(()) => DoctorItem::ok(ids::CARGO_HELPERS, NAME),
1131        Err(ToolchainError::Fixable(installation)) => DoctorItem::fixable(
1132            ids::CARGO_HELPERS,
1133            NAME,
1134            installation.describe(),
1135            installation,
1136            host,
1137        ),
1138        Err(ToolchainError::Unfixable(error)) => {
1139            DoctorItem::missing(ids::CARGO_HELPERS, NAME, unfixable_message(&error))
1140        }
1141    }
1142}
1143
1144/// The Linux system packages and the GTK4 probe, as
1145/// `(linux_system_packages, gtk4)`: the GTK4 diagnosis names the package
1146/// install when the packages are what is missing.
1147async fn linux_checks(host: &Host) -> (DoctorItem, DoctorItem) {
1148    const PACKAGES: &str = "Linux system packages";
1149    if !cfg!(target_os = "linux") {
1150        return (
1151            DoctorItem::skipped(ids::LINUX_SYSTEM_PACKAGES, PACKAGES),
1152            DoctorItem::skipped(ids::GTK4, "GTK4"),
1153        );
1154    }
1155
1156    let (packages, gtk4) = join!(LinuxSystemToolchain.check(host), Gtk4Toolchain.check(host));
1157    let (packages, packages_fixable) = match packages {
1158        Ok(()) => (DoctorItem::ok(ids::LINUX_SYSTEM_PACKAGES, PACKAGES), false),
1159        Err(ToolchainError::Fixable(installation)) => {
1160            let msg = format!(
1161                "Missing packages for {}: {}. Install command: {}",
1162                installation.package_manager_name(),
1163                installation.missing_packages().join(", "),
1164                installation.install_command_hint(),
1165            );
1166            (
1167                DoctorItem::fixable(
1168                    ids::LINUX_SYSTEM_PACKAGES,
1169                    PACKAGES,
1170                    msg,
1171                    installation,
1172                    host,
1173                ),
1174                true,
1175            )
1176        }
1177        Err(ToolchainError::Unfixable(error)) => (
1178            DoctorItem::missing(
1179                ids::LINUX_SYSTEM_PACKAGES,
1180                PACKAGES,
1181                unfixable_message(&error),
1182            ),
1183            false,
1184        ),
1185    };
1186
1187    let gtk4 = match gtk4 {
1188        Ok(()) => DoctorItem::ok(ids::GTK4, "GTK4"),
1189        Err(ToolchainError::Fixable(installation)) => DoctorItem::fixable(
1190            ids::GTK4,
1191            "GTK4",
1192            "GTK4 dependencies are missing",
1193            installation,
1194            host,
1195        ),
1196        Err(ToolchainError::Unfixable(error)) => {
1197            if packages_fixable {
1198                DoctorItem::missing(
1199                    ids::GTK4,
1200                    "GTK4",
1201                    "GTK4 probe failed because required Linux packages are missing. Run `water doctor --fix` to install Linux system packages, then re-run `water doctor`.",
1202                )
1203            } else {
1204                DoctorItem::missing(ids::GTK4, "GTK4", unfixable_message(&error))
1205            }
1206        }
1207    };
1208    (packages, gtk4)
1209}
1210
1211async fn winui_check(host: &Host) -> DoctorItem {
1212    if !cfg!(target_os = "windows") {
1213        return DoctorItem::skipped(ids::WINUI, "WinUI");
1214    }
1215
1216    toolchain_check(
1217        host,
1218        ids::WINUI,
1219        "WinUI",
1220        "WinUI build prerequisites are missing",
1221        WinUiToolchain,
1222    )
1223    .await
1224}
1225
1226/// Run diagnostics on all toolchains on `host` and return a report.
1227///
1228/// Item order is fixed ([`ids::ALL`]) and platform branching is driven by
1229/// `cfg!` plus the project context `host.cwd()` resolves, so two runs on
1230/// equal hosts in equal projects produce identical item sequences — the
1231/// property the orchestration tests and `--json` consumers rely on. The
1232/// independent probes run concurrently; the report is returned once all of
1233/// them have answered.
1234///
1235/// Which backends are in scope is [`ProjectContext::scope`]'s decision:
1236/// inside a project the manifest's, outside it the host's. Items of an
1237/// out-of-scope backend are marked [`DoctorItem::optional`].
1238pub async fn doctor(host: &Host) -> Vec<DoctorItem> {
1239    let project = project_context(host).await;
1240    let (
1241        rust,
1242        apple,
1243        (linux_system_packages, gtk4),
1244        hydrolysis,
1245        winui,
1246        android,
1247        esp32,
1248        sccache,
1249        cargo_helpers,
1250    ) = join!(
1251        Box::pin(rust_toolchain_check(host, &project)),
1252        Box::pin(apple_checks(host, &project)),
1253        Box::pin(linux_checks(host)),
1254        Box::pin(hydrolysis_checks(host, &project)),
1255        Box::pin(winui_check(host)),
1256        Box::pin(android_checks(host, &project)),
1257        Box::pin(esp32_check(host, &project)),
1258        Box::pin(toolchain_check(
1259            host,
1260            ids::SCCACHE,
1261            "sccache",
1262            "sccache not found (recommended for faster builds)",
1263            Sccache,
1264        )),
1265        Box::pin(cargo_helpers_check(host)),
1266    );
1267
1268    let mut items = vec![rust];
1269    items.extend(apple);
1270    items.push(linux_system_packages);
1271    items.extend(hydrolysis);
1272    items.push(winui);
1273    items.push(gtk4);
1274    items.extend(android);
1275    items.push(esp32);
1276    items.push(sccache);
1277    items.push(cargo_helpers);
1278    for item in &mut items {
1279        item.optional = item
1280            .group
1281            .backend()
1282            .is_some_and(|backend| !project.in_scope(backend));
1283    }
1284    items
1285}
1286
1287/// Checks the `[web] package_manager` the project's `Water.toml` declares.
1288/// Only the declared manager is probed — a project on `pnpm` is never
1289/// reported healthy because `bun` happens to be installed.
1290async fn web_package_manager_check(host: &Host, project: &ProjectContext) -> Option<DoctorItem> {
1291    let web = project
1292        .manifest
1293        .as_ref()
1294        .and_then(|manifest| manifest.web.as_ref())?;
1295    let package_manager = web.package_manager;
1296    let name: &'static str = match package_manager {
1297        crate::web::PackageManager::Bun => "bun (web package manager)",
1298        crate::web::PackageManager::Pnpm => "pnpm (web package manager)",
1299        crate::web::PackageManager::Npm => "npm (web package manager)",
1300        crate::web::PackageManager::Yarn => "yarn (web package manager)",
1301    };
1302    Some(
1303        toolchain_check(
1304            host,
1305            ids::WEB_PACKAGE_MANAGER,
1306            name,
1307            package_manager.install_hint(),
1308            PackageManagerToolchain(package_manager),
1309        )
1310        .await,
1311    )
1312}
1313
1314async fn rust_toolchain_check(host: &Host, project: &ProjectContext) -> DoctorItem {
1315    match RustToolchain::new(&project.rust_floor).check(host).await {
1316        Ok(()) => DoctorItem::ok(ids::RUST, "Rust toolchain"),
1317        Err(ToolchainError::Fixable(installation)) => DoctorItem::fixable(
1318            ids::RUST,
1319            "Rust toolchain",
1320            format!(
1321                "Rust toolchain is missing, outdated, or incomplete. Planned automatic fixes: {}",
1322                installation.summary()
1323            ),
1324            installation,
1325            host,
1326        ),
1327        Err(ToolchainError::Unfixable(error)) => {
1328            DoctorItem::missing(ids::RUST, "Rust toolchain", unfixable_message(&error))
1329        }
1330    }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335    use super::{BackendScope, CheckStatus, DoctorGroup, ProjectContext, doctor, ids, sections};
1336    use crate::platform::TargetBackend;
1337    use crate::toolchain::testing::TestMachine;
1338    use semver::Version;
1339
1340    fn project_less() -> ProjectContext {
1341        ProjectContext {
1342            manifest: None,
1343            rust_floor: Version::new(1, 85, 0),
1344        }
1345    }
1346
1347    fn project_with(extra: &str) -> ProjectContext {
1348        ProjectContext {
1349            manifest: Some(toml::from_str(&manifest(extra)).expect("fixture manifest must parse")),
1350            rust_floor: Version::new(1, 85, 0),
1351        }
1352    }
1353
1354    const ANDROID_COMPONENT_IDS: &[&str] = &[
1355        ids::ANDROID_PLATFORM_TOOLS,
1356        ids::ANDROID_SDK_PLATFORMS,
1357        ids::ANDROID_BUILD_TOOLS,
1358        ids::ANDROID_NDK,
1359    ];
1360
1361    /// A minimal `Water.toml` app manifest; `extra` is appended verbatim
1362    /// (`[backends.*]`, `[web]`, ...).
1363    fn manifest(extra: &str) -> String {
1364        format!(
1365            "[package]\ntype = \"app\"\nname = \"Fixture\"\nbundle_identifier = \"dev.waterui.fixture\"\n\n{extra}"
1366        )
1367    }
1368
1369    fn ids_of(items: &[super::DoctorItem]) -> Vec<&'static str> {
1370        items.iter().map(|item| item.id).collect()
1371    }
1372
1373    fn item<'a>(items: &'a [super::DoctorItem], id: &str) -> &'a super::DoctorItem {
1374        items
1375            .iter()
1376            .find(|item| item.id == id)
1377            .unwrap_or_else(|| panic!("doctor report must contain `{id}`"))
1378    }
1379
1380    #[test]
1381    fn doctor_emits_every_item_in_stable_order() {
1382        let machine = TestMachine::new();
1383        let host = machine.host(Vec::<(String, String)>::new());
1384        let items = smol::block_on(doctor(&host));
1385        // `WEB_PACKAGE_MANAGER` only emits when the current directory's
1386        // `Water.toml` declares a `[web]` section; the test CWD has none.
1387        let expected: Vec<&'static str> = ids::ALL
1388            .iter()
1389            .copied()
1390            .filter(|id| *id != ids::WEB_PACKAGE_MANAGER)
1391            .collect();
1392        assert_eq!(ids_of(&items), expected);
1393    }
1394
1395    #[test]
1396    fn doctor_blocks_android_components_when_sdk_absent() {
1397        let machine = TestMachine::new();
1398        let host = machine.host(Vec::<(String, String)>::new());
1399        let items = smol::block_on(doctor(&host));
1400
1401        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Missing);
1402        for id in ANDROID_COMPONENT_IDS {
1403            let component = item(&items, id);
1404            assert_eq!(component.status, CheckStatus::Missing, "{id}");
1405            assert!(
1406                component
1407                    .message
1408                    .as_deref()
1409                    .is_some_and(|message| message.contains("Blocked")),
1410                "{id} must carry the blocked diagnostic: {:?}",
1411                component.message
1412            );
1413            assert!(
1414                !component.is_fixable(),
1415                "blocked {id} must not offer an install"
1416            );
1417        }
1418
1419        // Without a manifest the Android rust targets are not required, so
1420        // the item is skipped rather than blocked or probed.
1421        assert_eq!(
1422            item(&items, ids::ANDROID_RUST_TARGETS).status,
1423            CheckStatus::Skipped
1424        );
1425
1426        let run_targets = item(&items, ids::ANDROID_RUN_TARGETS);
1427        assert_eq!(run_targets.status, CheckStatus::Missing);
1428        assert!(
1429            run_targets
1430                .message
1431                .as_deref()
1432                .is_some_and(|message| message.contains("Blocked"))
1433        );
1434    }
1435
1436    #[test]
1437    fn doctor_probes_android_components_when_sdk_ready() {
1438        let machine = TestMachine::new();
1439        machine.file("Water.toml", &manifest("[backends.android]\n"));
1440        let sdk = machine.install_android_sdk();
1441        let host = machine.host([(
1442            String::from("ANDROID_SDK_ROOT"),
1443            sdk.as_os_str().to_os_string(),
1444        )]);
1445        let items = smol::block_on(doctor(&host));
1446
1447        assert_eq!(item(&items, ids::ANDROID_SDK).status, CheckStatus::Ok);
1448        for id in ANDROID_COMPONENT_IDS {
1449            let component = item(&items, id);
1450            assert_eq!(component.status, CheckStatus::Missing, "{id}");
1451            assert!(
1452                !component
1453                    .message
1454                    .as_deref()
1455                    .is_some_and(|message| message.contains("Blocked")),
1456                "{id} must be a real diagnosis, not the blocked marker: {:?}",
1457                component.message
1458            );
1459        }
1460
1461        // adb / platforms / build-tools / NDK are installable via sdkmanager.
1462        for id in [
1463            ids::ANDROID_PLATFORM_TOOLS,
1464            ids::ANDROID_SDK_PLATFORMS,
1465            ids::ANDROID_BUILD_TOOLS,
1466            ids::ANDROID_NDK,
1467        ] {
1468            assert!(item(&items, id).is_fixable(), "{id} must be fixable");
1469        }
1470        // The manifest selects the Android backend, so the rustup targets are
1471        // probed; with no rustup on the fake PATH they are unfixable.
1472        let rust_targets = item(&items, ids::ANDROID_RUST_TARGETS);
1473        assert_eq!(rust_targets.status, CheckStatus::Missing);
1474        assert!(!rust_targets.is_fixable());
1475    }
1476
1477    #[test]
1478    fn doctor_apple_items_match_platform() {
1479        let machine = TestMachine::new();
1480        let host = machine.host(Vec::<(String, String)>::new());
1481        let items = smol::block_on(doctor(&host));
1482        for id in [
1483            ids::XCODE,
1484            ids::IOS_SDK,
1485            ids::IOS_SIMULATOR_SDK,
1486            ids::IOS_SIMULATORS,
1487            ids::MACOS_SDK,
1488        ] {
1489            let status = item(&items, id).status;
1490            if cfg!(target_os = "macos") {
1491                assert_eq!(
1492                    status,
1493                    CheckStatus::Missing,
1494                    "{id} is probed on macOS and missing on a bare host"
1495                );
1496            } else {
1497                assert_eq!(
1498                    status,
1499                    CheckStatus::Skipped,
1500                    "{id} must be skipped off macOS"
1501                );
1502            }
1503        }
1504    }
1505
1506    /// The staged `simctl list devices --json` transcript reports one healthy
1507    /// iPhone, so `ios-simulators` comes back `Ok` — the fake `xcrun` must
1508    /// answer the query and the transcript's `dataPath` must exist.
1509    #[test]
1510    #[cfg(target_os = "macos")]
1511    fn doctor_ios_simulators_ok_when_simctl_reports_healthy_device() {
1512        let machine = TestMachine::new();
1513        machine.install("xcrun");
1514        // Retarget the transcript's `/fake/...` paths into the scratch root
1515        // so `data_path.exists()` holds on the declared host.
1516        machine.dir(
1517            "Library/Developer/CoreSimulator/Devices/3E8B0C4F-0000-4000-8000-000000000001/data",
1518        );
1519        let transcript = include_str!("testdata/simctl_devices.json")
1520            .replace("/fake/", &format!("{}/", machine.root().display()));
1521        machine.respond("XCRUN_SIMCTL_DEVICES", &transcript);
1522        let host = machine.host(Vec::<(String, String)>::new());
1523        let items = smol::block_on(doctor(&host));
1524        assert_eq!(
1525            item(&items, ids::IOS_SIMULATORS).status,
1526            CheckStatus::Ok,
1527            "a healthy simctl device must satisfy ios-simulators"
1528        );
1529    }
1530
1531    #[test]
1532    fn doctor_linux_items_match_platform() {
1533        let machine = TestMachine::new();
1534        let host = machine.host(Vec::<(String, String)>::new());
1535        let items = smol::block_on(doctor(&host));
1536        for id in [ids::LINUX_SYSTEM_PACKAGES, ids::GTK4] {
1537            let status = item(&items, id).status;
1538            if cfg!(target_os = "linux") {
1539                assert_eq!(
1540                    status,
1541                    CheckStatus::Missing,
1542                    "{id} is probed on Linux and missing on a bare host"
1543                );
1544            } else {
1545                assert_eq!(
1546                    status,
1547                    CheckStatus::Skipped,
1548                    "{id} must be skipped off Linux"
1549                );
1550            }
1551        }
1552    }
1553
1554    #[test]
1555    fn doctor_windows_llvm_skipped_where_not_required() {
1556        let machine = TestMachine::new();
1557        let host = machine.host(Vec::<(String, String)>::new());
1558        let items = smol::block_on(doctor(&host));
1559        let status = item(&items, ids::WINDOWS_ARM64_LLVM).status;
1560        if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
1561            assert_eq!(status, CheckStatus::Missing);
1562        } else {
1563            assert_eq!(status, CheckStatus::Skipped);
1564        }
1565    }
1566
1567    #[test]
1568    fn doctor_fixable_and_manual_classification() {
1569        let machine = TestMachine::new();
1570        let host = machine.host(Vec::<(String, String)>::new());
1571        let items = smol::block_on(doctor(&host));
1572
1573        // No rust tools at all → manual fix required.
1574        let rust = item(&items, ids::RUST);
1575        assert_eq!(rust.status, CheckStatus::Missing);
1576        assert!(!rust.is_fixable());
1577
1578        // The cargo helpers need `cargo` to install → manual without it.
1579        let cargo_helpers = item(&items, ids::CARGO_HELPERS);
1580        assert_eq!(cargo_helpers.status, CheckStatus::Missing);
1581        assert!(!cargo_helpers.is_fixable());
1582
1583        // On Linux a bare host still plans an SDK install into ~/Android/Sdk.
1584        #[cfg(target_os = "linux")]
1585        assert!(item(&items, ids::ANDROID_SDK).is_fixable());
1586    }
1587
1588    /// With a hydrolysis backend selected and `cargo` on PATH, a missing
1589    /// `wasm-pack` is a `cargo install` away → fixable.
1590    #[test]
1591    fn doctor_wasm_pack_fixable_when_hydrolysis_selected() {
1592        let machine = TestMachine::new();
1593        machine.file("Water.toml", &manifest("[backends.hydrolysis]\n"));
1594        machine.install("cargo");
1595        let host = machine.host(Vec::<(String, String)>::new());
1596        let items = smol::block_on(doctor(&host));
1597
1598        let wasm_pack = item(&items, ids::WASM_PACK);
1599        assert_eq!(wasm_pack.status, CheckStatus::Missing);
1600        assert!(wasm_pack.is_fixable());
1601    }
1602
1603    /// Outside a project the host decides: Hydrolysis everywhere, Apple on
1604    /// macOS, GTK4 on Linux, `WinUI` on Windows; Android and Dew optional.
1605    #[test]
1606    fn scope_outside_a_project_follows_the_host() {
1607        let project = project_less();
1608        assert_eq!(
1609            project.scope(TargetBackend::Hydrolysis),
1610            BackendScope::HostDefault
1611        );
1612        assert_eq!(
1613            project.scope(TargetBackend::Android),
1614            BackendScope::Optional
1615        );
1616        assert_eq!(project.scope(TargetBackend::Dew), BackendScope::Optional);
1617        let host_only = |backend, on_host: bool| {
1618            let expected = if on_host {
1619                BackendScope::HostDefault
1620            } else {
1621                BackendScope::Optional
1622            };
1623            assert_eq!(project.scope(backend), expected, "{backend:?}");
1624        };
1625        host_only(TargetBackend::Apple, cfg!(target_os = "macos"));
1626        host_only(TargetBackend::Gtk4, cfg!(target_os = "linux"));
1627        host_only(TargetBackend::WinUi, cfg!(target_os = "windows"));
1628    }
1629
1630    /// Inside a project the manifest decides exactly as before: selected
1631    /// backends are in scope, the rest optional, and a playground selects all.
1632    #[test]
1633    fn scope_inside_a_project_follows_the_manifest() {
1634        let project = project_with("[backends.android]\n\n[backends.hydrolysis]\n");
1635        assert_eq!(
1636            project.scope(TargetBackend::Android),
1637            BackendScope::Selected
1638        );
1639        assert_eq!(
1640            project.scope(TargetBackend::Hydrolysis),
1641            BackendScope::Selected
1642        );
1643        for backend in [
1644            TargetBackend::Apple,
1645            TargetBackend::Gtk4,
1646            TargetBackend::WinUi,
1647            TargetBackend::Dew,
1648        ] {
1649            assert_eq!(
1650                project.scope(backend),
1651                BackendScope::Optional,
1652                "{backend:?} is not selected"
1653            );
1654        }
1655
1656        let playground = ProjectContext {
1657            manifest: Some(
1658                toml::from_str(
1659                    "[package]\ntype = \"playground\"\nname = \"Fixture\"\nbundle_identifier = \"dev.waterui.fixture\"\n",
1660                )
1661                    .expect("playground manifest must parse"),
1662            ),
1663            rust_floor: Version::new(1, 85, 0),
1664        };
1665        assert_eq!(playground.scope(TargetBackend::Dew), BackendScope::Selected);
1666    }
1667
1668    /// Outside a project the host's backends are probed and Android / ESP32
1669    /// are reported as optional rather than skipped-as-unselected.
1670    #[test]
1671    fn doctor_checks_the_hosts_backends_outside_a_project() {
1672        let machine = TestMachine::new();
1673        let host = machine.host(Vec::<(String, String)>::new());
1674        let items = smol::block_on(doctor(&host));
1675
1676        for id in [ids::WASM32_TARGET, ids::WASM_PACK] {
1677            let hydrolysis = item(&items, id);
1678            assert_eq!(hydrolysis.status, CheckStatus::Missing, "{id}");
1679            assert!(!hydrolysis.optional, "{id} is in scope on every desktop");
1680        }
1681        let apple_targets = item(&items, ids::APPLE_RUST_TARGETS);
1682        if cfg!(target_os = "macos") {
1683            assert_eq!(apple_targets.status, CheckStatus::Missing);
1684            assert!(!apple_targets.optional);
1685        } else {
1686            assert_eq!(apple_targets.status, CheckStatus::Skipped);
1687        }
1688        if cfg!(target_os = "linux") {
1689            assert!(!item(&items, ids::GTK4).optional);
1690        }
1691        for id in [
1692            ids::ANDROID_SDK,
1693            ids::ANDROID_RUST_TARGETS,
1694            ids::ESP32_TOOLCHAIN,
1695        ] {
1696            assert!(
1697                item(&items, id).optional,
1698                "{id} is optional without a project"
1699            );
1700        }
1701        assert_eq!(
1702            item(&items, ids::ESP32_TOOLCHAIN).status,
1703            CheckStatus::Skipped
1704        );
1705    }
1706
1707    /// The terminal groups follow the new-user order: Rust, the host's
1708    /// backends, optional backends, helpers — with every in-scope backend
1709    /// ahead of every optional one.
1710    #[test]
1711    fn sections_order_rust_host_optional_helpers() {
1712        let machine = TestMachine::new();
1713        let host = machine.host(Vec::<(String, String)>::new());
1714        let sections = sections(smol::block_on(doctor(&host)));
1715        let groups: Vec<DoctorGroup> = sections.iter().map(|section| section.group).collect();
1716        assert_eq!(groups.first(), Some(&DoctorGroup::Rust));
1717        assert_eq!(groups.last(), Some(&DoctorGroup::Helpers));
1718        let first_optional = sections.iter().position(|section| section.optional);
1719        let last_required_backend = sections
1720            .iter()
1721            .rposition(|section| !section.optional && section.group.backend().is_some());
1722        if let (Some(first_optional), Some(last_required)) = (first_optional, last_required_backend)
1723        {
1724            assert!(last_required < first_optional);
1725        }
1726        assert!(
1727            sections
1728                .iter()
1729                .find(|section| section.group == DoctorGroup::Android)
1730                .is_some_and(|section| section.optional)
1731        );
1732        assert!(
1733            sections
1734                .iter()
1735                .find(|section| section.group == DoctorGroup::Hydrolysis)
1736                .is_some_and(|section| !section.optional)
1737        );
1738    }
1739
1740    /// Every platform the manifest selects is probed, even on a bare host.
1741    #[test]
1742    fn doctor_probes_the_backends_a_manifest_selects() {
1743        let machine = TestMachine::new();
1744        machine.file(
1745            "Water.toml",
1746            &manifest(
1747                "[backends.android]\n\n[backends.hydrolysis]\n\n[backends.esp32]\nchip = \"esp32c3\"\n\n[backends.apple]\nscheme = \"Fixture\"\n",
1748            ),
1749        );
1750        let host = machine.host(Vec::<(String, String)>::new());
1751        let items = smol::block_on(doctor(&host));
1752
1753        for id in [
1754            ids::ANDROID_RUST_TARGETS,
1755            ids::WASM32_TARGET,
1756            ids::WASM_PACK,
1757            ids::ESP32_TOOLCHAIN,
1758        ] {
1759            assert_eq!(
1760                item(&items, id).status,
1761                CheckStatus::Missing,
1762                "selected {id} must be probed on a bare host"
1763            );
1764        }
1765        if cfg!(target_os = "macos") {
1766            assert_eq!(
1767                item(&items, ids::APPLE_RUST_TARGETS).status,
1768                CheckStatus::Missing
1769            );
1770        }
1771    }
1772
1773    /// An `[backends.esp32]` chip the CLI does not support is a diagnostic,
1774    /// not a skipped item.
1775    #[test]
1776    fn doctor_reports_invalid_esp32_chip() {
1777        let machine = TestMachine::new();
1778        machine.file(
1779            "Water.toml",
1780            &manifest("[backends.esp32]\nchip = \"atmega328p\"\n"),
1781        );
1782        let host = machine.host(Vec::<(String, String)>::new());
1783        let items = smol::block_on(doctor(&host));
1784
1785        let esp32 = item(&items, ids::ESP32_TOOLCHAIN);
1786        assert_eq!(esp32.status, CheckStatus::Missing);
1787        assert!(
1788            esp32
1789                .message
1790                .as_deref()
1791                .is_some_and(|message| message.contains("Invalid")),
1792            "the invalid chip must be diagnosed: {:?}",
1793            esp32.message
1794        );
1795    }
1796
1797    /// A `--fix` pass runs each fixable item's install; a re-diagnosis must
1798    /// then observe the repair — the fix-loop property `water doctor --fix`
1799    /// relies on.
1800    #[test]
1801    #[cfg(unix)]
1802    fn doctor_fix_loop_repairs_pinned_toolchain() {
1803        let machine = TestMachine::new();
1804        machine.file("Water.toml", &manifest(""));
1805        machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1806        for tool in ["rustup", "cargo", "rustc"] {
1807            machine.install(tool);
1808        }
1809        let host = machine.host([
1810            (
1811                String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1812                String::from("1.90"),
1813            ),
1814            (
1815                String::from("WATERUI_FAKE_RUSTC_VERSION"),
1816                String::from("99.0.0"),
1817            ),
1818            (
1819                String::from("WATERUI_FAKE_RUSTC_HOST"),
1820                String::from("x86_64-unknown-fake"),
1821            ),
1822            (
1823                String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1824                String::from("x86_64-unknown-fake"),
1825            ),
1826        ]);
1827
1828        let items = smol::block_on(doctor(&host));
1829        let rust = items
1830            .into_iter()
1831            .find(|item| item.id == ids::RUST)
1832            .expect("rust item");
1833        assert_eq!(rust.status, CheckStatus::Missing);
1834        let install = rust.install_fn.expect("the pin repair must be fixable");
1835        smol::block_on(install()).expect("install must succeed on the fake host");
1836
1837        let items = smol::block_on(doctor(&host));
1838        assert_eq!(
1839            item(&items, ids::RUST).status,
1840            CheckStatus::Ok,
1841            "after `rustup toolchain install 1.90` the rust item must be ok"
1842        );
1843    }
1844
1845    #[test]
1846    #[cfg(unix)]
1847    fn doctor_reports_complete_android_chain_when_fully_staged() {
1848        let machine = TestMachine::new();
1849        let sdk = machine.install_android_sdk();
1850        machine.install_adb();
1851        machine.install_android_platform("android-37.0");
1852        machine.install_android_build_tools("37.0.0");
1853        machine.install_android_ndk("29.0.14206865");
1854        machine.install_android_emulator();
1855        machine.install("rustup");
1856        machine.file("Water.toml", &manifest("[backends.android]\n"));
1857        machine.respond("EMULATOR_AVDS", "Medium_Phone_API_37\n");
1858        machine.respond(
1859            "RUSTUP_ACTIVE_TOOLCHAIN",
1860            "stable-x86_64-unknown-fake (default)",
1861        );
1862        machine.respond(
1863            "RUSTUP_INSTALLED_TARGETS",
1864            &[
1865                "aarch64-linux-android",
1866                "armv7-linux-androideabi",
1867                "i686-linux-android",
1868                "x86_64-linux-android",
1869            ]
1870            .join("\n"),
1871        );
1872        let host = machine.host([(
1873            String::from("ANDROID_SDK_ROOT"),
1874            sdk.as_os_str().to_os_string(),
1875        )]);
1876        let items = smol::block_on(doctor(&host));
1877        for id in [
1878            ids::ANDROID_SDK,
1879            ids::ANDROID_PLATFORM_TOOLS,
1880            ids::ANDROID_SDK_PLATFORMS,
1881            ids::ANDROID_BUILD_TOOLS,
1882            ids::ANDROID_NDK,
1883            ids::ANDROID_RUST_TARGETS,
1884            ids::ANDROID_RUN_TARGETS,
1885        ] {
1886            assert_eq!(
1887                item(&items, id).status,
1888                CheckStatus::Ok,
1889                "{id} must be ok on a fully staged SDK: {:?}",
1890                item(&items, id).message
1891            );
1892        }
1893    }
1894}