1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckStatus {
59 Ok,
61 Missing,
63 Skipped,
65}
66
67impl CheckStatus {
68 #[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
79pub type BoxedInstallFn =
81 Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = eyre::Result<()>> + Send>> + Send>;
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub enum DoctorGroup {
90 Rust,
92 Apple,
94 Hydrolysis,
96 WinUi,
98 Gtk4,
100 Android,
102 Esp32,
104 Helpers,
106}
107
108impl DoctorGroup {
109 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 #[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 #[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 #[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 #[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#[derive(Debug)]
211pub struct DoctorSection {
212 pub group: DoctorGroup,
214 pub optional: bool,
217 pub items: Vec<DoctorItem>,
219}
220
221#[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
256pub struct DoctorItem {
258 pub id: &'static str,
260 pub name: &'static str,
262 pub group: DoctorGroup,
264 pub optional: bool,
268 pub status: CheckStatus,
270 pub message: Option<String>,
272 pub system_wide: bool,
277 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303pub struct DoctorItemRecord {
304 #[serde(rename = "type")]
306 pub ty: Cow<'static, str>,
307 pub id: Cow<'static, str>,
309 pub name: Cow<'static, str>,
311 pub group: Cow<'static, str>,
313 pub optional: bool,
315 pub status: Cow<'static, str>,
317 pub fixable: bool,
319 #[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 #[must_use]
421 pub const fn is_fixable(&self) -> bool {
422 self.install_fn.is_some()
423 }
424}
425
426pub mod ids {
431 pub const XCODE: &str = "xcode";
433 pub const IOS_SDK: &str = "ios-sdk";
435 pub const IOS_SIMULATOR_SDK: &str = "ios-simulator-sdk";
437 pub const IOS_SIMULATORS: &str = "ios-simulators";
439 pub const MACOS_SDK: &str = "macos-sdk";
441 pub const RUST: &str = "rust";
443 pub const APPLE_RUST_TARGETS: &str = "apple-rust-targets";
445 pub const ANDROID_SDK: &str = "android-sdk";
447 pub const ANDROID_PLATFORM_TOOLS: &str = "android-platform-tools";
449 pub const ANDROID_SDK_PLATFORMS: &str = "android-sdk-platforms";
451 pub const ANDROID_BUILD_TOOLS: &str = "android-build-tools";
453 pub const ANDROID_NDK: &str = "android-ndk";
455 pub const ANDROID_RUST_TARGETS: &str = "android-rust-targets";
457 pub const ANDROID_RUN_TARGETS: &str = "android-run-targets";
459 pub const CMAKE: &str = "cmake";
461 pub const WINDOWS_ARM64_LLVM: &str = "windows-arm64-llvm";
463 pub const JAVA: &str = "java";
465 pub const KOTLIN: &str = "kotlin";
467 pub const WASM32_TARGET: &str = "wasm32-target";
469 pub const WASM_PACK: &str = "wasm-pack";
471 pub const ESP32_TOOLCHAIN: &str = "esp32-toolchain";
474 pub const CARGO_HELPERS: &str = "cargo-helpers";
477 pub const LINUX_SYSTEM_PACKAGES: &str = "linux-system-packages";
479 pub const MSVC_BUILD_TOOLS: &str = "msvc-build-tools";
481 pub const DXC: &str = "dxc";
483 pub const GTK4: &str = "gtk4";
485 pub const WINUI: &str = "winui";
487 pub const SCCACHE: &str = "sccache";
489 pub const WEB_PACKAGE_MANAGER: &str = "web-package-manager";
491
492 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541enum BackendScope {
542 Selected,
544 HostDefault,
546 Optional,
549}
550
551impl BackendScope {
552 const fn is_in_scope(self) -> bool {
553 matches!(self, Self::Selected | Self::HostDefault)
554 }
555}
556
557struct ProjectContext {
563 manifest: Option<Manifest>,
564 rust_floor: Version,
565}
566
567impl ProjectContext {
568 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 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 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
645async 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 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
731async 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
799async 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
824async 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 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
925fn 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
981async 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
1050async 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
1087async 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
1123async 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
1144async 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
1226pub 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
1287async 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 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 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 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 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 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 #[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 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 let rust = item(&items, ids::RUST);
1575 assert_eq!(rust.status, CheckStatus::Missing);
1576 assert!(!rust.is_fixable());
1577
1578 let cargo_helpers = item(&items, ids::CARGO_HELPERS);
1580 assert_eq!(cargo_helpers.status, CheckStatus::Missing);
1581 assert!(!cargo_helpers.is_fixable());
1582
1583 #[cfg(target_os = "linux")]
1585 assert!(item(&items, ids::ANDROID_SDK).is_fixable());
1586 }
1587
1588 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}