1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CheckStatus {
52 Ok,
54 Missing,
56 Skipped,
58}
59
60impl CheckStatus {
61 #[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
72pub type BoxedInstallFn =
74 Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send>> + Send>;
75
76pub struct DoctorItem {
78 pub id: &'static str,
80 pub name: &'static str,
82 pub status: CheckStatus,
84 pub message: Option<String>,
86 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct DoctorItemRecord {
110 #[serde(rename = "type")]
112 pub ty: Cow<'static, str>,
113 pub id: Cow<'static, str>,
115 pub name: Cow<'static, str>,
117 pub status: Cow<'static, str>,
119 pub fixable: bool,
121 #[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 #[must_use]
205 pub const fn is_fixable(&self) -> bool {
206 self.install_fn.is_some()
207 }
208}
209
210pub mod ids {
215 pub const XCODE: &str = "xcode";
217 pub const IOS_SDK: &str = "ios-sdk";
219 pub const IOS_SIMULATOR_SDK: &str = "ios-simulator-sdk";
221 pub const IOS_SIMULATORS: &str = "ios-simulators";
223 pub const MACOS_SDK: &str = "macos-sdk";
225 pub const RUST: &str = "rust";
227 pub const APPLE_RUST_TARGETS: &str = "apple-rust-targets";
229 pub const ANDROID_SDK: &str = "android-sdk";
231 pub const ANDROID_PLATFORM_TOOLS: &str = "android-platform-tools";
233 pub const ANDROID_SDK_PLATFORMS: &str = "android-sdk-platforms";
235 pub const ANDROID_BUILD_TOOLS: &str = "android-build-tools";
237 pub const ANDROID_NDK: &str = "android-ndk";
239 pub const ANDROID_RUST_TARGETS: &str = "android-rust-targets";
241 pub const ANDROID_RUN_TARGETS: &str = "android-run-targets";
243 pub const CMAKE: &str = "cmake";
245 pub const WINDOWS_ARM64_LLVM: &str = "windows-arm64-llvm";
247 pub const JAVA: &str = "java";
249 pub const KOTLIN: &str = "kotlin";
251 pub const WASM32_TARGET: &str = "wasm32-target";
253 pub const WASM_PACK: &str = "wasm-pack";
255 pub const ESP32_TOOLCHAIN: &str = "esp32-toolchain";
258 pub const CARGO_HELPERS: &str = "cargo-helpers";
261 pub const LINUX_SYSTEM_PACKAGES: &str = "linux-system-packages";
263 pub const GTK4: &str = "gtk4";
265 pub const WINUI: &str = "winui";
267 pub const SCCACHE: &str = "sccache";
269 pub const WEB_PACKAGE_MANAGER: &str = "web-package-manager";
271
272 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
316struct ProjectContext {
322 manifest: Option<Manifest>,
323 rust_floor: Version,
324}
325
326impl ProjectContext {
327 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 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
353async 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 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 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
596fn 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
741async 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
781async 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
894pub 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
930async 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
969async 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 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 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 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 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 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 #[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 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 let rust = item(&items, ids::RUST);
1254 assert_eq!(rust.status, CheckStatus::Missing);
1255 assert!(!rust.is_fixable());
1256
1257 let cargo_helpers = item(&items, ids::CARGO_HELPERS);
1259 assert_eq!(cargo_helpers.status, CheckStatus::Missing);
1260 assert!(!cargo_helpers.is_fixable());
1261
1262 #[cfg(target_os = "linux")]
1264 assert!(item(&items, ids::ANDROID_SDK).is_fixable());
1265 }
1266
1267 #[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 #[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 #[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 #[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 #[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}